feat(shifts): /shift→/shifts, clickable activity log (shared event-detail), booth-style full-height layout
Three changes to the shift hub, addressing the report: 1. Route rename /shift → /shifts (matches the plural "Turnet" label and the section). /shift and /setup/shifts both redirect to /shifts; the header link and the operator-landing fallback point at /shifts. 2. The activity-log rows are now CLICKABLE and open the same read-only event-detail modal the booth live feed uses (full signed payload + entry/exit snapshots + chain provenance) — previously they were static rows. Extracted EVENT_STYLE, the feed row, the detail modal, and their helpers out of BoothScreen into a shared apps/web/src/ui/event-detail.tsx imported by both the booth and the shift log, so the two render and behave identically and can't drift. 3. Reworked the /shifts layout to fill the viewport like /booth: a fixed title + filters, then a two-pane area (shift list | activity log) where each pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the whole page growing. ShiftActivityLog is now a flex column with a fixed header and a scrollable list. Verified at runtime (Playwright): /shift redirects to /shifts, an activity row opens the detail modal, the layout fills height, and the booth still works (0 console errors after the extraction). build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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<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" },
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(e)}
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||
isAnomaly ? "bg-term-red/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||
{e.plate && (
|
||||
<span
|
||||
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{e.plate}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-term-muted">#{e.index}</span>
|
||||
{showDetail && (
|
||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{via && (
|
||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||
{t(via)}
|
||||
</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label/value line in the event-detail modal. */
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
||||
{(reason || money) && (
|
||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
{reason ?? money}
|
||||
</div>
|
||||
)}
|
||||
{!reason && !money && isAnomaly && (
|
||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||
)}
|
||||
{badges.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||
<div>
|
||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
||||
{/* 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 && (
|
||||
<DetailRow label={t("booth.edOccurrence")}>
|
||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{money && (
|
||||
<DetailRow label={t("booth.edAmount")}>
|
||||
<span className="text-term-cyan">{money}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
||||
{viaKey(p) && (
|
||||
<DetailRow label={t("booth.edVia")}>
|
||||
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
{sessionRef && sessionRef !== e.identity && (
|
||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||
)}
|
||||
{tariffVersionId && (
|
||||
<DetailRow label={t("booth.edTariffVersion")}>
|
||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The entry/exit evidence images for this session's identity. */}
|
||||
{e.identity && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||
<SnapshotStrip identity={e.identity} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
{t("booth.edAuditData")}
|
||||
</summary>
|
||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||
<DetailRow label={t("booth.edSignature")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edKeyId")}>
|
||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edPrevHash")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||
</DetailRow>
|
||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("booth.edRawPayload")}
|
||||
</div>
|
||||
{p && Object.keys(p).length > 0 ? (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
||||
{JSON.stringify(p, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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<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" },
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||
</h1>
|
||||
@@ -155,7 +143,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
</div>
|
||||
|
||||
{/* Filters: timeframe presets (everyone) + operator (admin only). */}
|
||||
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
|
||||
<div className="card mb-3 flex shrink-0 flex-wrap items-end gap-3 p-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("shifts.timeframe")}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -187,12 +175,13 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
</div>
|
||||
|
||||
{q.isError && (
|
||||
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
||||
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
||||
)}
|
||||
|
||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). */}
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
||||
panes scroll independently and fill the remaining height (like the booth). */}
|
||||
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||
{!q.isLoading && list.length === 0 && (
|
||||
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
||||
)}
|
||||
@@ -201,7 +190,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-term border border-term-border">
|
||||
<div className="min-h-0 overflow-hidden rounded-term border border-term-border">
|
||||
{selected ? (
|
||||
<ShiftActivityLog
|
||||
shift={selected}
|
||||
@@ -294,6 +283,9 @@ function ShiftActivityLog({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(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<LedgerEvent | null>(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 (
|
||||
<div>
|
||||
<div className="border-b border-term-border bg-term-panel-2 px-3 py-2">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||
@@ -337,14 +330,15 @@ function ShiftActivityLog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||
{events.map((e) => (
|
||||
<ActivityRow key={e.id} e={e} />
|
||||
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "takings" && <TakingsModal onClose={() => 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 (
|
||||
<div className="flex items-center gap-2 border-t border-term-border/60 px-3 py-1.5 text-[12px] first:border-t-0">
|
||||
<span className="w-16 shrink-0 tabular-nums text-term-muted">{time}</span>
|
||||
<span className={`w-20 shrink-0 font-semibold uppercase ${style.color}`}>{style.labelKey ? t(style.labelKey) : e.type}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-term-text" title={actor}>{actor}</span>
|
||||
{amount && <span className="shrink-0 tabular-nums text-term-cyan">{amount}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -368,7 +368,7 @@ function RootLayout() {
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shift" label={t("nav.shifts")} />
|
||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||
Shown if the user can reach ANY of its tabs. */}
|
||||
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||
@@ -437,10 +437,11 @@ const legacyRedirects = (
|
||||
["/site", "/setup/site"],
|
||||
["/users", "/setup/users"],
|
||||
["/roles", "/setup/roles"],
|
||||
["/shift", "/shifts"],
|
||||
["/setup/subscriptions", "/subscriptions"],
|
||||
["/setup/plans", "/subscriptions/plans"],
|
||||
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
|
||||
["/setup/shifts", "/shift"],
|
||||
["/setup/shifts", "/shifts"],
|
||||
] as const
|
||||
).map(([from, to]) =>
|
||||
createRoute({
|
||||
@@ -454,7 +455,7 @@ const legacyRedirects = (
|
||||
|
||||
const shiftRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/shift",
|
||||
path: "/shifts",
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||
@@ -483,14 +484,14 @@ function requirePerm(perm: Permission) {
|
||||
// The Setup tabs in display order, each with the permission its screen needs. Used
|
||||
// to land a user on the FIRST tab they may see when they open /setup without
|
||||
// `site:update` (e.g. an operator who only has shift:read → goes to the standalone
|
||||
// /shift hub, which is no longer a Setup tab).
|
||||
// /shifts hub, which is no longer a Setup tab).
|
||||
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||||
{ to: "/setup", perm: "site:update" },
|
||||
{ to: "/setup/tariff", perm: "tariff:read" },
|
||||
{ to: "/setup/site", perm: "site:read" },
|
||||
{ to: "/setup/users", perm: "user:read" },
|
||||
{ to: "/setup/roles", perm: "role:read" },
|
||||
{ to: "/shift", perm: "shift:read" },
|
||||
{ to: "/shifts", perm: "shift:read" },
|
||||
{ to: "/setup/logs", perm: "log:read" },
|
||||
];
|
||||
|
||||
@@ -584,8 +585,8 @@ const rolesRoute = createRoute({
|
||||
return <RolesManager user={user} />;
|
||||
},
|
||||
});
|
||||
// (Shift history lives at the standalone /shift route — see shiftRoute. It was
|
||||
// removed as a Setup tab; /setup/shifts redirects there for old bookmarks.)
|
||||
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
|
||||
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
|
||||
|
||||
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
||||
const logsRoute = createRoute({
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ReactNode } from "react";
|
||||
import { type LedgerEvent } from "../api.js";
|
||||
import { formatMoney } from "../lib/format.js";
|
||||
import { renderReason } from "../lib/reason.js";
|
||||
import { Modal } from "./Modal.js";
|
||||
import { SnapshotStrip } from "./SnapshotStrip.js";
|
||||
|
||||
// Shared ledger-event presentation: the colour/label map, the clickable feed ROW, and
|
||||
// the read-only DETAIL modal (full signed payload + snapshots + chain provenance). Used
|
||||
// by the booth live feed AND the shift activity log so both render — and open — events
|
||||
// identically. See wiki/concepts/append-only-event-chain.md.
|
||||
|
||||
export 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" },
|
||||
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" },
|
||||
};
|
||||
|
||||
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
||||
function hhmmss(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
/** Red-flag classification badges computed from the signed payload. */
|
||||
export 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.
|
||||
// (`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. */
|
||||
export 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"). */
|
||||
export 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. */
|
||||
export function displayIdentity(e: LedgerEvent): string {
|
||||
return e.subscriberLabel ?? e.identity ?? "—";
|
||||
}
|
||||
|
||||
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
|
||||
* the time/label/identity/index columns aligned across rows; the detail line lives in
|
||||
* its own row, indented under the identity column. */
|
||||
export 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;
|
||||
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;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(e)}
|
||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||
isAnomaly ? "bg-term-red/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||
{e.plate && (
|
||||
<span
|
||||
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
||||
title={t("booth.plateTitle")}
|
||||
>
|
||||
{e.plate}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-term-muted">#{e.index}</span>
|
||||
{showDetail && (
|
||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{via && (
|
||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||
{t(via)}
|
||||
</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label/value line in the event-detail modal. */
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export 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 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 (
|
||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
||||
{(reason || money) && (
|
||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||
{reason ?? money}
|
||||
</div>
|
||||
)}
|
||||
{!reason && !money && isAnomaly && (
|
||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||
)}
|
||||
{badges.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{badges.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||
>
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||
<div>
|
||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
||||
{/* 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 && (
|
||||
<DetailRow label={t("booth.edOccurrence")}>
|
||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{money && (
|
||||
<DetailRow label={t("booth.edAmount")}>
|
||||
<span className="text-term-cyan">{money}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
||||
{viaKey(p) && (
|
||||
<DetailRow label={t("booth.edVia")}>
|
||||
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
{sessionRef && sessionRef !== e.identity && (
|
||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||
)}
|
||||
{tariffVersionId && (
|
||||
<DetailRow label={t("booth.edTariffVersion")}>
|
||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The entry/exit evidence images for this session's identity. */}
|
||||
{e.identity && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||
<SnapshotStrip identity={e.identity} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
{t("booth.edAuditData")}
|
||||
</summary>
|
||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||
<DetailRow label={t("booth.edSignature")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edKeyId")}>
|
||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label={t("booth.edPrevHash")}>
|
||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||
</DetailRow>
|
||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
{t("booth.edRawPayload")}
|
||||
</div>
|
||||
{p && Object.keys(p).length > 0 ? (
|
||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
||||
{JSON.stringify(p, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+12
@@ -1287,3 +1287,15 @@ relabelled to nav.shifts (plural: Turnet/Shifts). Removed the duplicate "Turnet"
|
||||
/setup/shifts route + tab rendered the SAME ShiftsHistory as the standalone /shift). /setup/shifts now
|
||||
redirects to /shift; the operator-landing fallback (shift:read user opening /setup) points at /shift.
|
||||
nav.shift key left in the catalogs (now orphaned, harmless). Verified at runtime. build+lint 14/14.
|
||||
|
||||
## [2026-06-21] feat | /shift→/shifts; clickable activity log (shared event-detail); booth-style full-height layout
|
||||
Three changes to the shift hub. (1) Renamed the route /shift→/shifts (matches the plural Turnet label
|
||||
+ the section); /shift and /setup/shifts both redirect there. (2) The activity-log rows are now
|
||||
CLICKABLE and open the SAME read-only event-detail modal the booth live feed uses (full signed payload
|
||||
+ snapshots + chain provenance). Extracted EVENT_STYLE + the row + the modal + their helpers from
|
||||
BoothScreen into a shared apps/web/src/ui/event-detail.tsx, imported by both — so the booth feed and the
|
||||
shift log render/behave identically and can't drift. (3) Reworked the /shifts layout to fill the
|
||||
viewport like /booth: a fixed title+filters, then a two-pane area (shift list | activity log) where each
|
||||
pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the whole page growing.
|
||||
Verified at runtime (Playwright): /shift redirects, rows open the detail modal, layout fills height,
|
||||
booth still works (0 console errors). build+lint 14/14.
|
||||
|
||||
Reference in New Issue
Block a user