feat(web): frontend foundation — Tailwind terminal theme, Query, Router, Zustand + live booth screen
Add tailwindcss (Bloomberg-terminal theme in index.css), @tanstack/react-query + react-router, zustand, and Radix primitives. Router with role-guarded routes; QueryClient wrapping the existing apiFetch; a small Zustand live store fed by a /api/ws client that invalidates Query caches. Booth screen: live occupancy gauge + streaming entry/exit/payment feed. Vite proxies the WS upgrade. Note: BoothScreen references the pay/exit modal + active-sessions panel added in following commits; final HEAD builds.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useLiveStore } from "./lib/live-store.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothPayModal } from "./BoothPayModal.js";
|
||||
import { ActiveSessions } from "./ActiveSessions.js";
|
||||
|
||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||
// the authoritative numbers; the WS-fed live store overlays real-time updates so
|
||||
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
||||
|
||||
/** Per-event-type display: label + accent colour for the ticker. */
|
||||
const EVENT_STYLE: Record<string, { label: string; color: string }> = {
|
||||
vehicle_entry: { label: "ENTRY", color: "text-term-green" },
|
||||
vehicle_exit: { label: "EXIT", color: "text-term-red" },
|
||||
payment: { label: "PAY", color: "text-term-cyan" },
|
||||
void: { label: "VOID", color: "text-term-amber" },
|
||||
barrier_open_command: { label: "OPEN→", color: "text-term-muted" },
|
||||
barrier_open_observed: { label: "OPEN✓", color: "text-term-muted" },
|
||||
shift_open: { label: "SHIFT+", color: "text-term-amber" },
|
||||
shift_z_report: { label: "SHIFT Z", color: "text-term-amber" },
|
||||
anomaly: { label: "ANOMALY", color: "text-term-red" },
|
||||
};
|
||||
|
||||
function hhmmss(iso: string): string {
|
||||
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
||||
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||
<div className="pb-1 text-term-muted">
|
||||
<div className="text-[11px] uppercase tracking-wider">inside</div>
|
||||
<div className="text-sm tabular-nums">
|
||||
{occ.capacity == null ? "uncapped" : `of ${occ.capacity}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">free</div>
|
||||
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||
{occ.free == null ? "∞" : occ.free}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pct != null && (
|
||||
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
|
||||
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{occ.full && (
|
||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
||||
● lot full
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ e }: { e: LedgerEvent }) {
|
||||
const style = EVENT_STYLE[e.type] ?? { label: e.type.toUpperCase(), color: "text-term-text" };
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`w-20 shrink-0 font-semibold ${style.color}`}>{style.label}</span>
|
||||
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
|
||||
<span className="ml-auto text-term-muted">#{e.index}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
||||
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
||||
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
|
||||
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const id = value.trim();
|
||||
if (id) {
|
||||
onSubmit(id);
|
||||
setValue("");
|
||||
ref.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={ref}
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Scan or type ticket number…"
|
||||
inputMode="numeric"
|
||||
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoothScreen() {
|
||||
// Initial load via Query (also the fallback if the WS is briefly down).
|
||||
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
|
||||
|
||||
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||
|
||||
// Live overlays from the WS store.
|
||||
const liveOcc = useLiveStore((s) => s.occupancy);
|
||||
const liveFeed = useLiveStore((s) => s.feed);
|
||||
|
||||
// Prefer the live-pushed occupancy; fall back to the query.
|
||||
const occ = liveOcc ?? occQuery.data ?? null;
|
||||
|
||||
// Merge: live events first (newest), then the queried history, de-duped by id.
|
||||
const seen = new Set(liveFeed.map((e) => e.id));
|
||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||
const events = [...liveFeed, ...history].slice(0, 200);
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
||||
<div className="lg:col-span-2">
|
||||
<Panel title="Process ticket">
|
||||
<TicketInput onSubmit={setActiveTicket} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Left column: occupancy gauge above the active-sessions list. */}
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<Panel title="Occupancy" right={<StatusDot />}>
|
||||
{occ ? (
|
||||
<OccupancyGauge occ={occ} />
|
||||
) : (
|
||||
<div className="text-term-muted">{occQuery.isError ? "occupancy unavailable" : "loading…"}</div>
|
||||
)}
|
||||
</Panel>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ActiveSessions onPick={setActiveTicket} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Live feed"
|
||||
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{events.length} events</span>}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-term-muted">
|
||||
{eventsQuery.isLoading ? "loading…" : "no events yet — entries and exits will stream here."}
|
||||
</div>
|
||||
) : (
|
||||
events.map((e) => <EventRow key={e.id} e={e} />)
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user