diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index 110eec2..7fa2a2f 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -1,8 +1,7 @@ -import { useRef, useState, type ReactNode } from "react"; +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 { formatMoney } from "./lib/format.js"; import { qk } from "./lib/query.js"; import { useLiveStore } from "./lib/live-store.js"; import { useShift } from "./lib/use-shift.js"; @@ -10,10 +9,8 @@ import { Panel } from "./ui/Panel.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothPayModal } from "./BoothPayModal.js"; import { ActiveSessions } from "./ActiveSessions.js"; -import { Modal } from "./ui/Modal.js"; -import { SnapshotStrip } from "./ui/SnapshotStrip.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; -import { renderReason } from "./lib/reason.js"; +import { EventDetailModal, EventRow } from "./ui/event-detail.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 @@ -21,21 +18,6 @@ import { renderReason } from "./lib/reason.js"; // 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 = { - 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" }, - cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" }, - cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" }, - anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, -}; - // Live-feed filter category for an event type. Several ledger types collapse into a // few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the // filter and only show under "all". @@ -57,12 +39,6 @@ function feedCat(type: string): FeedCat | null { } } -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; @@ -98,263 +74,6 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) { ); } -/** Translated classification badges derived from a payload's boolean flags. Unlike - * `reason` (an immutable English sentence baked into the signed ledger, shown - * verbatim), these are computed client-side so they CAN be localized. They give a - * glanceable "what kind of anomaly" tag without parsing the free-text reason. */ -function eventBadges(p: LedgerEvent["payload"]): string[] { - if (!p) return []; - const keys: string[] = []; - if (p.entryRefused) keys.push("booth.badgeEntryRefused"); - if (p.exitRefused) keys.push("booth.badgeExitRefused"); - if (p.full) keys.push("booth.badgeLotFull"); - if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed"); - if (p.permitRefused) keys.push("booth.badgeSubRefused"); - if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket"); - if (p.subscriptionSale) keys.push("booth.badgeSubSale"); - // Subscriber entered outside their plan's allowed window → will owe a transient charge - // for the minutes actually parked out-of-window, priced + collected (gated) at exit. - // Flag it so the operator KNOWS now. (`windowOwedMinor` is the old fixed-amount stamp, - // kept so historic events still badge.) - if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0)) - keys.push("booth.badgeWindowCharge"); - if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen"); - return keys; -} - -/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity - * log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */ -function viaKey(p: LedgerEvent["payload"]): string | null { - if (!p) return null; - if (p.via === "qr") return "booth.viaQr"; - if (p.via === "card") return "booth.viaCard"; - if (p.via === "plate") return "booth.viaPlate"; - return null; -} - -/** A short money summary for payment events (e.g. "350.00 ALL"). */ -function paymentSummary(p: LedgerEvent["payload"]): string | null { - if (!p || typeof p.amountMinor !== "number" || !p.currency) return null; - return formatMoney(p.amountMinor, p.currency); -} - -/** What to SHOW for an event's actor. A subscription occurrence has an opaque - * `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`, - * so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */ -function displayIdentity(e: LedgerEvent): string { - return e.subscriberLabel ?? e.identity ?? "—"; -} - -function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) { - const { t } = useTranslation(); - const style = EVENT_STYLE[e.type]; - const label = style ? t(style.labelKey) : e.type.toUpperCase(); - const isAnomaly = e.type === "anomaly"; - const p = e.payload; - // Localize the reason from the signed reasonCode (falls back to the English text on - // legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent. - const reason = renderReason(p, t); - const amount = paymentSummary(p); - const badges = eventBadges(p); - const via = viaKey(p); - const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null); - const showDetail = detail != null || badges.length > 0 || via != null; - - // The whole row is a button → opens the event-detail modal (full payload + the - // session's entry/exit snapshots). A grid keeps the time/label/identity/index - // columns aligned across rows; the detail line lives in its own row, indented to - // start under the identity column so it never collides with the ticket code. - return ( - - ); -} - -/** One label/value line in the event-detail modal. */ -function DetailRow({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - -/** Full read-only detail for one ledger event: business fields + the human-readable - * reason + the session's entry/exit snapshots, then the signed-chain provenance - * (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable; - * this only DISPLAYS the signed record. */ -function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) { - const { t } = useTranslation(); - const style = EVENT_STYLE[e.type]; - const label = style ? t(style.labelKey) : e.type.toUpperCase(); - const p = e.payload; - const reason = renderReason(p, t); - const amount = paymentSummary(p); - const badges = eventBadges(p); - const isAnomaly = e.type === "anomaly"; - - // Pretty money for any minor-unit amount in the payload. - const money = - p && typeof p.amountMinor === "number" && typeof p.currency === "string" - ? formatMoney(p.amountMinor, p.currency) - : null; - // Pull out the business fields worth a labelled row. Everything else (and the raw - // bytes) lives behind the audit disclosure — the operator sees a clean summary. - const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null; - const plate = typeof p?.plate === "string" ? p.plate : null; - const category = typeof p?.category === "string" ? p.category : null; - const operator = typeof p?.operator === "string" ? p.operator : null; - const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null; - - return ( - -
- {/* Headline: the type + localized reason, prominent for anomalies. */} -
-
{label}
- {(reason || money) && ( -
- {reason ?? money} -
- )} - {!reason && !money && isAnomaly && ( -
{t("booth.evtNoReason")}
- )} - {badges.length > 0 && ( -
- {badges.map((k) => ( - - {t(k)} - - ))} -
- )} -
- - {/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */} -
- {new Date(e.occurredAt).toLocaleString()} - #{e.index} - {e.direction && {e.direction}} - {e.source && {e.source}} - {displayIdentity(e)} - {/* When we showed a subscriber NAME above, also expose the raw occurrence id - (the SUBSESS-… session key) for traceability against the ledger. */} - {e.subscriberLabel && e.identity && ( - - {e.identity} - - )} - {money && ( - - {money} - - )} - {typeof p?.tender === "string" && {p.tender}} - {viaKey(p) && ( - - {t(viaKey(p)!)} - - )} - {category && {category}} - {plate && {plate}} - {operator && {operator}} - {sessionRef && sessionRef !== e.identity && ( - {sessionRef} - )} - {tariffVersionId && ( - - {tariffVersionId} - - )} -
- - {/* The entry/exit evidence images for this session's identity. */} - {e.identity && ( -
-
{t("booth.edSnapshots")}
- -
- )} - - {/* Audit data — collapsed by default. The signed-chain provenance (signature, - key, prev-hash) and the raw payload are an auditor's concern, not the - operator's; tucking them behind a disclosure keeps the common view clean - while preserving the tamper-evidence trail on demand. */} -
- - {t("booth.edAuditData")} - -
- - {e.signature} - - - {e.keyId} - - - {e.prevHash ?? "—"} - -
- {t("booth.edRawPayload")} -
- {p && Object.keys(p).length > 0 ? ( -
-                {JSON.stringify(p, null, 2)}
-              
- ) : ( -
{t("booth.edNoPayload")}
- )} -
-
-
-
- ); -} /** 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 diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 3b83bd5..45d3557 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -15,6 +15,7 @@ import { } from "./api.js"; import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js"; import { Modal } from "./ui/Modal.js"; +import { EventDetailModal, EventRow } from "./ui/event-detail.js"; import type { LedgerEvent } from "@parking/shared"; // Shift hub — a two-pane master/detail. LEFT: the open/CURRENT shift (when any) plus @@ -28,22 +29,6 @@ function money(minor: number, currency: string | null): string { return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2); } -// Event styling for the activity log (mirrors the booth live feed). -const EVENT_STYLE: Record = { - 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" }, - cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" }, - cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" }, - anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, -}; - type Preset = "yesterday" | "week" | "month" | "custom" | "all"; /** A preset → an inclusive [from, to] date window (yyyy-mm-dd) over the shift START. */ @@ -142,9 +127,12 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"]; + // Fill the viewport like the booth: a fixed title + filters, then a two-pane area + // that takes the remaining height — the shift LIST and the activity LOG each scroll + // on their own rather than the whole page growing. return ( -
-
+
+

{isAdmin ? t("shifts.title") : t("shifts.myTitle")}

@@ -155,7 +143,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
{/* Filters: timeframe presets (everyone) + operator (admin only). */} -
+
{t("shifts.timeframe")}
@@ -187,12 +175,13 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
{q.isError && ( -
{t("shifts.loadFailed")}
+
{t("shifts.loadFailed")}
)} - {/* Two-pane: shift list (left) + selected shift's activity log (right). */} -
-
+ {/* Two-pane: shift list (left) + selected shift's activity log (right). Both + panes scroll independently and fill the remaining height (like the booth). */} +
+
{!q.isLoading && list.length === 0 && (

{t("shifts.none")}

)} @@ -201,7 +190,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { ))}
-
+
{selected ? ( (null); + // Click an activity row → the SAME read-only event-detail modal the booth feed opens + // (full signed payload + snapshots + chain provenance). + const [detailEvent, setDetailEvent] = useState(null); // The current shift's log runs entry→now (no upper bound); a closed shift is bounded. const q = useQuery({ @@ -304,9 +296,10 @@ function ShiftActivityLog({ const events = q.data?.events ?? []; const cur = shift.currency; + // Fill the pane: a fixed header + a scrollable activity list (matches the booth feed). return ( -
-
+
+
{isCurrent && {t("shifts.current")}} @@ -337,14 +330,15 @@ function ShiftActivityLog({
-
+
{q.isLoading &&

{t("common.loading")}

} {!q.isLoading && events.length === 0 &&

{t("shifts.noActivity")}

} {events.map((e) => ( - + ))}
+ {detailEvent && setDetailEvent(null)} />} {modal === "end" && setModal(null)} onDone={onChanged} />} {modal === "voucher" && setModal(null)} onDone={onChanged} />} {modal === "takings" && setModal(null)} />} @@ -516,23 +510,6 @@ function TakingsModal({ onClose }: { onClose: () => void }) { ); } -function ActivityRow({ e }: { e: LedgerEvent }) { - const { t } = useTranslation(); - const style = EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" }; - const time = new Date(e.occurredAt).toLocaleTimeString(); - const p = e.payload ?? {}; - const amount = typeof p.amountMinor === "number" && p.amountMinor !== 0 ? money(p.amountMinor, (p.currency as string) ?? null) : null; - const actor = (e.subscriberLabel as string | undefined) ?? e.identity ?? (p.sessionRef as string | undefined) ?? ""; - return ( -
- {time} - {style.labelKey ? t(style.labelKey) : e.type} - {actor} - {amount && {amount}} -
- ); -} - function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { return (
diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 6a078d9..9fe39c0 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -669,7 +669,7 @@ export const sq = { preset_week: "Javën e fundit", preset_month: "Muajin e fundit", preset_all: "Të gjitha", - preset_custom: "E zgjedhur", + preset_custom: "Zgjidh periudhë", selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.", noActivity: "Asnjë aktivitet në këtë turn.", current: "aktual", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index e783dbb..1d78fcc 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -368,7 +368,7 @@ function RootLayout() { ▮ Parking