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:
@@ -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" : ""}`}>
|
||||
|
||||
Reference in New Issue
Block a user