Files
parking_solution/apps/web/src/BoothScreen.tsx
T
julian 4e2e4feedb feat(shift): site-wide single-open shift + booth money-path gate
A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).

Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
  shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
  /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).

Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
  pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
  invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.

Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.

Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
2026-06-18 12:13:17 +02:00

210 lines
8.9 KiB
TypeScript

import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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 { useShift } from "./lib/use-shift.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: i18n label key + accent colour for the ticker. */
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
anomaly: { labelKey: "booth.evtAnomaly", 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 { t } = useTranslation();
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">{t("booth.inside")}</div>
<div className="text-sm tabular-nums">
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div>
</div>
<div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.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">
{t("booth.lotFull")}
</div>
)}
</div>
);
}
function EventRow({ e }: { e: LedgerEvent }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
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 ?? "text-term-text"}`}>{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 { t } = useTranslation();
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={t("booth.scanPlaceholder")}
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"
>
{t("booth.open")}
</button>
</form>
);
}
export function BoothScreen() {
const { t } = useTranslation();
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
// window (per-shift logs, not all history). When no shift is open, the feed is
// empty and the operator is prompted to open one.
const { isOpen: shiftOpen, startedAt: shiftStart } = useShift();
// Initial load via Query (also the fallback if the WS is briefly down). The events
// query is scoped to the current shift's start so it never shows prior shifts.
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
enabled: shiftOpen,
});
// 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 —
// then clip to the current shift window (the live store spans shifts; the feed
// must not show events from before this shift's start). No shift → no feed.
const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200);
const events =
shiftOpen && shiftStart
? merged.filter((e) => e.occurredAt >= shiftStart)
: [];
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={t("booth.processTicket")}>
<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={t("booth.occupancy")} right={<StatusDot />}>
{occ ? (
<OccupancyGauge occ={occ} />
) : (
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
)}
</Panel>
<div className="min-h-0 flex-1">
<ActiveSessions onPick={setActiveTicket} />
</div>
</div>
<Panel
title={t("booth.liveFeed")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{events.length} {t("booth.events")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{!shiftOpen ? (
<div className="text-term-amber">{t("shift.gateTitle")}</div>
) : events.length === 0 ? (
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} />)
)}
</div>
</Panel>
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
</div>
);
}