feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket id barcode; site_config.exit_voucher_default (migration 0002) drives the default. Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher. Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'), SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
boothExit,
|
||||
fetchSiteConfig,
|
||||
lookupSession,
|
||||
paySession,
|
||||
printVoucher,
|
||||
type SessionLookup,
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
|
||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
||||
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
|
||||
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
|
||||
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
|
||||
|
||||
type Phase = "review" | "paying" | "finishing" | "done" | "error";
|
||||
|
||||
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
|
||||
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
|
||||
|
||||
const [tender, setTender] = useState<"cash" | "card">("cash");
|
||||
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("review");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const s: SessionLookup | undefined = session.data;
|
||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||
|
||||
const alreadyPaid = s?.paidAt != null;
|
||||
const canPay = s?.found && s.open && !alreadyPaid;
|
||||
|
||||
async function handlePayAndExit() {
|
||||
if (!s) return;
|
||||
setError(null);
|
||||
try {
|
||||
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
|
||||
if (!alreadyPaid) {
|
||||
setPhase("paying");
|
||||
await paySession(identity, tender);
|
||||
}
|
||||
// 2. Voucher OR immediate exit.
|
||||
setPhase("finishing");
|
||||
if (voucher) {
|
||||
const r = await printVoucher(identity);
|
||||
setResult(`Exit voucher printed on ${r.printedBy}. Customer self-exits at the exit.`);
|
||||
} else {
|
||||
const r = await boothExit(identity);
|
||||
setResult(
|
||||
r.opened
|
||||
? "Paid — barrier opened. Car may exit."
|
||||
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`,
|
||||
);
|
||||
}
|
||||
// Refresh the live views.
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
setPhase("done");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setPhase("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
Ticket {identity}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
|
||||
✕
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{session.isLoading && <div className="text-term-muted">looking up…</div>}
|
||||
|
||||
{s && !s.found && (
|
||||
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
|
||||
No session found for this ticket.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && !s.open && (
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
This session is already closed (exited {formatTime(s.exitedAt)}).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && s.open && (
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label="Entry" value={formatTime(s.enteredAt)} />
|
||||
<Row label="Now" value={formatTime(new Date().toISOString())} />
|
||||
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
|
||||
<Row
|
||||
label="Status"
|
||||
value={alreadyPaid ? "PAID" : "UNPAID"}
|
||||
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">Total</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? "paid"
|
||||
: "no tariff"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
{phase !== "done" && (
|
||||
<>
|
||||
{/* Tender */}
|
||||
{canPay && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">Tender</span>
|
||||
{(["cash", "card"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTender(t)}
|
||||
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
|
||||
tender === t
|
||||
? "border-term-amber text-term-amber"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voucher checkbox (default from site config) */}
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={voucher}
|
||||
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||||
/>
|
||||
Printo biletë dalje
|
||||
<span className="text-term-muted">(customer self-exits at the exit)</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||
{result && (
|
||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
{phase === "done" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
? "taking payment…"
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? "printing voucher…"
|
||||
: "opening…"
|
||||
: alreadyPaid
|
||||
? voucher
|
||||
? "Print voucher"
|
||||
: "Open barrier"
|
||||
: voucher
|
||||
? "Pay + print voucher"
|
||||
: "Pay + open barrier"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||
const [capInput, setCapInput] = useState("");
|
||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
function reload() {
|
||||
@@ -31,6 +32,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
fetchSiteConfig()
|
||||
.then((c) => {
|
||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||
setExitVoucherDefault(c.exitVoucherDefault);
|
||||
const m: Record<string, string> = {};
|
||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||
setMeta(m);
|
||||
@@ -41,7 +43,10 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
const raw = capInput.trim();
|
||||
const patch: Partial<SiteConfig> = { capacity: raw === "" ? null : Math.round(Number(raw)) };
|
||||
const patch: Partial<SiteConfig> = {
|
||||
capacity: raw === "" ? null : Math.round(Number(raw)),
|
||||
exitVoucherDefault,
|
||||
};
|
||||
// Send each metadata field; "" → null is applied server-side.
|
||||
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||
try {
|
||||
@@ -75,6 +80,17 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
Capacity (blank = no limit):{" "}
|
||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exitVoucherDefault}
|
||||
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
||||
/>
|
||||
Print exit ticket by default
|
||||
<span style={{ color: "#888", fontSize: "0.8rem" }}>
|
||||
(booth far from exit → customer self-exits with a voucher)
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||
Park details (optional — shown on tickets/receipts)
|
||||
</div>
|
||||
|
||||
+130
-1
@@ -311,6 +311,9 @@ export function deletePermit(id: string): Promise<void> {
|
||||
export interface ShiftStatus {
|
||||
operator: string;
|
||||
open: { startedAt: string } | null;
|
||||
/** Live physical drawer balance (cash payments + cash movements). */
|
||||
drawerMinor: number;
|
||||
currency: string | null;
|
||||
}
|
||||
export interface ShiftReport {
|
||||
operator: string;
|
||||
@@ -320,19 +323,35 @@ export interface ShiftReport {
|
||||
cardTotalMinor: number;
|
||||
currency: string | null;
|
||||
paymentCount: number;
|
||||
// Drawer (carries across shifts).
|
||||
openingFloatMinor: number;
|
||||
cashAddedMinor: number;
|
||||
cashRemovedMinor: number;
|
||||
expectedDrawerMinor: number;
|
||||
printed: boolean;
|
||||
}
|
||||
|
||||
export function fetchShift(): Promise<ShiftStatus> {
|
||||
return apiFetch("/api/shift/current");
|
||||
}
|
||||
export function openShift(): Promise<{ startedAt: string }> {
|
||||
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
return apiFetch("/api/shift/open", { method: "POST" });
|
||||
}
|
||||
export function closeShift(): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
|
||||
export function recordCashMovement(
|
||||
amountMinor: number,
|
||||
reason: string,
|
||||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||||
return apiFetch("/api/cash-movement", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ amountMinor, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Site config / occupancy ----------------------------------------------
|
||||
|
||||
export interface Occupancy {
|
||||
@@ -345,6 +364,8 @@ export interface Occupancy {
|
||||
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
|
||||
export interface SiteConfig {
|
||||
capacity: number | null;
|
||||
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||
exitVoucherDefault: boolean;
|
||||
parkName: string | null;
|
||||
operatorName: string | null;
|
||||
/** NIUS — Albanian tax/identification number. */
|
||||
@@ -357,6 +378,114 @@ export interface SiteConfig {
|
||||
export function fetchOccupancy(): Promise<Occupancy> {
|
||||
return apiFetch("/api/occupancy");
|
||||
}
|
||||
|
||||
// --- Ledger events (the signed audit trail; read-only) --------------------
|
||||
|
||||
/** A persisted ledger row. Re-exported from shared so UI code has one source of
|
||||
* truth for the event shape (the same type the WS pushes). */
|
||||
export type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||
* booth feed's initial load; live updates then arrive over the WS. */
|
||||
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||
return apiFetch(`/api/events?limit=${limit}`);
|
||||
}
|
||||
|
||||
// --- Booth: session lookup, payment, exit ---------------------------------
|
||||
|
||||
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
|
||||
export interface SessionLookup {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
enteredAt: string | null;
|
||||
exitedAt: string | null;
|
||||
paidAt: string | null;
|
||||
amountMinor: number | null;
|
||||
currency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
}
|
||||
|
||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||
export function lookupSession(identity: string): Promise<SessionLookup> {
|
||||
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
|
||||
}
|
||||
|
||||
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
|
||||
export interface ActiveSession {
|
||||
identity: string;
|
||||
source: string | null;
|
||||
enteredAt: string;
|
||||
exitedAt: string | null;
|
||||
open: boolean;
|
||||
paidAt: string | null;
|
||||
amountMinor: number | null;
|
||||
currency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
}
|
||||
|
||||
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
|
||||
return apiFetch("/api/sessions/active");
|
||||
}
|
||||
|
||||
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
|
||||
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
|
||||
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
|
||||
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
}
|
||||
|
||||
/** Take payment for a session → signed payment event. `overrideMinor` sets an
|
||||
* operator amount (lost ticket / dispute). */
|
||||
export function paySession(
|
||||
identity: string,
|
||||
tender: "cash" | "card",
|
||||
overrideMinor?: number,
|
||||
): Promise<{ amountMinor: number; currency: string }> {
|
||||
return apiFetch("/api/pay", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||
* open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
}
|
||||
|
||||
/** Print an exit voucher (paid ticket id reprinted) for self-exit at a distant
|
||||
* exit. Requires the session to be paid. */
|
||||
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
|
||||
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
}
|
||||
|
||||
// --- Snapshots (entry/exit evidence images) -------------------------------
|
||||
|
||||
export interface SnapshotMeta {
|
||||
id: string;
|
||||
direction: "entry" | "exit" | null;
|
||||
deviceId: string;
|
||||
identity: string;
|
||||
contentType: string;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
/** Snapshot metadata for a session identity (newest first). Image bytes are at
|
||||
* `/api/snapshots/:id` — use that URL directly as an <img src>. */
|
||||
export function fetchSnapshots(identity: string): Promise<{ snapshots: SnapshotMeta[] }> {
|
||||
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
|
||||
}
|
||||
|
||||
/** URL for a snapshot's image bytes (cookie-authed; usable as <img src>). */
|
||||
export function snapshotImageUrl(id: string): string {
|
||||
return `/api/snapshots/${encodeURIComponent(id)}`;
|
||||
}
|
||||
export function fetchSiteConfig(): Promise<SiteConfig> {
|
||||
return apiFetch("/api/site-config");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Small formatting helpers for the booth. Money is integer MINOR units (never a
|
||||
// float — matches the tariff/ledger model); duration is whole minutes.
|
||||
|
||||
/** Format integer minor units + ISO-4217 currency as a major-unit string. */
|
||||
export function formatMoney(amountMinor: number, currency: string): string {
|
||||
const major = amountMinor / 100;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(major);
|
||||
} catch {
|
||||
// Unknown/garbled currency code — fall back to a plain number + the code.
|
||||
return `${major.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Human duration between two ISO times, e.g. "2h 14m" / "47m" / "0m". */
|
||||
export function formatDuration(fromIso: string, toIso: string): string {
|
||||
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
||||
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||
export function formatTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
||||
|
||||
// Entry/exit evidence images for a session. Lets the operator verify the car at the
|
||||
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
|
||||
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
||||
|
||||
export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["snapshots", identity],
|
||||
queryFn: () => fetchSnapshots(identity),
|
||||
enabled: !!identity,
|
||||
});
|
||||
const [zoom, setZoom] = useState<string | null>(null);
|
||||
|
||||
const shots = data?.snapshots ?? [];
|
||||
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">loading snapshots…</div>;
|
||||
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no snapshots</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{shots.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setZoom(s.id)}
|
||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||
title={`${s.direction ?? "snapshot"} · ${new Date(s.capturedAt).toLocaleString()}`}
|
||||
>
|
||||
<img
|
||||
src={snapshotImageUrl(s.id)}
|
||||
alt={s.direction ?? "snapshot"}
|
||||
className="h-20 w-28 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`text-[9px] uppercase tracking-wider ${
|
||||
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||
}`}
|
||||
>
|
||||
{s.direction ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{zoom && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
||||
onClick={() => setZoom(null)}
|
||||
>
|
||||
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user