diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index 5e5498b..572475c 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { desc, gte, ledgerEvents, type Db } from "@parking/db"; +import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db"; import type { LedgerEvent } from "@parking/shared"; import { requirePermission } from "../auth.js"; import { enrichEvents } from "../event-enrich.js"; @@ -19,19 +19,26 @@ export async function eventRoutes( const guard = requirePermission("event:read"); // Recent events, newest first. `limit` caps the page (default 100, max 1000). - // Optional `since` (ISO) scopes the page to events at/after that instant — the - // booth passes the current shift's start so the live feed shows ONLY this shift's - // activity (logs are per-shift, not all history). See wiki/concepts/shift.md. - app.get<{ Querystring: { limit?: string; since?: string } }>( + // Optional `since` (ISO) scopes to events at/after that instant — the booth passes + // the current shift's start so the live feed shows ONLY this shift's activity. An + // optional `until` (ISO) closes the upper bound — the shift-history screen passes a + // selected shift's [start, end] to show just that shift's signed activity log. + // (logs are per-shift, not all history). See wiki/concepts/shift.md. + app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>( "/api/events", { preHandler: guard }, async (req) => { const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const since = (req.query.since ?? "").trim(); + const until = (req.query.until ?? "").trim(); + const bounds = [ + since ? gte(ledgerEvents.occurredAt, since) : undefined, + until ? lte(ledgerEvents.occurredAt, until) : undefined, + ].filter(Boolean); const rows = db .select() .from(ledgerEvents) - .where(since ? gte(ledgerEvents.occurredAt, since) : undefined) + .where(bounds.length ? and(...bounds) : undefined) .orderBy(desc(ledgerEvents.index)) .limit(limit) .all(); diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 4daed49..b3722b3 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -1,28 +1,68 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; -import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js"; +import { fetchEvents, fetchShifts, type ShiftSummary, type SessionUser } from "./api.js"; import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js"; +import type { LedgerEvent } from "@parking/shared"; -// Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator -// gets only their own shifts; an admin (shift:cash) gets all + a date/operator -// filter. The screen mirrors that — it shows the filter only when the server -// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the -// drawer reconciliation. See wiki/concepts/shift.md. +// Shift history — a two-pane master/detail. LEFT: the operator's (or all, for an admin) +// completed shifts, filterable by a timeframe preset (yesterday / last week / last month / +// custom) and, for an admin, by operator. RIGHT: the SELECTED shift's signed activity log +// (every ledger event in its [start, end] window). Scope is enforced SERVER-SIDE: an +// operator sees only their own shifts; an admin (shift:cash) sees all. See shift.md. 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. */ +function presetRange(p: Preset): { from: string; to: string } | null { + if (p === "all" || p === "custom") return null; + const now = new Date(); + const iso = (d: Date) => d.toISOString().slice(0, 10); + if (p === "yesterday") { + const y = new Date(now); + y.setDate(y.getDate() - 1); + return { from: iso(y), to: iso(y) }; + } + const from = new Date(now); + from.setDate(from.getDate() - (p === "week" ? 7 : 30)); + return { from: iso(from), to: iso(now) }; +} + export function ShiftsHistory({ user }: { user: SessionUser | null }) { const { t } = useTranslation(); - // Admin filter inputs (only sent when the server grants the "all" scope; for an - // operator the server ignores them anyway). + const [preset, setPreset] = useState("week"); const [operator, setOperator] = useState(""); - const [from, setFrom] = useState(""); - const [to, setTo] = useState(""); - // The applied filter (separate from the inputs, so typing doesn't refetch). - const [applied, setApplied] = useState<{ operator?: string; from?: string; to?: string }>({}); + const [customFrom, setCustomFrom] = useState(""); + const [customTo, setCustomTo] = useState(""); + const [selected, setSelected] = useState(null); + + // Resolve the active date window from the preset (or the custom inputs). + const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset); + const applied = { + operator: operator.trim() || undefined, + from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined, + to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined, + }; const q = useQuery({ queryKey: ["shifts", applied], @@ -32,32 +72,56 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) { const isAdmin = q.data?.scope === "all"; const shifts = q.data?.shifts ?? []; - function apply() { - setApplied({ - operator: operator.trim() || undefined, - // A date input gives yyyy-mm-dd; widen `to` to the end of that day. - from: from ? new Date(`${from}T00:00:00`).toISOString() : undefined, - to: to ? new Date(`${to}T23:59:59`).toISOString() : undefined, - }); - } - function clear() { - setOperator(""); - setFrom(""); - setTo(""); - setApplied({}); - } + // Keep a selection valid as the list changes; default to the newest shift. + useEffect(() => { + if (shifts.length === 0) { + setSelected(null); + } else if (!selected || !shifts.some((s) => s.id === selected.id)) { + setSelected(shifts[0]!); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [q.data]); + + const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"]; return ( -
-
+
+

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

- {/* Admin-only filter: by operator + a date window over the shift start. */} - {isAdmin && ( -
+ {/* Filters: timeframe presets (everyone) + operator (admin only). */} +
+
+ {t("shifts.timeframe")} +
+ {PRESETS.map((p) => ( + + ))} +
+
+ {preset === "custom" && ( + <> +
+ {t("shifts.filterFrom")} + setCustomFrom(e.target.value)} /> +
+
+ {t("shifts.filterTo")} + setCustomTo(e.target.value)} /> +
+ + )} + {isAdmin && (
{t("shifts.operator")}
-
- {t("shifts.filterFrom")} - setFrom(e.target.value)} /> -
-
- {t("shifts.filterTo")} - setTo(e.target.value)} /> -
- - -
- )} + )} +
{q.isError && (
@@ -90,82 +140,152 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
)} -
- - - - {isAdmin && } - - - - - - - - - - {shifts.map((s) => ( - - ))} - {!q.isLoading && shifts.length === 0 && ( - - - - )} - -
{t("shifts.operator")}{t("shifts.started")}{t("shifts.ended")}{t("shifts.payments")}{t("shifts.cash")}{t("shifts.card")}{t("shifts.expectedDrawer")}
- {t("shifts.none")} -
+ {/* Two-pane: shift list (left) + selected shift's activity log (right). */} +
+ {/* LEFT — shift list */} +
+ {!q.isLoading && shifts.length === 0 && ( +

{t("shifts.none")}

+ )} + {shifts.map((s) => ( + setSelected(s)} + /> + ))} +
+ + {/* RIGHT — activity log for the selected shift */} +
+ {selected ? ( + + ) : ( +

{t("shifts.selectAShift")}

+ )} +
); } -function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator: boolean; colSpan: number }) { +function ShiftCard({ + s, + showOperator, + selected, + onClick, +}: { + s: ShiftSummary; + showOperator: boolean; + selected: boolean; + onClick: () => void; +}) { const { t } = useTranslation(); - const [open, setOpen] = useState(false); const cur = s.currency; const when = (iso: string) => formatRelativeDateTime(iso, t); - return ( - <> - setOpen((o) => !o)} - > - {showOperator && {s.operator}} - {when(s.startedAt)} - - {when(s.endedAt)} - {formatDuration(s.startedAt, s.endedAt)} - - {s.paymentCount} - {money(s.cashTotalMinor, cur)} - {money(s.cardTotalMinor, cur)} - {money(s.expectedDrawerMinor, cur)} - - {open && ( - - -
{t("shifts.drawerSection")}
-
-
-
-
-
-
- - - )} - + ); } -function Figure({ label, value }: { label: string; value: string }) { +function ShiftActivityLog({ shift, showOperator }: { shift: ShiftSummary; showOperator: boolean }) { + const { t } = useTranslation(); + // Every signed event in the shift's [start, end] window — the full audit trail. + const q = useQuery({ + queryKey: ["shift-events", shift.id], + queryFn: () => fetchEvents(1000, shift.startedAt, shift.endedAt), + }); + const events = q.data?.events ?? []; + const cur = shift.currency; + return ( -
- {label} - {value} +
+ {/* Header — the shift's drawer reconciliation. */} +
+
+ + {showOperator && `${shift.operator} · `} + {formatRelativeDateTime(shift.startedAt, t)} → {formatRelativeDateTime(shift.endedAt, t)} + + {formatDuration(shift.startedAt, shift.endedAt)} +
+
+
+
+
+
+
+
+
+
+ + {/* Activity log */} +
+ {q.isLoading &&

{t("common.loading")}

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

{t("shifts.noActivity")}

+ )} + {events.map((e) => ( + + ))} +
+
+ ); +} + +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; + // A short actor/context: the subscriber holder, the identity, or the session ref. + 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 }: { label: string; value: string; bold?: boolean }) { + return ( +
+ {label} + {value}
); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 76a8cf6..ca68f40 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -862,9 +862,11 @@ export type { AppLogRecord }; export function fetchEvents( limit = 100, since?: string, + until?: string, ): Promise<{ events: import("@parking/shared").LedgerEvent[] }> { const qs = new URLSearchParams({ limit: String(limit) }); if (since) qs.set("since", since); + if (until) qs.set("until", until); return apiFetch(`/api/events?${qs.toString()}`); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 148141d..6c741f4 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -632,6 +632,14 @@ export const en: Catalog = { allOperators: "All operators", apply: "Apply", clear: "Clear", + timeframe: "Timeframe", + preset_yesterday: "Yesterday", + preset_week: "Last week", + preset_month: "Last month", + preset_all: "All", + preset_custom: "Custom", + selectAShift: "Select a shift to see its activity log.", + noActivity: "No activity in this shift.", drawerSection: "Drawer", openingFloat: "Opening float", cashTaken: "Cash taken", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 4239198..33e2539 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -645,6 +645,14 @@ export const sq = { allOperators: "Të gjithë operatorët", apply: "Apliko", clear: "Pastro", + timeframe: "Periudha", + preset_yesterday: "Dje", + preset_week: "Javën e fundit", + preset_month: "Muajin e fundit", + preset_all: "Të gjitha", + preset_custom: "E zgjedhur", + selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.", + noActivity: "Asnjë aktivitet në këtë turn.", // Expanded drawer detail. drawerSection: "Arka", openingFloat: "Bilanci fillestar", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 7474ffc..8346969 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -344,9 +344,19 @@ const shiftRoute = createRoute({ path: "/shift", component: function ShiftRoute() { const { user } = rootRoute.useRouteContext(); - // The drawer-voucher form is operator-RAISED (shift:create); an admin still has - // to authorize each voucher with their password server-side. - return ; + // Top: the shift CONTROL (open/close, drawer vouchers, X-report). The drawer-voucher + // form is operator-RAISED (shift:create); an admin authorizes with their password. + // Below: the shift LIST + per-shift activity log (scoped server-side by permission). + return ( +
+ + {can(user, "shift:read") && ( +
+ +
+ )} +
+ ); }, });