live update

This commit is contained in:
2026-08-27 11:26:04 +05:30
parent 3545819be8
commit 7248e11e65
10 changed files with 404 additions and 23 deletions

View File

@@ -13,7 +13,7 @@ import type { FiestaEnvelope } from './types';
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
* 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. */
export const WEB = '/live/api/v1/web';

View 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`;
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { FileInput } from '@astryxdesign/core/FileInput';
import { HStack } from '@astryxdesign/core/HStack';
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
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 { errorMessage } from '@/api/client';
import { SectionHeader } from '@/components/SectionHeader';
import { SheetDropzone } from '@/components/SheetDropzone';
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
interface PreviewRow extends Record<string, unknown> {
@@ -148,7 +148,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
if (result) {
const isClean = result.failures.length === 0;
return (
<Card padding={4} elevation="low">
<Card padding={4} variant="transparent">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
{isClean ? (
@@ -197,7 +197,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
return (
<VStack gap={2}>
<Card padding={0} elevation="low">
<Card padding={0} variant="transparent">
<VStack gap={2} padding={3}>
<SectionHeader
title="Upload the tenant's product list"
@@ -212,17 +212,18 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
}
/>
<FileInput
label="Product workbook"
value={file}
onChange={handleFile}
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}
<SheetDropzone
file={file}
onFile={handleFile}
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
/>
<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 ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{parseError}
@@ -232,7 +233,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
</Card>
{parsed ? (
<Card padding={0} elevation="low">
<Card padding={0} variant="transparent">
<VStack gap={2} padding={3}>
<SectionHeader
title="What is in the file"

View File

@@ -120,7 +120,7 @@ export function GlobalCataloguePage() {
<VStack gap={2}>
{/* Only here. An upload is written against one merchant and one
outlet, and the endpoint refuses a row without both. */}
<Card padding={0} elevation="low">
<Card padding={0} variant="transparent">
<VStack gap={2} padding={2}>
<SectionHeader
title="Uploading for"

View File

@@ -468,8 +468,8 @@ export function TillDrawer({
isNew
? cashierOnly
? 'Add a cashier'
: 'Add a till account'
: (row.full_name ?? 'Edit till account')
: 'Add a terminal account'
: (row.full_name ?? 'Edit terminal account')
}
subtitle={
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, Monitor, Store, Users } from 'lucide-react';
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
/**
* Store Admin — the merchant workspace.
@@ -42,7 +43,7 @@ const MANAGE: readonly MenuEntry[] = [
to: '/admin/users',
label: 'Users & access',
icon: <Users size={13} />,
note: 'Store and till accounts',
note: 'Store and terminal accounts',
},
{
to: '/admin/terminals',
@@ -55,6 +56,7 @@ const MANAGE: readonly MenuEntry[] = [
export function StoreAdminShell() {
return (
<BranchScopeProvider>
<LiveWatch />
<AppShell
nav={NAV}
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.
*

View File

@@ -987,7 +987,10 @@ function StatusTab({
padding: '0 11px',
borderRadius: 12,
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)',
fontSize: 12.5,
fontWeight: 500,

View File

@@ -111,7 +111,7 @@ export function UsersPage() {
description="Who can sign in to the console, and who can open a till."
actions={
<Button
label={group === 'store' ? 'Add person' : 'Add till account'}
label={group === 'store' ? 'Add person' : 'Add terminal account'}
variant="primary"
size="sm"
icon={<Plus size={14} />}
@@ -137,7 +137,7 @@ export function UsersPage() {
}}
/>
<TabButton
label="Till accounts"
label="Terminal accounts"
icon={<Monitor size={14} />}
badge={tillRows.length || undefined}
isActive={group === 'till'}
@@ -314,12 +314,12 @@ function TillTable({
showBranch: boolean;
onEdit: (row: PosUser, locationid: number) => void;
}) {
if (isLoading) return <Loading what="till accounts" />;
if (isLoading) return <Loading what="terminal accounts" />;
if (rows.length === 0) {
return (
<Empty
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."
/>
);

View File

@@ -5,6 +5,7 @@ import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/component
import { useAuth } from '@/auth/AuthContext';
import { useTenantLocations } from '@/queries/hooks';
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
/**
* Store user — one branch, and only that branch.
@@ -100,6 +101,7 @@ export function StoreUserShell() {
return (
<BranchScopeProvider pin={locationid}>
<LiveWatch />
<AppShell
nav={NAV}
home="/store/console"
@@ -203,3 +205,15 @@ function NoStore() {
</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;
}

View 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 };