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:
2026-06-21 14:58:50 +02:00
parent 2a9e6846a1
commit 35c10a7310
6 changed files with 327 additions and 336 deletions
+22 -45
View File
@@ -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" : ""}`}>