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 ( +
+ + + + +
+
+ {file.name} +
+
+ {formatSize(file.size)} +
+
+ + + + + onFile(event.target.files?.[0] ?? null)} + style={{ display: 'none' }} + /> +
+ ); + } + + /* ── Waiting / dragging ───────────────────────────────────────────────── */ + + return ( +
{ + 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', + }} + > + + + + +
+
+ {isDisabled + ? blockedReason + : isDragging + ? 'Drop it here' + : 'Drop your workbook, or click to browse'} +
+
+ {ACCEPTED.join(' · ')} +
+
+ + onFile(event.target.files?.[0] ?? null)} + style={{ display: 'none' }} + /> +
+ ); +} + +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 };