live update
This commit is contained in:
@@ -13,7 +13,7 @@ import type { FiestaEnvelope } from './types';
|
|||||||
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
|
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
|
||||||
* surprises. In production the deployed host is set by VITE_API_BASE.
|
* surprises. In production the deployed host is set by VITE_API_BASE.
|
||||||
*/
|
*/
|
||||||
const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
export const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
||||||
|
|
||||||
/** Every console route lives under this prefix. */
|
/** Every console route lives under this prefix. */
|
||||||
export const WEB = '/live/api/v1/web';
|
export const WEB = '/live/api/v1/web';
|
||||||
|
|||||||
235
src/components/SheetDropzone.tsx
Normal file
235
src/components/SheetDropzone.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useRef, useState, type DragEvent } from 'react';
|
||||||
|
import { FileSpreadsheet, Trash2, Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workbook dropzone.
|
||||||
|
*
|
||||||
|
* Astryx's `FileInput` in dropzone mode is a white slab with a dashed rule and
|
||||||
|
* a bare arrow — correct, and completely mute about what it wants or whether it
|
||||||
|
* is even available. This one says all three things: what it takes, whether it
|
||||||
|
* can take it right now, and what it is holding.
|
||||||
|
*
|
||||||
|
* Three states, and each looks different at a glance rather than on reading:
|
||||||
|
*
|
||||||
|
* - **Waiting** — a tinted panel with a brand-lit icon and an explicit
|
||||||
|
* "Choose file" affordance, because a dropzone that only accepts a drag is
|
||||||
|
* unusable to anyone on a laptop with the file already in a dialog.
|
||||||
|
* - **Dragging** — the brand colour comes up and the panel lifts. A dropzone
|
||||||
|
* that does not visibly react is one people drop next to.
|
||||||
|
* - **Filled** — the file itself, with its size and a way to swap it. The old
|
||||||
|
* control kept saying "Drop an .xlsx here" after you already had.
|
||||||
|
*
|
||||||
|
* It stays a real `<input type="file">` under the surface, so the keyboard, the
|
||||||
|
* screen reader and the OS file dialog all behave as they should — the div is
|
||||||
|
* decoration over an input, not a replacement for one.
|
||||||
|
*/
|
||||||
|
export interface SheetDropzoneProps {
|
||||||
|
file: File | null;
|
||||||
|
onFile: (file: File | null) => void;
|
||||||
|
/** Comma-separated extensions, passed straight to the input. */
|
||||||
|
accept?: string;
|
||||||
|
/** Why the control is unavailable. Present means disabled. */
|
||||||
|
blockedReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCEPTED = ['.xlsx', '.xls', '.csv'];
|
||||||
|
|
||||||
|
export function SheetDropzone({
|
||||||
|
file,
|
||||||
|
onFile,
|
||||||
|
accept = ACCEPTED.join(','),
|
||||||
|
blockedReason,
|
||||||
|
}: SheetDropzoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const isDisabled = Boolean(blockedReason);
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!isDisabled) inputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (isDisabled) return;
|
||||||
|
const dropped = event.dataTransfer.files?.[0];
|
||||||
|
if (dropped) onFile(dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filled ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid var(--color-line)',
|
||||||
|
background: 'var(--color-surface-subtle)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
flex: 'none',
|
||||||
|
borderRadius: 11,
|
||||||
|
background: 'color-mix(in oklab, var(--color-brand) 12%, transparent)',
|
||||||
|
color: 'var(--color-brand)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet size={19} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-ink-1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={open} style={ghostButtonStyle}>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFile(null)}
|
||||||
|
aria-label="Remove file"
|
||||||
|
style={{ ...ghostButtonStyle, padding: '7px 9px' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Waiting / dragging ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={open}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isDisabled) setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={isDisabled ? -1 : 0}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderRadius: 13,
|
||||||
|
border: `1.5px dashed ${
|
||||||
|
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
||||||
|
}`,
|
||||||
|
background: isDragging
|
||||||
|
? 'color-mix(in oklab, var(--color-brand) 8%, transparent)'
|
||||||
|
: 'color-mix(in oklab, var(--color-brand) 3%, transparent)',
|
||||||
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isDisabled ? 0.55 : 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'background .16s ease, border-color .16s ease, transform .16s ease',
|
||||||
|
transform: isDragging ? 'scale(1.004)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: isDragging
|
||||||
|
? 'var(--color-brand)'
|
||||||
|
: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
|
||||||
|
color: isDragging ? '#fff' : 'var(--color-brand)',
|
||||||
|
transition: 'background .16s ease, color .16s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
|
||||||
|
{isDisabled
|
||||||
|
? blockedReason
|
||||||
|
: isDragging
|
||||||
|
? 'Drop it here'
|
||||||
|
: 'Drop your workbook, or click to browse'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
|
||||||
|
{ACCEPTED.join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghostButtonStyle: React.CSSProperties = {
|
||||||
|
flex: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '7px 12px',
|
||||||
|
borderRadius: 9,
|
||||||
|
border: '1px solid var(--color-line)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-ink-2)',
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** kB up to a megabyte, then MB. Nobody needs "1420800 bytes". */
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1024) return `${Math.round(kb)} kB`;
|
||||||
|
return `${(kb / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
|||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
@@ -12,6 +11,7 @@ import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-r
|
|||||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||||
import { errorMessage } from '@/api/client';
|
import { errorMessage } from '@/api/client';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
|
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||||
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
||||||
|
|
||||||
interface PreviewRow extends Record<string, unknown> {
|
interface PreviewRow extends Record<string, unknown> {
|
||||||
@@ -148,7 +148,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
|||||||
if (result) {
|
if (result) {
|
||||||
const isClean = result.failures.length === 0;
|
const isClean = result.failures.length === 0;
|
||||||
return (
|
return (
|
||||||
<Card padding={4} elevation="low">
|
<Card padding={4} variant="transparent">
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<HStack align="center" gap={1.5}>
|
<HStack align="center" gap={1.5}>
|
||||||
{isClean ? (
|
{isClean ? (
|
||||||
@@ -197,7 +197,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} variant="transparent">
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Upload the tenant's product list"
|
title="Upload the tenant's product list"
|
||||||
@@ -212,17 +212,18 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FileInput
|
<SheetDropzone
|
||||||
label="Product workbook"
|
file={file}
|
||||||
value={file}
|
onFile={handleFile}
|
||||||
onChange={handleFile}
|
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
|
||||||
accept=".xlsx,.xls,.csv"
|
|
||||||
mode="dropzone"
|
|
||||||
placeholder="Drop an .xlsx or .csv here"
|
|
||||||
description="Required columns: productname, productsku, categoryid, retailprice, productcost. The catalogue's category names do not map to a tenant's own ids, so categoryid must be in the file."
|
|
||||||
isDisabled={!hasTarget}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
||||||
|
Required columns: productname, productsku, categoryid, retailprice, productcost. The
|
||||||
|
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
|
||||||
|
the file.
|
||||||
|
</Text>
|
||||||
|
|
||||||
{parseError ? (
|
{parseError ? (
|
||||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||||
{parseError}
|
{parseError}
|
||||||
@@ -232,7 +233,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{parsed ? (
|
{parsed ? (
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} variant="transparent">
|
||||||
<VStack gap={2} padding={3}>
|
<VStack gap={2} padding={3}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="What is in the file"
|
title="What is in the file"
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function GlobalCataloguePage() {
|
|||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
{/* Only here. An upload is written against one merchant and one
|
{/* Only here. An upload is written against one merchant and one
|
||||||
outlet, and the endpoint refuses a row without both. */}
|
outlet, and the endpoint refuses a row without both. */}
|
||||||
<Card padding={0} elevation="low">
|
<Card padding={0} variant="transparent">
|
||||||
<VStack gap={2} padding={2}>
|
<VStack gap={2} padding={2}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Uploading for"
|
title="Uploading for"
|
||||||
|
|||||||
@@ -468,8 +468,8 @@ export function TillDrawer({
|
|||||||
isNew
|
isNew
|
||||||
? cashierOnly
|
? cashierOnly
|
||||||
? 'Add a cashier'
|
? 'Add a cashier'
|
||||||
: 'Add a till account'
|
: 'Add a terminal account'
|
||||||
: (row.full_name ?? 'Edit till account')
|
: (row.full_name ?? 'Edit terminal account')
|
||||||
}
|
}
|
||||||
subtitle={
|
subtitle={
|
||||||
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { Check, ChevronDown, Monitor, Store, Users } from 'lucide-react';
|
import { Check, ChevronDown, Monitor, Store, Users } from 'lucide-react';
|
||||||
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
||||||
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
||||||
|
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store Admin — the merchant workspace.
|
* Store Admin — the merchant workspace.
|
||||||
@@ -42,7 +43,7 @@ const MANAGE: readonly MenuEntry[] = [
|
|||||||
to: '/admin/users',
|
to: '/admin/users',
|
||||||
label: 'Users & access',
|
label: 'Users & access',
|
||||||
icon: <Users size={13} />,
|
icon: <Users size={13} />,
|
||||||
note: 'Store and till accounts',
|
note: 'Store and terminal accounts',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/admin/terminals',
|
to: '/admin/terminals',
|
||||||
@@ -55,6 +56,7 @@ const MANAGE: readonly MenuEntry[] = [
|
|||||||
export function StoreAdminShell() {
|
export function StoreAdminShell() {
|
||||||
return (
|
return (
|
||||||
<BranchScopeProvider>
|
<BranchScopeProvider>
|
||||||
|
<LiveWatch />
|
||||||
<AppShell
|
<AppShell
|
||||||
nav={NAV}
|
nav={NAV}
|
||||||
home="/admin/console"
|
home="/admin/console"
|
||||||
@@ -66,6 +68,23 @@ export function StoreAdminShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the live stream for the branch currently in scope.
|
||||||
|
*
|
||||||
|
* A component rather than a hook call in the shell body, because it has to sit
|
||||||
|
* INSIDE `BranchScopeProvider` to read the selection — and it renders nothing,
|
||||||
|
* so where it sits in the tree costs nothing either.
|
||||||
|
*
|
||||||
|
* Under "All branches" `current` is undefined and no stream opens. That is
|
||||||
|
* deliberate: the stream is per-outlet, and a six-branch tenant would otherwise
|
||||||
|
* hold six connections open for a screen that is already polling.
|
||||||
|
*/
|
||||||
|
function LiveWatch() {
|
||||||
|
const { tenantid, current } = useBranchScope();
|
||||||
|
useLiveEvents(tenantid, current?.locationid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The branch selector, in the header.
|
* The branch selector, in the header.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -987,7 +987,10 @@ function StatusTab({
|
|||||||
padding: '0 11px',
|
padding: '0 11px',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
|
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
|
||||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface)',
|
// Transparent when idle, not white. A row of white chips on a tinted
|
||||||
|
// page reads as six raised slabs; only the selected one should carry a
|
||||||
|
// fill, and the hairline is enough to keep the rest legible as chips.
|
||||||
|
background: isActive ? 'var(--color-brand)' : 'transparent',
|
||||||
color: isActive ? '#fff' : 'var(--color-ink-2)',
|
color: isActive ? '#fff' : 'var(--color-ink-2)',
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function UsersPage() {
|
|||||||
description="Who can sign in to the console, and who can open a till."
|
description="Who can sign in to the console, and who can open a till."
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
label={group === 'store' ? 'Add person' : 'Add till account'}
|
label={group === 'store' ? 'Add person' : 'Add terminal account'}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={<Plus size={14} />}
|
icon={<Plus size={14} />}
|
||||||
@@ -137,7 +137,7 @@ export function UsersPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Till accounts"
|
label="Terminal accounts"
|
||||||
icon={<Monitor size={14} />}
|
icon={<Monitor size={14} />}
|
||||||
badge={tillRows.length || undefined}
|
badge={tillRows.length || undefined}
|
||||||
isActive={group === 'till'}
|
isActive={group === 'till'}
|
||||||
@@ -314,12 +314,12 @@ function TillTable({
|
|||||||
showBranch: boolean;
|
showBranch: boolean;
|
||||||
onEdit: (row: PosUser, locationid: number) => void;
|
onEdit: (row: PosUser, locationid: number) => void;
|
||||||
}) {
|
}) {
|
||||||
if (isLoading) return <Loading what="till accounts" />;
|
if (isLoading) return <Loading what="terminal accounts" />;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
icon={<Monitor size={22} />}
|
icon={<Monitor size={22} />}
|
||||||
title="No till accounts"
|
title="No terminal 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."
|
body="Create a Supervisor first — they open the till on the device, and the terminal appears on the Console board once it reports in."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/component
|
|||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTenantLocations } from '@/queries/hooks';
|
import { useTenantLocations } from '@/queries/hooks';
|
||||||
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
|
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store user — one branch, and only that branch.
|
* Store user — one branch, and only that branch.
|
||||||
@@ -100,6 +101,7 @@ export function StoreUserShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BranchScopeProvider pin={locationid}>
|
<BranchScopeProvider pin={locationid}>
|
||||||
|
<LiveWatch />
|
||||||
<AppShell
|
<AppShell
|
||||||
nav={NAV}
|
nav={NAV}
|
||||||
home="/store/console"
|
home="/store/console"
|
||||||
@@ -203,3 +205,15 @@ function NoStore() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the live stream for this shop's outlet.
|
||||||
|
*
|
||||||
|
* Simpler than the Store Admin's: the branch is pinned, so there is always
|
||||||
|
* exactly one outlet to watch and exactly one connection to hold.
|
||||||
|
*/
|
||||||
|
function LiveWatch() {
|
||||||
|
const { tenantid, current } = useBranchScope();
|
||||||
|
useLiveEvents(tenantid, current?.locationid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
109
src/queries/useLiveEvents.ts
Normal file
109
src/queries/useLiveEvents.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* The live stream.
|
||||||
|
*
|
||||||
|
* The console reads through TanStack Query on a 30-second poll. That is the
|
||||||
|
* floor, not the ceiling: a bill rung at 10:00:01 waits until 10:00:30 to
|
||||||
|
* appear. This connects to the backend's event stream and, when a till actually
|
||||||
|
* sells something, invalidates the queries that just went stale — so the screen
|
||||||
|
* updates in about a second instead of averaging fifteen.
|
||||||
|
*
|
||||||
|
* ── What it does NOT do ──────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* It never writes data into the cache. An event says "outlet 1185 sold
|
||||||
|
* something, products 42 and 77 were involved" and nothing more; the numbers
|
||||||
|
* still come from the API, because the API is the only thing that knows the
|
||||||
|
* total after the backend's locks, dedup and rejections have had their say.
|
||||||
|
* Painting figures straight from a broker message would put totals on screen
|
||||||
|
* that the database never agreed to.
|
||||||
|
*
|
||||||
|
* It also does not replace the poll. Every hook keeps its `refetchInterval`. If
|
||||||
|
* this connection drops, the broker is down, or MQTT_URL is unset on the
|
||||||
|
* server, the console behaves exactly as it did before — slower, never wrong.
|
||||||
|
* That is the whole reason this is safe to add.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { API_BASE } from '@/api/client';
|
||||||
|
import { queryKeys } from './keys';
|
||||||
|
|
||||||
|
/** Mirrors `messaging.LiveEvent` on the backend. */
|
||||||
|
interface LiveEvent {
|
||||||
|
type: 'sale' | 'customer' | 'terminal';
|
||||||
|
locationid: number;
|
||||||
|
productids?: number[];
|
||||||
|
terminalid?: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch one outlet.
|
||||||
|
*
|
||||||
|
* Pass `undefined` for either id and nothing connects — which is what the Store
|
||||||
|
* Admin's "All branches" view does, since a stream is per-outlet and opening
|
||||||
|
* one per branch would mean six connections for a screen that is already
|
||||||
|
* polling. Under All branches the poll remains the only refresh, deliberately.
|
||||||
|
*/
|
||||||
|
export function useLiveEvents(tenantid: number | undefined, locationid: number | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tenantid || !locationid) return;
|
||||||
|
|
||||||
|
const url = `${API_BASE}/live/api/v1/web/live/events?tenantid=${tenantid}&locationid=${locationid}`;
|
||||||
|
const source = new EventSource(url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sale moved stock and added a bill.
|
||||||
|
*
|
||||||
|
* Invalidated by prefix rather than by exact key: the product list is keyed
|
||||||
|
* by page and the statement by its filters, and this has no idea which page
|
||||||
|
* or filter the operator is looking at. `invalidateQueries` with a prefix
|
||||||
|
* only refetches what is actually mounted, so the cost is the queries on
|
||||||
|
* screen — not every page ever visited.
|
||||||
|
*/
|
||||||
|
const onSale = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posSales(locationid) });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [...queryKeys.insights.all, 'pos-bills', locationid],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [...queryKeys.insights.all, 'pos-summary', locationid],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A heartbeat — the presence board, and nothing else. */
|
||||||
|
const onTerminal = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posHealth(locationid) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCustomer = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.customers.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener('sale', onSale);
|
||||||
|
source.addEventListener('terminal', onTerminal);
|
||||||
|
source.addEventListener('customer', onCustomer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors are left alone on purpose.
|
||||||
|
*
|
||||||
|
* `EventSource` reconnects by itself, honouring the `retry:` the server
|
||||||
|
* sends, and it fires `error` on every one of those attempts. Logging here
|
||||||
|
* fills the console with noise during an ordinary backend deploy, and
|
||||||
|
* surfacing it to the operator would be reporting a failure that costs them
|
||||||
|
* nothing — the poll is still running underneath.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
source.removeEventListener('sale', onSale);
|
||||||
|
source.removeEventListener('terminal', onTerminal);
|
||||||
|
source.removeEventListener('customer', onCustomer);
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
|
}, [tenantid, locationid, queryClient]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { LiveEvent };
|
||||||
Reference in New Issue
Block a user