diff --git a/src/api/client.ts b/src/api/client.ts
index 233fc56..05737c8 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -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';
diff --git a/src/components/SheetDropzone.tsx b/src/components/SheetDropzone.tsx
new file mode 100644
index 0000000..54315b8
--- /dev/null
+++ b/src/components/SheetDropzone.tsx
@@ -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 `` 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(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const isDisabled = Boolean(blockedReason);
+
+ function open() {
+ if (!isDisabled) inputRef.current?.click();
+ }
+
+ function handleDrop(event: DragEvent) {
+ event.preventDefault();
+ setIsDragging(false);
+ if (isDisabled) return;
+ const dropped = event.dataTransfer.files?.[0];
+ if (dropped) onFile(dropped);
+ }
+
+ /* ── Filled ───────────────────────────────────────────────────────────── */
+
+ if (file) {
+ return (
+
+ );
+}
+
+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`;
+}
diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/nearle-admin/import/SheetImportPanel.tsx
index 35795d1..06c3ff2 100644
--- a/src/features/nearle-admin/import/SheetImportPanel.tsx
+++ b/src/features/nearle-admin/import/SheetImportPanel.tsx
@@ -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 {
@@ -148,7 +148,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
if (result) {
const isClean = result.failures.length === 0;
return (
-
+
{isClean ? (
@@ -197,7 +197,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
return (
-
+
-
+
+ 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.
+
+
{parseError ? (
{parseError}
@@ -232,7 +233,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
{parsed ? (
-
+
{/* Only here. An upload is written against one merchant and one
outlet, and the endpoint refuses a row without both. */}
-
+ ,
- 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 (
+ }
@@ -137,7 +137,7 @@ export function UsersPage() {
}}
/>
}
badge={tillRows.length || undefined}
isActive={group === 'till'}
@@ -314,12 +314,12 @@ function TillTable({
showBranch: boolean;
onEdit: (row: PosUser, locationid: number) => void;
}) {
- if (isLoading) return ;
+ if (isLoading) return ;
if (rows.length === 0) {
return (
}
- 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."
/>
);
diff --git a/src/features/store-user/StoreUserShell.tsx b/src/features/store-user/StoreUserShell.tsx
index ef38281..974a55b 100644
--- a/src/features/store-user/StoreUserShell.tsx
+++ b/src/features/store-user/StoreUserShell.tsx
@@ -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 (
+
);
}
+
+/**
+ * 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;
+}
diff --git a/src/queries/useLiveEvents.ts b/src/queries/useLiveEvents.ts
new file mode 100644
index 0000000..c4edb05
--- /dev/null
+++ b/src/queries/useLiveEvents.ts
@@ -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 };