store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

View File

@@ -10,7 +10,6 @@ import {
AlertTriangle,
ArrowRight,
ClipboardList,
Info,
Monitor,
PackageX,
Receipt,
@@ -51,7 +50,16 @@ import {
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
*/
export function ConsolePage() {
const { branches, scoped, selected, current, tenantid, isLoading } = useBranchScope();
const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope();
/**
* Where this page's links point.
*
* The page is shared with the Store user workspace, pinned to one branch, and
* a hardcoded `/admin/...` there sends a shop to a workspace its role cannot
* enter — which the route guard answers with a redirect, so the alert silently
* does nothing.
*/
const base = isPinned ? '/store' : '/admin';
const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
@@ -116,7 +124,7 @@ export function ConsolePage() {
[rows],
);
const alerts = useMemo(() => buildAlerts(rows), [rows]);
const alerts = useMemo(() => buildAlerts(rows, base, isPinned), [rows, base, isPinned]);
const cancelShare = share(totals.cancelled, totals.onlineOrders);
return (
@@ -145,17 +153,7 @@ export function ConsolePage() {
{/* ── 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>
}
/>
title="Revenue" />
<div className="kpi-grid">
<KpiCard
label="App revenue"
@@ -173,7 +171,7 @@ export function ConsolePage() {
/>
<KpiCard
label="Cancelled"
value={percent(cancelShare)}
value={percent(cancelShare ?? 0)}
note={`${count(totals.cancelled)} of ${count(totals.onlineOrders)} app orders`}
tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'}
icon={<TrendingDown size={15} />}
@@ -187,21 +185,12 @@ export function ConsolePage() {
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"
title={isPinned ? 'Your shop' : selected === null ? 'Branches' : 'Branch'}
/>
{isLoading ? (
@@ -231,7 +220,7 @@ export function ConsolePage() {
{/* ── 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" />
<SectionHeader title="Comparison" />
<Card padding={0} elevation="low">
<div className="table-scroll">
<ComparisonTable rows={rows} />
@@ -244,7 +233,6 @@ export function ConsolePage() {
<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
@@ -260,7 +248,7 @@ export function ConsolePage() {
{/* ── Action required ─────────────────────────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader title="Needs you" note="ranked by what costs money soonest" />
<SectionHeader title="Needs you" />
{alerts.length === 0 ? (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
@@ -466,27 +454,27 @@ function ComparisonTable({ rows }: { rows: Row[] }) {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Metric</Th>
<Th>Metric</Th>
{rows.map((row) => (
<Th key={row.branch.locationid} align="right">
<Th key={row.branch.locationid}>
{branchLabel(row.branch.locationname)}
</Th>
))}
<Th align="right">All</Th>
<Th>All</Th>
</tr>
</thead>
<tbody>
{metrics.map((metric) => (
<tr key={metric.label}>
<Td align="left" isMuted>
<Td isMuted>
{metric.label}
</Td>
{rows.map((row) => (
<Td key={row.branch.locationid} align="right">
<Td key={row.branch.locationid}>
{metric.get(row)}
</Td>
))}
<Td align="right" isStrong>
<Td isStrong>
{metric.total}
</Td>
</tr>
@@ -541,39 +529,39 @@ function TillTable({ rows }: { rows: Row[] }) {
<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>
<Th>Till</Th>
<Th>Branch</Th>
<Th>Status</Th>
<Th>Queued</Th>
<Th>Oldest</Th>
<Th>Today</Th>
<Th>Notes</Th>
</tr>
</thead>
<tbody>
{terminals.map(({ branch, terminal }) => (
<tr key={`${branch}:${terminal.terminalId}`}>
<Td align="left" isStrong>
<Td isStrong>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{terminal.terminalId}
</span>
</Td>
<Td align="left" isMuted>
<Td isMuted>
{branchLabel(branch)}
</Td>
<Td align="left">
<Td>
<Badge variant={SYNC_TONE[terminal.state]} label={SYNC_LABEL[terminal.state]} />
</Td>
<Td align="right" isStrong={terminal.pendingBills > 0}>
<Td isStrong={terminal.pendingBills > 0}>
{terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'}
</Td>
<Td align="right" isMuted>
<Td isMuted>
{shortAge(terminal.oldestPendingMs)}
</Td>
<Td align="right">
<Td>
{count(terminal.todayBills)} · {money(terminal.todayAmount)}
</Td>
<Td align="left" isMuted>
<Td isMuted>
{deviceNote(terminal)}
</Td>
</tr>
@@ -602,7 +590,7 @@ interface Alert {
* business cannot see, and it is the only item here that gets worse purely by
* being ignored.
*/
function buildAlerts(rows: Row[]): Alert[] {
function buildAlerts(rows: Row[], base: string, isPinned: boolean): Alert[] {
const alerts: Alert[] = [];
for (const row of rows) {
@@ -622,7 +610,7 @@ function buildAlerts(rows: Row[]): Alert[] {
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',
to: `${base}/terminals`,
action: 'Open Terminals',
});
}
@@ -651,22 +639,39 @@ function buildAlerts(rows: Row[]): Alert[] {
.map(({ terminal }) => `${terminal.terminalId} (${SYNC_LABEL[terminal.state].toLowerCase()})`)
.join(', ')
.concat(branches.length > 1 ? ` across ${branches.length} branches` : ` at ${branches[0]}`),
to: '/admin/terminals',
to: `${base}/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',
});
// Same fact, opposite side of the counter. The merchant is the one holding
// it up; the shop is the one waiting — and a shop told to "approve" a
// request it raised itself would go looking for a button that is not there,
// because the backend gives it to nobody but the tenant.
alerts.push(
isPinned
? {
id: 'requests',
tone: 'warning',
icon: <ClipboardList size={15} />,
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} waiting on head office`,
detail:
'Your store administrator approves these. Confirm the goods here once they arrive.',
to: `${base}/products`,
action: 'Open Products',
}
: {
id: 'requests',
tone: 'warning',
icon: <ClipboardList size={15} />,
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
to: '/admin/inventory',
action: 'Review requests',
},
);
}
for (const row of rows) {
@@ -678,7 +683,7 @@ function buildAlerts(rows: Row[]): Alert[] {
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',
to: `${base}/sales`,
action: 'Open Sales',
});
}
@@ -738,11 +743,11 @@ function AlertRow({ alert }: { alert: Alert }) {
/* ── Table primitives ────────────────────────────────────────────────────── */
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
function Th({ children }: { children: React.ReactNode; }) {
return (
<th
style={{
textAlign: align,
textAlign: 'center',
padding: '10px 14px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
@@ -758,24 +763,22 @@ function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'r
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
textAlign: 'center',
padding: '11px 14px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>