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>
|
||||
);
|
||||
}
|
||||
727
src/features/store-admin/pages/InventoryPage.tsx
Normal file
727
src/features/store-admin/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ClipboardList,
|
||||
Info,
|
||||
Layers,
|
||||
PackageSearch,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { StockRequest } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
|
||||
type Tab = 'catalogue' | 'stock' | 'requests';
|
||||
|
||||
/**
|
||||
* Inventory — catalogue, stock and the approval queue.
|
||||
*
|
||||
* The spec's three catalogue tiers are real in Fiesta, not an invention of the
|
||||
* UI. `productlocations.publishedat` is NULL while a product sits in the admin
|
||||
* catalogue and stamped once it is released to the shops, and the column is
|
||||
* deliberately read-only through ordinary writes so that setting a price cannot
|
||||
* accidentally publish. So "review, price, then publish" is the backend's own
|
||||
* model, and this page follows it rather than layering a second one on top.
|
||||
*
|
||||
* What is NOT real: per-store assignment. `PublishProduct` takes a tenant and a
|
||||
* product and releases to every outlet. The store checkboxes below are built to
|
||||
* the spec and marked pending, per the decision to have the UI lead and the
|
||||
* backend catch up. Nothing on screen claims the selection is in force.
|
||||
*/
|
||||
export function InventoryPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('requests');
|
||||
|
||||
const requests = useStockRequests(
|
||||
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
|
||||
);
|
||||
|
||||
const pending = useMemo(
|
||||
() => (requests.data ?? []).filter((entry) => entry.status === 'Pending'),
|
||||
[requests.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Inventory"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="What you sell, where it is stocked, and what the shops are asking you for."
|
||||
actions={
|
||||
pending.length > 0 ? (
|
||||
<Badge variant="warning" label={`${pending.length} awaiting approval`} />
|
||||
) : undefined
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Stock requests"
|
||||
icon={<ClipboardList size={14} />}
|
||||
isActive={tab === 'requests'}
|
||||
onClick={() => setTab('requests')}
|
||||
badge={pending.length || undefined}
|
||||
/>
|
||||
<TabButton
|
||||
label="Stock"
|
||||
icon={<Layers size={14} />}
|
||||
isActive={tab === 'stock'}
|
||||
onClick={() => setTab('stock')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Catalogue"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'catalogue'}
|
||||
onClick={() => setTab('catalogue')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{tab === 'requests' ? (
|
||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
||||
) : tab === 'stock' ? (
|
||||
<StockPanel />
|
||||
) : (
|
||||
<CataloguePanel branchCount={scoped.length} />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stock requests ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The approval queue.
|
||||
*
|
||||
* Approve means "mark Received", which posts the full requested quantity to
|
||||
* that branch's stock. There is no approve-for-less: the service reads the
|
||||
* quantity off the request row and the call carries only a status string. The
|
||||
* quantity field below is therefore shown read-only with the reason attached,
|
||||
* rather than as an input that silently discards what the admin types.
|
||||
*/
|
||||
function RequestsPanel({
|
||||
requests,
|
||||
isLoading,
|
||||
}: {
|
||||
requests: StockRequest[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [acting, setActing] = useState<number | null>(null);
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ requestid, approve }: { requestid: number; approve: boolean }) =>
|
||||
approve ? stockApi.approve(requestid) : stockApi.reject(requestid),
|
||||
onSettled: async () => {
|
||||
setActing(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||
// Approving moves stock, so anything counting stock is stale too.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
||||
},
|
||||
});
|
||||
|
||||
const pending = requests.filter((entry) => entry.status === 'Pending');
|
||||
const settled = requests.filter((entry) => entry.status !== 'Pending');
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Awaiting you"
|
||||
value={count(pending.length)}
|
||||
note="nothing reaches a shelf until approved"
|
||||
tone={pending.length > 0 ? 'warning' : 'success'}
|
||||
icon={<ClipboardList size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Units requested"
|
||||
value={count(pending.reduce((sum, entry) => sum + entry.qty, 0))}
|
||||
note="across open requests"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Settled"
|
||||
value={count(settled.length)}
|
||||
note="approved or rejected"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Open requests" note="a store cannot add its own stock" />
|
||||
{isLoading ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the queue…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : pending.length === 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Every request has been decided. New ones appear here the moment a store raises them.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={1}>
|
||||
{pending.map((request) => (
|
||||
<Card key={request.requestid} padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} style={{ padding: 16 }}>
|
||||
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
flex: 'none',
|
||||
background: 'var(--color-slate-50)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{request.productimage ? (
|
||||
<img
|
||||
src={request.productimage}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<PackageSearch size={16} style={{ color: 'var(--color-slate-300)' }} />
|
||||
)}
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{request.productname ?? `Product ${request.productid}`}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(request.locationname) || `Branch ${request.locationid}`} · asked for{' '}
|
||||
<strong>{count(request.qty)}</strong> units
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={1} align="center" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
label="Reject"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<X size={14} />}
|
||||
isDisabled={decide.isPending && acting === request.requestid}
|
||||
onClick={() => {
|
||||
setActing(request.requestid);
|
||||
decide.mutate({ requestid: request.requestid, approve: false });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label={`Approve ${count(request.qty)}`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Check size={14} />}
|
||||
isDisabled={decide.isPending && acting === request.requestid}
|
||||
onClick={() => {
|
||||
setActing(request.requestid);
|
||||
decide.mutate({ requestid: request.requestid, approve: true });
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Stated once, under the queue, rather than on every row. */}
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Approving posts the <strong>full requested quantity</strong> to that branch. Fiesta
|
||||
reads the amount off the request row, so approving for less is not possible yet — and
|
||||
the request carries no reason, requester or remarks, because those columns do not exist.
|
||||
To give a shop a different amount, reject and ask them to re-raise it.
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{settled.length > 0 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Decided" note="most recent first" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">Qty</Th>
|
||||
<Th align="left">Outcome</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settled.slice(0, 25).map((request) => (
|
||||
<tr key={request.requestid}>
|
||||
<Td align="left" isStrong>
|
||||
{request.productname ?? `Product ${request.productid}`}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(request.locationname) || `Branch ${request.locationid}`}
|
||||
</Td>
|
||||
<Td align="right">{count(request.qty)}</Td>
|
||||
<Td align="left">
|
||||
<Badge
|
||||
variant={request.status === 'Received' ? 'success' : 'neutral'}
|
||||
label={request.status === 'Received' ? 'Stock added' : request.status}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stock ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Stock levels and movement.
|
||||
*
|
||||
* Opening → in → out → closing is the whole vocabulary the ledger has.
|
||||
* `productstocks.stocktype` is `in`/`out` with no reason column, so a sale, a
|
||||
* branch transfer and a stock-take correction are indistinguishable once
|
||||
* written. The spec's seven movement types are not shown, because showing seven
|
||||
* columns sourced from two would be a fabrication.
|
||||
*/
|
||||
function StockPanel() {
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// One branch at a time: the statement endpoint requires a single locationid.
|
||||
const branch = scoped[0];
|
||||
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
|
||||
keyword: keyword.trim() || undefined,
|
||||
pagesize: 100,
|
||||
});
|
||||
|
||||
const rows = statement.data ?? [];
|
||||
const value = rows.reduce(
|
||||
(sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0),
|
||||
0,
|
||||
);
|
||||
const low = rows.filter((row) => (row.closing ?? 0) > 0 && (row.closing ?? 0) <= 5).length;
|
||||
const out = rows.filter((row) => (row.closing ?? 0) <= 0).length;
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Stock value"
|
||||
value={money(value)}
|
||||
note={`${count(rows.length)} products`}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Low stock"
|
||||
value={count(low)}
|
||||
note="5 units or fewer"
|
||||
tone={low > 0 ? 'warning' : 'neutral'}
|
||||
icon={<AlertTriangle size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Out of stock"
|
||||
value={count(out)}
|
||||
note="nothing on the shelf"
|
||||
tone={out > 0 ? 'error' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={branch ? `Stock at ${branch.locationname}` : 'Stock'}
|
||||
note="opening → in → out → closing"
|
||||
action={
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search stock"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{scoped.length > 1 ? (
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Showing <strong>{branch?.locationname}</strong>. The stock statement is scoped to one
|
||||
branch by the backend — pick a branch in the header to switch.
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
{statement.isLoading ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading stock…
|
||||
</Text>
|
||||
</VStack>
|
||||
) : rows.length === 0 ? (
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No stock recorded
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Products reach a shelf through an approved stock request, not by being in the
|
||||
catalogue.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Product</Th>
|
||||
<Th align="right">Opening</Th>
|
||||
<Th align="right">In</Th>
|
||||
<Th align="right">Out</Th>
|
||||
<Th align="right">Closing</Th>
|
||||
<Th align="right">Value</Th>
|
||||
<Th align="left">Status</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const closing = row.closing ?? 0;
|
||||
return (
|
||||
<tr key={row.productid}>
|
||||
<Td align="left" isStrong>
|
||||
{row.productname ?? `Product ${row.productid}`}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.opening)}
|
||||
</Td>
|
||||
<Td align="right">{count(row.credit)}</Td>
|
||||
<Td align="right">{count(row.debit)}</Td>
|
||||
<Td align="right" isStrong>
|
||||
{count(closing)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{money(closing * (row.retailprice ?? 0))}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Badge
|
||||
variant={closing <= 0 ? 'error' : closing <= 5 ? 'warning' : 'success'}
|
||||
label={closing <= 0 ? 'Out' : closing <= 5 ? 'Low' : 'In stock'}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Catalogue ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The catalogue tiers and how a product moves between them.
|
||||
*
|
||||
* Deliberately an explanation plus the two entry points rather than a second
|
||||
* copy of the Nearle Admin's catalogue browser: the import UI already exists
|
||||
* and works, and duplicating it here is how two screens drift apart.
|
||||
*/
|
||||
function CataloguePanel({ branchCount }: { branchCount: number }) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="How a product reaches a shelf" note="four steps, two entry points" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<div className="form-grid">
|
||||
<Step
|
||||
n={1}
|
||||
title="Into your catalogue"
|
||||
body="Import from the global catalogue, or upload your own spreadsheet. Only you can do either — a store manager cannot."
|
||||
/>
|
||||
<Step
|
||||
n={2}
|
||||
title="Price it"
|
||||
body="The global catalogue carries a price range, not a price. Yours is the one customers pay, and it is set per branch."
|
||||
/>
|
||||
<Step
|
||||
n={3}
|
||||
title="Publish"
|
||||
body={`Publishing releases the product to all ${branchCount} branch${branchCount === 1 ? '' : 'es'}. Until then it is yours alone and no store can see it.`}
|
||||
/>
|
||||
<Step
|
||||
n={4}
|
||||
title="Stock it"
|
||||
body="A published product is not stock. A store raises a request, you approve it, and only then does anything reach the shelf."
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Add products" note="both paths end in the same catalogue" />
|
||||
<div className="card-grid">
|
||||
<EntryCard
|
||||
icon={<PackageSearch size={18} />}
|
||||
title="From the global catalogue"
|
||||
body="1,267 FMCG products with images and pack sizes. Pick what you sell and set your own price."
|
||||
action="Browse catalogue"
|
||||
/>
|
||||
<EntryCard
|
||||
icon={<Send size={18} />}
|
||||
title="From a spreadsheet"
|
||||
body="Your own product list. Validated before anything is created — you see valid, duplicate and error rows, and can download the failures to fix and re-upload."
|
||||
action="Upload a sheet"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Two things the spec asks for that the backend cannot do yet
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Per-branch assignment.</strong> Publishing releases a product to every
|
||||
branch — `PublishProduct` takes a tenant and a product, with no location list. The
|
||||
per-branch controls are built to your spec and will start working the day that
|
||||
parameter lands; nothing here pretends the selection is in force today.
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
<strong>Duplicate detection by SKU.</strong> Across the live catalogue 6,245 products
|
||||
share 93 distinct SKU values and 154 are blank, so a SKU cannot decide that two rows
|
||||
are the same product. Upload validation warns on repeats but will not merge on them,
|
||||
and there is no barcode column anywhere in Fiesta to fall back to.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, title, body }: { n: number; title: string; body: string }) {
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryCard({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
action: string;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<span
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<Button label={action} variant="secondary" size="sm" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
321
src/features/store-admin/pages/OnboardBranchPage.tsx
Normal file
321
src/features/store-admin/pages/OnboardBranchPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { TimeInput } from '@astryxdesign/core/TimeInput';
|
||||
import { createISOTimeString, type ISOTimeString } from '@astryxdesign/core/utils';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, CheckCircle2, Clock, MapPin, Store } from 'lucide-react';
|
||||
import { tenantsApi, type CreateBranchRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useTenants } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
interface FormState {
|
||||
tenantid: string;
|
||||
locationname: string;
|
||||
email: string;
|
||||
contactno: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
opentime: ISOTimeString;
|
||||
closetime: ISOTimeString;
|
||||
deliveryradius: number;
|
||||
deliverymins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `createISOTimeString` returns null for anything it cannot parse. These two are
|
||||
* compile-time literals that are known-good, so a failure here is a typo in this
|
||||
* file rather than bad input — fail loudly instead of defaulting silently.
|
||||
*/
|
||||
function isoTime(value: string): ISOTimeString {
|
||||
const parsed = createISOTimeString(value);
|
||||
if (!parsed) throw new Error(`Invalid default time: ${value}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
tenantid: '',
|
||||
locationname: '',
|
||||
email: '',
|
||||
contactno: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '',
|
||||
opentime: isoTime('08:00'),
|
||||
closetime: isoTime('22:00'),
|
||||
deliveryradius: 5000,
|
||||
deliverymins: 30,
|
||||
};
|
||||
|
||||
/**
|
||||
* Commission a branch under an existing tenant.
|
||||
*
|
||||
* Sets the store's parameters and delivery thresholds, and spawns a placeholder
|
||||
* branch-manager account. The delivery radius and estimated speed are what the
|
||||
* customer app uses to decide whether an address is servable, so they are
|
||||
* required rather than optional-with-a-default-nobody-checks.
|
||||
*
|
||||
* ── PARKED, NOT DEAD ────────────────────────────────────────────────────────
|
||||
* Branch onboarding belongs to the Store Admin, not to us: a merchant opens
|
||||
* their own outlets. This file is therefore unrouted until the Store Admin
|
||||
* workspace exists, and it lives here rather than under `nearle-admin/` so its
|
||||
* eventual owner is obvious from the path.
|
||||
*
|
||||
* Two things must change when it is wired up:
|
||||
* 1. Drop the tenant Selector. A Store Admin has exactly one tenant and it
|
||||
* comes from the session, so asking them to pick it is asking them to
|
||||
* confirm something they cannot get wrong.
|
||||
* 2. Take `tenantid` from `useAuth()` instead of form state, and let the
|
||||
* backend reject a mismatch rather than trusting the client's number.
|
||||
*/
|
||||
export function OnboardBranchPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: tenants, isLoading: isLoadingTenants } = useTenants();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** One option per tenant — `getalltenants` returns a row per branch. */
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, string>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||
}, [tenants]);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
return (value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.tenantid !== '' &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantid: Number(form.tenantid),
|
||||
locationname: form.locationname.trim(),
|
||||
email: form.email.trim(),
|
||||
contactno: form.contactno.trim(),
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
postcode: form.postcode.trim(),
|
||||
opentime: form.opentime,
|
||||
closetime: form.closetime,
|
||||
deliveryradius: form.deliveryradius,
|
||||
deliverymins: form.deliverymins,
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader title="Branch commissioned" description="The outlet is live and can take orders." />
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.locationname} is commissioned
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
A placeholder branch-manager account was created with it. The branch has no catalogue
|
||||
yet — products are published to it per store.
|
||||
</Text>
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Commission another"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
mutation.reset();
|
||||
setForm({ ...EMPTY, tenantid: form.tenantid });
|
||||
}}
|
||||
/>
|
||||
<Button label="Back to stores" variant="primary" onClick={() => navigate('/nearle/stores')} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard branch"
|
||||
description="Commission a new outlet under an existing tenant and set its delivery thresholds."
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack gap={3}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Owner and identity"
|
||||
note="which tenant this branch belongs to"
|
||||
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Owner tenant <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={tenantOptions}
|
||||
value={form.tenantid}
|
||||
onChange={set('tenantid')}
|
||||
placeholder={isLoadingTenants ? 'Loading tenants…' : 'Select tenant…'}
|
||||
isLoading={isLoadingTenants}
|
||||
hasSearch
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. RS Puram Super Hub"
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
placeholder="rspuram@kaveri.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Outlet contact"
|
||||
value={form.contactno}
|
||||
onChange={set('contactno')}
|
||||
placeholder="9876543222"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Location"
|
||||
note="where the outlet sits"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 240, DB Road"
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. RS Puram" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.postcode} onChange={set('postcode')} />
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Hours and delivery"
|
||||
note="what the customer app uses to decide if an address is servable"
|
||||
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TimeInput
|
||||
label="Open time"
|
||||
value={form.opentime}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, opentime: value ?? prev.opentime }))
|
||||
}
|
||||
/>
|
||||
<TimeInput
|
||||
label="Close time"
|
||||
value={form.closetime}
|
||||
onChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, closetime: value ?? prev.closetime }))
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Delivery radius (m)"
|
||||
value={form.deliveryradius}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, deliveryradius: value ?? 0 }))}
|
||||
min={0}
|
||||
step={500}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Est. delivery (mins)"
|
||||
value={form.deliverymins}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, deliverymins: value ?? 0 }))}
|
||||
min={0}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end">
|
||||
<Button
|
||||
label={mutation.isPending ? 'Commissioning…' : 'Commission branch'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
576
src/features/store-admin/pages/ReportsPage.tsx
Normal file
576
src/features/store-admin/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { BarChart3, Boxes, IndianRupee, Info } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel, count, money, percent, share } from '../format';
|
||||
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
type Tab = 'revenue' | 'sales' | 'inventory';
|
||||
|
||||
/**
|
||||
* Reports — revenue, sales and stock.
|
||||
*
|
||||
* The same rule as the Console: the two channels are reported side by side and
|
||||
* never summed. Every comparison on this page is either within a channel or
|
||||
* between branches, both of which are sound; a figure that mixed `orders` and
|
||||
* `posorders` would not be.
|
||||
*/
|
||||
export function ReportsPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [tab, setTab] = useState<Tab>('revenue');
|
||||
const [preset, setPreset] = useState<RangePreset>('month');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('month'));
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
const posSales = usePosSalesByBranch(branchIds, range);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
scoped.map((branch, index) => {
|
||||
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
|
||||
const pos = posSales[index]?.data;
|
||||
return {
|
||||
branch,
|
||||
appRevenue: order?.revenue ?? 0,
|
||||
appOrders: order?.totalorders ?? 0,
|
||||
cancelled: order?.cancelled ?? 0,
|
||||
delivered: order?.delivered ?? 0,
|
||||
counterRevenue: pos?.grosssales ?? 0,
|
||||
counterBills: pos?.billcount ?? 0,
|
||||
tax: pos?.taxcollected ?? 0,
|
||||
discount: pos?.discountgiven ?? 0,
|
||||
byDay: pos?.byday ?? [],
|
||||
};
|
||||
}),
|
||||
[scoped, orders.data, posSales],
|
||||
);
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, row) => ({
|
||||
appRevenue: acc.appRevenue + row.appRevenue,
|
||||
appOrders: acc.appOrders + row.appOrders,
|
||||
cancelled: acc.cancelled + row.cancelled,
|
||||
delivered: acc.delivered + row.delivered,
|
||||
counterRevenue: acc.counterRevenue + row.counterRevenue,
|
||||
counterBills: acc.counterBills + row.counterBills,
|
||||
tax: acc.tax + row.tax,
|
||||
discount: acc.discount + row.discount,
|
||||
}),
|
||||
{
|
||||
appRevenue: 0,
|
||||
appOrders: 0,
|
||||
cancelled: 0,
|
||||
delivered: 0,
|
||||
counterRevenue: 0,
|
||||
counterBills: 0,
|
||||
tax: 0,
|
||||
discount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Revenue and stock over a period you choose, compared where a comparison is sound."
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Revenue"
|
||||
icon={<IndianRupee size={14} />}
|
||||
isActive={tab === 'revenue'}
|
||||
onClick={() => setTab('revenue')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Sales"
|
||||
icon={<BarChart3 size={14} />}
|
||||
isActive={tab === 'sales'}
|
||||
onClick={() => setTab('sales')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Inventory"
|
||||
icon={<Boxes size={14} />}
|
||||
isActive={tab === 'inventory'}
|
||||
onClick={() => setTab('inventory')}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{tab === 'revenue' ? (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="App revenue"
|
||||
value={money(totals.appRevenue)}
|
||||
note={`${count(totals.appOrders)} orders`}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter revenue"
|
||||
value={money(totals.counterRevenue)}
|
||||
note={`${count(totals.counterBills)} bills`}
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tax collected"
|
||||
value={money(totals.tax)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Discount given"
|
||||
value={money(totals.discount)}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelSplit app={totals.appRevenue} counter={totals.counterRevenue} />
|
||||
|
||||
{rows.length > 1 ? (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="By branch" note="within a channel, so the comparison holds" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Branch</Th>
|
||||
<Th align="right">App revenue</Th>
|
||||
<Th align="right">Counter revenue</Th>
|
||||
<Th align="right">App orders</Th>
|
||||
<Th align="right">Counter bills</Th>
|
||||
<Th align="right">Avg bill</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.branch.locationid}>
|
||||
<Td align="left" isStrong>
|
||||
{branchLabel(row.branch.locationname)}
|
||||
</Td>
|
||||
<Td align="right">{money(row.appRevenue)}</Td>
|
||||
<Td align="right">{money(row.counterRevenue)}</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.appOrders)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{count(row.counterBills)}
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
{row.counterBills > 0
|
||||
? money(row.counterRevenue / row.counterBills)
|
||||
: '—'}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : tab === 'sales' ? (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Fulfilled"
|
||||
value={count(totals.delivered)}
|
||||
note={`of ${count(totals.appOrders)} app orders`}
|
||||
tone="success"
|
||||
fill={share(totals.delivered, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={percent(share(totals.cancelled, totals.appOrders))}
|
||||
note={`${count(totals.cancelled)} orders`}
|
||||
tone={
|
||||
(share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral'
|
||||
}
|
||||
fill={share(totals.cancelled, totals.appOrders) ?? undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Counter bills"
|
||||
value={count(totals.counterBills)}
|
||||
note="rung up at a till"
|
||||
tone="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average bill"
|
||||
value={
|
||||
totals.counterBills > 0
|
||||
? money(totals.counterRevenue / totals.counterBills)
|
||||
: '—'
|
||||
}
|
||||
note="counter sales"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DailyTrend rows={rows} />
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
|
||||
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Fast and slow movers need one more read
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Product-level performance comes from the stock statement, not from the sales
|
||||
summary — movement out of stock is what makes a product fast or slow. It is on the
|
||||
Inventory tab, per branch, because the statement endpoint is scoped to a single
|
||||
location.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
) : (
|
||||
<InventoryReport />
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The channel split, as a bar rather than a pie.
|
||||
*
|
||||
* Two quantities that must not be added still compare fine as lengths, and a
|
||||
* bar makes "counter is two thirds of the business" readable without implying a
|
||||
* whole they sum to.
|
||||
*/
|
||||
function ChannelSplit({ app, counter }: { app: number; counter: number }) {
|
||||
const max = Math.max(app, counter, 1);
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="App versus counter" note="compared, not combined" />
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
|
||||
<Bar
|
||||
label="Counter"
|
||||
value={counter}
|
||||
max={max}
|
||||
color="var(--color-success, #10b981)"
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
max: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={0.5}>
|
||||
<HStack justify="between" align="center">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{label}
|
||||
</Text>
|
||||
<Text type="label" size="sm" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{money(value)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-surface-sunken)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.max(2, (value / max) * 100)}%`,
|
||||
height: '100%',
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
transition: 'width .4s cubic-bezier(.16,1,.3,1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
/** Counter takings per trading day, straight from `PosSalesSummary.byday`. */
|
||||
function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }[] }[] }) {
|
||||
const chartData = useMemo(() => {
|
||||
const totals = new Array(7).fill(0);
|
||||
let hasData = false;
|
||||
for (const row of rows) {
|
||||
for (const entry of row.byDay) {
|
||||
if (!entry.day) continue;
|
||||
const d = new Date(entry.day);
|
||||
if (!isNaN(d.getTime())) {
|
||||
const dayIndex = (d.getDay() + 6) % 7;
|
||||
totals[dayIndex] += (entry.amount ?? 0);
|
||||
hasData = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasData ? WEEKDAYS.map((name, index) => ({ name, amount: totals[index] })) : [];
|
||||
}, [rows]);
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Counter takings by day" note="every till, combined" />
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
|
||||
<div style={{ minWidth: 400, height: '100%' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 13, fill: 'var(--color-slate-500)', fontWeight: 500 }}
|
||||
dy={10}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'var(--color-surface-sunken)', radius: 6 }}
|
||||
formatter={(value: any) => [money(value as number), 'Takings']}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-low)',
|
||||
background: 'var(--color-background-surface)',
|
||||
padding: '8px 12px'
|
||||
}}
|
||||
itemStyle={{ color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
<RechartsBar
|
||||
dataKey="amount"
|
||||
fill="var(--color-brand)"
|
||||
radius={[6, 6, 6, 6]}
|
||||
barSize={36}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory reporting, per branch.
|
||||
*
|
||||
* Fast and slow movers come from the stock statement's `debit` — what actually
|
||||
* left the shelf — rather than from a sales table, because that is the one
|
||||
* figure that counts both channels without double-counting either.
|
||||
*/
|
||||
function InventoryReport() {
|
||||
const { scoped, tenantid } = useBranchScope();
|
||||
const branch = scoped[0];
|
||||
const statement = useStockStatement(tenantid || undefined, branch?.locationid, {
|
||||
pagesize: 200,
|
||||
});
|
||||
|
||||
const rows = statement.data ?? [];
|
||||
const moving = [...rows].sort((a, b) => (b.debit ?? 0) - (a.debit ?? 0));
|
||||
const fast = moving.slice(0, 5);
|
||||
const slow = moving.filter((row) => (row.closing ?? 0) > 0).slice(-5).reverse();
|
||||
const value = rows.reduce((sum, row) => sum + (row.closing ?? 0) * (row.retailprice ?? 0), 0);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Inventory value"
|
||||
value={money(value)}
|
||||
note={branch?.locationname ?? '—'}
|
||||
tone="accent"
|
||||
/>
|
||||
<KpiCard label="Products" value={count(rows.length)} note="with a stock record" tone="neutral" />
|
||||
<KpiCard
|
||||
label="Units moved"
|
||||
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
|
||||
note="off the shelf this period"
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<MoverList title="Fast moving" note="most units off the shelf" rows={fast} />
|
||||
<MoverList title="Slow moving" note="in stock, barely selling" rows={slow} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function MoverList({
|
||||
title,
|
||||
note,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
note: string;
|
||||
rows: { productid: number; productname?: string; debit?: number; closing?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title={title} note={note} />
|
||||
<Card padding={0} elevation="low">
|
||||
{rows.length === 0 ? (
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Not enough movement to rank yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack>
|
||||
{rows.map((row, index) => (
|
||||
<HStack
|
||||
key={row.productid}
|
||||
justify="between"
|
||||
align="center"
|
||||
gap={1}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: index === 0 ? undefined : '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<Text type="body" size="sm" maxLines={1}>
|
||||
{row.productname ?? `Product ${row.productid}`}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}
|
||||
>
|
||||
{count(row.debit)} sold
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '11px 14px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
1176
src/features/store-admin/pages/SalesPage.tsx
Normal file
1176
src/features/store-admin/pages/SalesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
566
src/features/store-admin/pages/TerminalsPage.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, EyeOff, Pencil } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel } from '../format';
|
||||
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||
import { BUCKET_COLOR, BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
||||
|
||||
/**
|
||||
* Counters — the till board, written for the person who actually opens it.
|
||||
*
|
||||
* That person is a shop supervisor who knows the shop and nothing about
|
||||
* networks. The previous version of this page was accurate and unusable by
|
||||
* them: it led with a terminal code and a word like "Stale", put the money in
|
||||
* the smallest grey type at the bottom, and expected the reader to translate
|
||||
* severity into action.
|
||||
*
|
||||
* So: three plainly-named groups, one problem per card, the stake in the
|
||||
* headline, one instruction in bold, and every technical reading — version,
|
||||
* battery, storage, exact timestamps — behind a Details toggle, because that is
|
||||
* support information and support is not who is looking.
|
||||
*
|
||||
* A shop with nothing wrong sees a nearly empty page that says so. That is the
|
||||
* correct output, and it is the one the old design could not produce.
|
||||
*/
|
||||
export function TerminalsPage() {
|
||||
const { branches, scoped, selected, current } = useBranchScope();
|
||||
const [preset, setPreset] = useState<RangePreset>('today');
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
|
||||
const [showFine, setShowFine] = useState(false);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const labels = useCounterLabels();
|
||||
|
||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
||||
|
||||
const needs = board.now.length + board.look.length;
|
||||
const summary = useMemo(() => {
|
||||
if (board.isLoading && board.total === 0) return 'Checking your counters…';
|
||||
if (board.total === 0) return undefined;
|
||||
if (needs === 0) {
|
||||
return `All ${board.total} counter${board.total === 1 ? '' : 's'} are fine — everything they’ve sold has reached us. Each one is listed by the code printed on your receipts, until you rename it.`;
|
||||
}
|
||||
const stuck = board.strandedBills;
|
||||
const n = board.now.length > 0 ? board.now.length : needs;
|
||||
const head = `${n} counter${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention.`;
|
||||
const tail = ' Counters are listed by the code printed on your receipts, until you rename them.';
|
||||
return stuck > 0
|
||||
? `${head} ${stuck} sale${stuck === 1 ? '' : 's'} haven’t reached us yet.${tail}`
|
||||
: `${head}${tail}`;
|
||||
}, [board, needs]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Counters"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
{...(summary ? { description: summary } : {})}
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* A failed read is not an answer. Kept visually distinct from "this shop
|
||||
has no counters", because only one of the two means nothing is wrong. */}
|
||||
{board.failed.length > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" padding={2}>
|
||||
<span style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 2 }}>
|
||||
<AlertTriangle size={16} />
|
||||
</span>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
We couldn’t check {board.failed.map(branchLabel).join(', ')}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
This is a problem reaching our own system, not a problem with your counters —
|
||||
they may be trading normally.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{board.now.length > 0 ? (
|
||||
<Group bucket="now" count={board.now.length}>
|
||||
{board.now.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.look.length > 0 ? (
|
||||
<Group bucket="look" count={board.look.length}>
|
||||
{board.look.map((card) => (
|
||||
<ProblemCard key={card.key} card={card} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.fine.length > 0 ? (
|
||||
<Group
|
||||
bucket="fine"
|
||||
count={board.fine.length}
|
||||
isCollapsible
|
||||
isOpen={showFine}
|
||||
onToggle={() => setShowFine((open) => !open)}
|
||||
>
|
||||
{board.fine.map((counter) => (
|
||||
<FineRow key={counter.key} counter={counter} labels={labels} />
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{board.total === 0 && !board.isLoading ? <Empty checked={board.checked} /> : null}
|
||||
|
||||
{labels.hiddenCount > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((open) => !open)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
alignSelf: 'flex-start',
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
fontSize: 12.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{showHidden ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
Hidden counters ({labels.hiddenCount})
|
||||
</button>
|
||||
{showHidden ? <HiddenList labels={labels} /> : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Group({
|
||||
bucket,
|
||||
count,
|
||||
children,
|
||||
isCollapsible,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
bucket: Bucket;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
isCollapsible?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<HStack align="center" gap={1}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: BUCKET_COLOR[bucket],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{count}
|
||||
</Text>
|
||||
{isCollapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Hide' : 'Show'}
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</HStack>
|
||||
{!isCollapsible || isOpen ? <VStack gap={1.5}>{children}</VStack> : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The card ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One counter, one problem.
|
||||
*
|
||||
* Full width and stacked rather than in a grid: each card is a short paragraph
|
||||
* now, and a paragraph wants a line length. Three columns of prose is the
|
||||
* layout that made the last version unreadable.
|
||||
*/
|
||||
function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const { problem } = card;
|
||||
const rail = BUCKET_COLOR[problem.bucket];
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ display: 'flex', minWidth: 0 }}>
|
||||
<span aria-hidden style={{ width: 3, flex: 'none', background: rail }} />
|
||||
<VStack gap={1} padding={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<CounterName card={card} labels={labels} isRenaming={isRenaming} setIsRenaming={setIsRenaming} />
|
||||
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{problem.headline}
|
||||
</Text>
|
||||
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
{problem.detail}
|
||||
</Text>
|
||||
|
||||
{problem.action ? (
|
||||
<Text type="body" size="sm" weight="semibold" style={{ lineHeight: 1.6 }}>
|
||||
{problem.action}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* The reassurance. On a stuck-sales card this is the most important
|
||||
sentence on the page: it is the difference between a supervisor
|
||||
phoning head office and a supervisor checking a router. */}
|
||||
{problem.comfort ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
||||
{problem.comfort}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Everything else wrong with this counter at this urgency. One line
|
||||
each, under the instruction — the same walk fixes them, and three
|
||||
full cards with the same name at the top read as a broken page. */}
|
||||
{card.also.length > 0 ? (
|
||||
<VStack gap={0} style={{ paddingTop: 2 }}>
|
||||
{card.also.map((extra) => (
|
||||
<Text key={extra.code} type="body" size="xsm" color="secondary">
|
||||
Also: {extra.headline.toLowerCase()}
|
||||
{extra.action ? ` — ${extra.action.toLowerCase()}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
|
||||
<SmallButton
|
||||
label={showDetails ? 'Hide details' : 'Details'}
|
||||
onClick={() => setShowDetails((open) => !open)}
|
||||
/>
|
||||
<SmallButton label="Rename" icon={<Pencil size={11} />} onClick={() => setIsRenaming(true)} />
|
||||
{problem.code === 'never-used' || problem.code === 'no-status' ? (
|
||||
<SmallButton
|
||||
label="Hide this counter"
|
||||
icon={<EyeOff size={11} />}
|
||||
onClick={() => labels.hide(card.terminalId)}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{showDetails ? <Details card={card} /> : null}
|
||||
</VStack>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The counter's name, and the code underneath it.
|
||||
*
|
||||
* The code stays visible even after renaming, because `models/poshealth.go`
|
||||
* calls it "the short code printed on invoices — the thing a support call
|
||||
* starts with". Someone reading a receipt needs to match it to a card.
|
||||
*/
|
||||
function CounterName({
|
||||
card,
|
||||
labels,
|
||||
isRenaming,
|
||||
setIsRenaming,
|
||||
}: {
|
||||
card: BoardCard | HealthyCounter;
|
||||
labels: CounterLabels;
|
||||
isRenaming: boolean;
|
||||
setIsRenaming: (value: boolean) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => labels.nameFor(card.terminalId));
|
||||
const named = labels.isNamed(card.terminalId);
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Name this counter"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder="Counter 2, by the door…"
|
||||
/>
|
||||
</div>
|
||||
<SmallButton
|
||||
label="Save"
|
||||
icon={<Check size={11} />}
|
||||
onClick={() => {
|
||||
labels.rename(card.terminalId, draft);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
/>
|
||||
<SmallButton label="Cancel" onClick={() => setIsRenaming(false)} />
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{card.terminalId} is the code printed on your receipts. Saved on this browser only.
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{labels.nameFor(card.terminalId)}
|
||||
</Text>
|
||||
{named && card.terminalId !== UNASSIGNED ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
style={{ color: 'var(--color-ink-4)', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{card.terminalId}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(card.branchName)}
|
||||
</Text>
|
||||
{/* The "printed on your receipts" hint is NOT here. It is true of every
|
||||
unnamed counter, so putting it on every card printed it ten times down
|
||||
one page and stopped being read on the second. It is said once, under
|
||||
the page title, and again where it is actually needed — inside Rename,
|
||||
where someone is deciding what to call this thing. */}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The support view.
|
||||
*
|
||||
* Everything the previous design put on the face of the card. It is all true
|
||||
* and all useful — to whoever eventually rings the supplier, which is not the
|
||||
* person deciding whether to walk to the counter.
|
||||
*/
|
||||
function Details({ card }: { card: BoardCard }) {
|
||||
const status = card.status;
|
||||
const rows: [string, string][] = [
|
||||
['Counter code', card.terminalId],
|
||||
['Sold in this period', `${card.periodBills} · ${rupees(card.periodAmount)}`],
|
||||
];
|
||||
|
||||
if (status) {
|
||||
rows.push(['Today on the till', `${status.todayBills} · ${rupees(status.todayAmount)}`]);
|
||||
rows.push(['Last sale', clockTime(status.lastBillAt)]);
|
||||
rows.push([
|
||||
'Last heard from',
|
||||
status.receivedAt
|
||||
? `${clockTime(status.receivedAt)} (${spokenAge(status.silentForMs ?? 0)} ago)`
|
||||
: 'never',
|
||||
]);
|
||||
rows.push(['Waiting to send', String(status.pendingBills)]);
|
||||
if (status.appVersion) rows.push(['Till app version', status.appVersion]);
|
||||
if (status.batteryLevel !== null) {
|
||||
rows.push([
|
||||
'Battery',
|
||||
`${status.batteryLevel}%${status.batteryCharging ? ' (charging)' : ''}`,
|
||||
]);
|
||||
}
|
||||
if (status.storageFreeMb !== null) rows.push(['Free space', `${status.storageFreeMb} MB`]);
|
||||
if (status.printerReachable !== null) {
|
||||
rows.push(['Printer', status.printerReachable ? 'responding' : 'not responding']);
|
||||
}
|
||||
if (status.drawerStatus) rows.push(['Cash drawer', status.drawerStatus]);
|
||||
if (status.clockDriftSeconds !== null) {
|
||||
rows.push(['Clock difference', `${status.clockDriftSeconds}s vs our server`]);
|
||||
}
|
||||
if (status.reason) rows.push(['Reported reason', status.reason]);
|
||||
} else {
|
||||
rows.push(['Status reporting', 'this counter has never reported']);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={0} style={{ paddingTop: 4 }}>
|
||||
{rows.map(([label, value]) => (
|
||||
<HStack key={label} justify="between" align="center" gap={2} style={{ padding: '4px 0' }}>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The fine list ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** A counter with nothing wrong gets one line. Anything more is noise. */
|
||||
function FineRow({ counter, labels }: { counter: HealthyCounter; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} padding={2} wrap="wrap">
|
||||
<CounterName
|
||||
card={counter}
|
||||
labels={labels}
|
||||
isRenaming={isRenaming}
|
||||
setIsRenaming={setIsRenaming}
|
||||
/>
|
||||
{!isRenaming ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{counter.periodBills} sales · {rupees(counter.periodAmount)}
|
||||
{counter.status.lastBillAt ? ` · last sale ${clockTime(counter.status.lastBillAt)}` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hidden ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function HiddenList({ labels }: { labels: CounterLabels }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
{labels.hidden.map((terminalId) => (
|
||||
<HStack key={terminalId} justify="between" align="center" gap={2}>
|
||||
<Text type="body" size="xsm" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{labels.nameFor(terminalId)}
|
||||
</Text>
|
||||
<SmallButton label="Bring back" onClick={() => labels.unhide(terminalId)} />
|
||||
</HStack>
|
||||
))}
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
Hidden here only, on this browser. There’s no way to remove a counter from our system
|
||||
once it has been used, so this just keeps your list tidy.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Empty({ checked }: { checked: string[] }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No counter has reported yet
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
A counter appears here the first time it is used — nobody has to register one. So an
|
||||
empty list means no till has ever sent us anything, not that sales are missing.
|
||||
</Text>
|
||||
{checked.length > 0 ? (
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
Checked: {checked.map(branchLabel).join(', ')}.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bits ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function SmallButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
608
src/features/store-admin/pages/UsersPage.tsx
Normal file
608
src/features/store-admin/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Info, Monitor, Plus, UserCog, Users } from 'lucide-react';
|
||||
import type { PosUser, StaffInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
usePosRoles,
|
||||
usePosUsersByBranch,
|
||||
useStaff,
|
||||
useStaffShifts,
|
||||
} from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { branchLabel } from '../format';
|
||||
import { PersonDrawer, TillDrawer } from '../PeopleDrawers';
|
||||
|
||||
type Group = 'store' | 'till';
|
||||
|
||||
/**
|
||||
* Users & access — the two account systems, side by side but never merged.
|
||||
*
|
||||
* A till account is NOT a Nearle Daily user. The backend excludes roles 7 and 8
|
||||
* from every application lookup *inside the query itself* — deliberately, so a
|
||||
* cashier is "not found" rather than "refused". Merging the two into one table
|
||||
* would list people in a directory they cannot sign in to, and would invite an
|
||||
* edit that moves someone between two systems that do not share a login.
|
||||
*
|
||||
* Neither group offers a delete. Both delete endpoints are hard deletes with no
|
||||
* cascade, and `status` already carries deactivation.
|
||||
*/
|
||||
export function UsersPage() {
|
||||
const { branches, scoped, selected, current, tenantid } = useBranchScope();
|
||||
const [group, setGroup] = useState<Group>('store');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [editingPerson, setEditingPerson] = useState<StaffInfo | 'new' | null>(null);
|
||||
const [editingTill, setEditingTill] = useState<{ row: PosUser | 'new'; locationid: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
|
||||
const staff = useStaff(tenantid || undefined);
|
||||
const posUserPages = usePosUsersByBranch(tenantid || undefined, branchIds);
|
||||
const roles = usePosRoles();
|
||||
const shifts = useStaffShifts(tenantid || undefined, scoped[0]?.locationid);
|
||||
|
||||
const staffRows = useMemo(() => {
|
||||
const term = keyword.trim().toLowerCase();
|
||||
return (staff.data ?? []).filter((person) => {
|
||||
if (roleFilter && person.rolename !== roleFilter) return false;
|
||||
if (selected !== null && person.locationid !== selected) return false;
|
||||
if (!term) return true;
|
||||
return `${person.fullname} ${person.email} ${person.contactno}`.toLowerCase().includes(term);
|
||||
});
|
||||
}, [staff.data, keyword, roleFilter, selected]);
|
||||
|
||||
const tillRows = useMemo(() => {
|
||||
const out: { person: PosUser; branch: string; locationid: number }[] = [];
|
||||
posUserPages.forEach((page, index) => {
|
||||
const locationid = branchIds[index];
|
||||
if (locationid === undefined) return;
|
||||
const branch = branches.find((entry) => entry.locationid === locationid);
|
||||
for (const person of page.data ?? []) {
|
||||
out.push({
|
||||
person,
|
||||
branch: branch?.locationname ?? `Branch ${locationid}`,
|
||||
locationid,
|
||||
});
|
||||
}
|
||||
});
|
||||
const term = keyword.trim().toLowerCase();
|
||||
return out.filter(({ person }) => {
|
||||
if (roleFilter && person.role?.toLowerCase() !== roleFilter.toLowerCase()) return false;
|
||||
if (!term) return true;
|
||||
return `${person.full_name} ${person.contactno}`.toLowerCase().includes(term);
|
||||
});
|
||||
}, [posUserPages, branchIds, branches, keyword, roleFilter]);
|
||||
|
||||
const staffRoles = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const person of staff.data ?? []) if (person.rolename) seen.add(person.rolename);
|
||||
return [...seen].sort();
|
||||
}, [staff.data]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
group === 'store'
|
||||
? [
|
||||
{ value: '', label: 'All roles' },
|
||||
...staffRoles.map((name) => ({ value: name, label: name })),
|
||||
]
|
||||
: [
|
||||
{ value: '', label: 'All roles' },
|
||||
...(roles.data ?? []).map((role) => ({ value: role.role, label: role.label })),
|
||||
],
|
||||
[group, staffRoles, roles.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Users & access"
|
||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||
description="Who can sign in to the console, and who can open a till."
|
||||
actions={
|
||||
<Button
|
||||
label={group === 'store' ? 'Add person' : 'Add till account'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => {
|
||||
if (group === 'store') setEditingPerson('new');
|
||||
else {
|
||||
const locationid = selected ?? scoped[0]?.locationid;
|
||||
if (locationid) setEditingTill({ row: 'new', locationid });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<TabButton
|
||||
label="Store users"
|
||||
icon={<UserCog size={14} />}
|
||||
badge={staff.data?.length || undefined}
|
||||
isActive={group === 'store'}
|
||||
onClick={() => {
|
||||
setGroup('store');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
<TabButton
|
||||
label="Till accounts"
|
||||
icon={<Monitor size={14} />}
|
||||
badge={tillRows.length || undefined}
|
||||
isActive={group === 'till'}
|
||||
onClick={() => {
|
||||
setGroup('till');
|
||||
setRoleFilter('');
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title={group === 'store' ? 'Back office' : 'Counter staff'}
|
||||
note={
|
||||
group === 'store'
|
||||
? 'signs in to this console'
|
||||
: 'signs in at a till — no console login'
|
||||
}
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 170 }}>
|
||||
<Selector
|
||||
label="Role"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={roleFilter}
|
||||
onChange={setRoleFilter}
|
||||
placeholder="All roles"
|
||||
options={roleOptions}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 220 }}>
|
||||
<TextInput
|
||||
label="Search people"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Name, email, mobile…"
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{group === 'store' ? (
|
||||
<StaffTable
|
||||
rows={staffRows}
|
||||
isLoading={staff.isLoading}
|
||||
showBranch={selected === null}
|
||||
branches={branches}
|
||||
onEdit={setEditingPerson}
|
||||
/>
|
||||
) : (
|
||||
<TillTable
|
||||
rows={tillRows}
|
||||
isLoading={posUserPages.some((page) => page.isLoading)}
|
||||
showBranch={selected === null}
|
||||
onEdit={(row, locationid) => setEditingTill({ row, locationid })}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Stated once, under whichever list is showing. */}
|
||||
<HStack gap={1} align="start">
|
||||
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{group === 'store' ? (
|
||||
<>
|
||||
There is no delete here. Removing an account is a hard delete with no cascade in
|
||||
Fiesta, so anything that account touched would be left pointing at nothing —
|
||||
deactivating keeps the history and stops the login. Passwords are not managed from
|
||||
this console either, because they are stored in clear; adding a person creates the
|
||||
account, and issuing its password is a backend job until that changes.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A till account has no console login — the backend leaves roles 7 and 8 out of every
|
||||
application lookup, so a cashier is simply not found rather than refused. The PIN is
|
||||
shown once when the account is created and never read back; if it is lost, reissue
|
||||
rather than look it up.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{editingPerson ? (
|
||||
<PersonDrawer
|
||||
row={editingPerson === 'new' ? null : editingPerson}
|
||||
tenantid={tenantid}
|
||||
branches={branches}
|
||||
onClose={() => setEditingPerson(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{editingTill ? (
|
||||
<TillDrawer
|
||||
row={editingTill.row === 'new' ? null : editingTill.row}
|
||||
tenantid={tenantid}
|
||||
locationid={editingTill.locationid}
|
||||
roles={roles.data ?? []}
|
||||
shifts={shifts.data ?? []}
|
||||
onClose={() => setEditingTill(null)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function StaffTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
branches,
|
||||
onEdit,
|
||||
}: {
|
||||
rows: StaffInfo[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
branches: { locationid: number; locationname: string }[];
|
||||
onEdit: (row: StaffInfo) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="the directory" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Users size={22} />}
|
||||
title="Nobody here"
|
||||
body="Nothing matches this role or search. Add a person to give them a console login."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Email</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((person) => {
|
||||
const branch = branches.find((entry) => entry.locationid === person.locationid);
|
||||
return (
|
||||
<tr key={person.userid}>
|
||||
<Td align="left" isStrong>
|
||||
{person.fullname || `${person.firstname ?? ''} ${person.lastname ?? ''}`.trim() || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
{/* Server-resolved. Never mapped from roleid here — most
|
||||
accounts carry an id that is absent from `app_roles`. */}
|
||||
<Chip label={person.rolename} tone="brand" />
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{person.email || '—'}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
{person.contactno || '—'}
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch?.locationname ?? person.locationname)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TillTable({
|
||||
rows,
|
||||
isLoading,
|
||||
showBranch,
|
||||
onEdit,
|
||||
}: {
|
||||
rows: { person: PosUser; branch: string; locationid: number }[];
|
||||
isLoading: boolean;
|
||||
showBranch: boolean;
|
||||
onEdit: (row: PosUser, locationid: number) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="till accounts" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Monitor size={22} />}
|
||||
title="No till accounts"
|
||||
body="Create a Supervisor first — they open the till on the device, and the terminal appears on the Console board once it reports in."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Name</Th>
|
||||
<Th align="left">Role</Th>
|
||||
<Th align="left">Mobile</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Shift</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ person, branch, locationid }) => (
|
||||
<tr key={person.user_id}>
|
||||
<Td align="left" isStrong>
|
||||
{person.full_name || '—'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Chip
|
||||
label={person.role}
|
||||
tone={person.role?.toLowerCase() === 'supervisor' ? 'violet' : 'slate'}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{person.contactno || '—'}
|
||||
</span>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left" isMuted>
|
||||
{person.shift_name ? (
|
||||
<>
|
||||
<span style={{ display: 'block' }}>{person.shift_name}</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}>
|
||||
{person.shift_start}–{person.shift_end}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'Any'
|
||||
)}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<StatusChip status={person.status} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Button
|
||||
label="Edit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(person, locationid)}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Primitives ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
brand: 'var(--color-brand)',
|
||||
violet: '#8b5cf6',
|
||||
slate: 'var(--color-ink-3)',
|
||||
};
|
||||
|
||||
function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof TONE }) {
|
||||
const colour = TONE[tone] ?? TONE.slate!;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: 999,
|
||||
padding: '2px 9px',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
whiteSpace: 'nowrap',
|
||||
color: colour,
|
||||
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
|
||||
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{label || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Active is quiet, anything else is loud — an inactive account is the exception. */
|
||||
function StatusChip({ status }: { status: string | undefined }) {
|
||||
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
|
||||
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: isActive ? 'var(--color-ink-2)' : 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
|
||||
/>
|
||||
{isActive ? 'Active' : (status ?? 'Inactive')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: isActive ? '#fff' : 'var(--color-ink-3)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading({ what }: { what: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading {what}…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={4} align="center">
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>{icon}</span>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 400 }}>
|
||||
{body}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user