feat(shift): two-pane shift history — list + per-shift activity log, timeframe presets
Rework the shift screen into a master/detail view on /shift: the shift CONTROL (open/close, drawer vouchers, X-report) on top, then a two-pane history below — shift list on the LEFT, the selected shift's signed activity log on the RIGHT. - Timeframe presets replace the bare from/to inputs: Yesterday / Last week / Last month / All / Custom (custom reveals the date pickers). Filters the shift list by start time. - Activity log = every ledger event in the selected shift's [start, end] window (entries, exits, payments, vouchers, anomalies, the Z-report), rendered like the booth live feed (same EVENT_STYLE), with the shift's drawer reconciliation in the pane header. - Scope unchanged + enforced SERVER-SIDE: an operator sees only their own shifts (no operator filter); an admin (shift:cash) sees all + the operator filter. The list auto-selects the newest shift. API: /api/events gains an optional `until` (ISO) upper bound so a shift's window can be fetched ([start,end]); fetchEvents passes it. Verified on live data: a closed shift window returns just its 20 events out of 260. Build+lint 12/12 (i18n parity). The same component also backs /setup/shifts. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
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 type { LedgerEvent } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { enrichEvents } from "../event-enrich.js";
|
import { enrichEvents } from "../event-enrich.js";
|
||||||
@@ -19,19 +19,26 @@ export async function eventRoutes(
|
|||||||
const guard = requirePermission("event:read");
|
const guard = requirePermission("event:read");
|
||||||
|
|
||||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
// 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
|
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
|
||||||
// booth passes the current shift's start so the live feed shows ONLY this shift's
|
// the current shift's start so the live feed shows ONLY this shift's activity. An
|
||||||
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
|
||||||
app.get<{ Querystring: { limit?: string; since?: string } }>(
|
// 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",
|
"/api/events",
|
||||||
{ preHandler: guard },
|
{ preHandler: guard },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||||
const since = (req.query.since ?? "").trim();
|
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
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
|
.where(bounds.length ? and(...bounds) : undefined)
|
||||||
.orderBy(desc(ledgerEvents.index))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all();
|
.all();
|
||||||
|
|||||||
+231
-111
@@ -1,28 +1,68 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { 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
|
// Shift history — a two-pane master/detail. LEFT: the operator's (or all, for an admin)
|
||||||
// gets only their own shifts; an admin (shift:cash) gets all + a date/operator
|
// completed shifts, filterable by a timeframe preset (yesterday / last week / last month /
|
||||||
// filter. The screen mirrors that — it shows the filter only when the server
|
// custom) and, for an admin, by operator. RIGHT: the SELECTED shift's signed activity log
|
||||||
// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the
|
// (every ledger event in its [start, end] window). Scope is enforced SERVER-SIDE: an
|
||||||
// drawer reconciliation. See wiki/concepts/shift.md.
|
// operator sees only their own shifts; an admin (shift:cash) sees all. See shift.md.
|
||||||
|
|
||||||
function money(minor: number, currency: string | null): string {
|
function money(minor: number, currency: string | null): string {
|
||||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
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. */
|
||||||
|
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 }) {
|
export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Admin filter inputs (only sent when the server grants the "all" scope; for an
|
const [preset, setPreset] = useState<Preset>("week");
|
||||||
// operator the server ignores them anyway).
|
|
||||||
const [operator, setOperator] = useState("");
|
const [operator, setOperator] = useState("");
|
||||||
const [from, setFrom] = useState("");
|
const [customFrom, setCustomFrom] = useState("");
|
||||||
const [to, setTo] = useState("");
|
const [customTo, setCustomTo] = useState("");
|
||||||
// The applied filter (separate from the inputs, so typing doesn't refetch).
|
const [selected, setSelected] = useState<ShiftSummary | null>(null);
|
||||||
const [applied, setApplied] = useState<{ operator?: string; from?: string; to?: string }>({});
|
|
||||||
|
// 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({
|
const q = useQuery({
|
||||||
queryKey: ["shifts", applied],
|
queryKey: ["shifts", applied],
|
||||||
@@ -32,32 +72,56 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|||||||
const isAdmin = q.data?.scope === "all";
|
const isAdmin = q.data?.scope === "all";
|
||||||
const shifts = q.data?.shifts ?? [];
|
const shifts = q.data?.shifts ?? [];
|
||||||
|
|
||||||
function apply() {
|
// Keep a selection valid as the list changes; default to the newest shift.
|
||||||
setApplied({
|
useEffect(() => {
|
||||||
operator: operator.trim() || undefined,
|
if (shifts.length === 0) {
|
||||||
// A date input gives yyyy-mm-dd; widen `to` to the end of that day.
|
setSelected(null);
|
||||||
from: from ? new Date(`${from}T00:00:00`).toISOString() : undefined,
|
} else if (!selected || !shifts.some((s) => s.id === selected.id)) {
|
||||||
to: to ? new Date(`${to}T23:59:59`).toISOString() : undefined,
|
setSelected(shifts[0]!);
|
||||||
});
|
}
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
function clear() {
|
}, [q.data]);
|
||||||
setOperator("");
|
|
||||||
setFrom("");
|
const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"];
|
||||||
setTo("");
|
|
||||||
setApplied({});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin-only filter: by operator + a date window over the shift start. */}
|
{/* Filters: timeframe presets (everyone) + operator (admin only). */}
|
||||||
{isAdmin && (
|
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
|
||||||
<div className="card mb-3 flex 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">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${preset === p ? "btn-primary" : ""}`}
|
||||||
|
onClick={() => setPreset(p)}
|
||||||
|
>
|
||||||
|
{t(`shifts.preset_${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{preset === "custom" && (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("shifts.filterFrom")}</span>
|
||||||
|
<input type="date" className="input w-40" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("shifts.filterTo")}</span>
|
||||||
|
<input type="date" className="input w-40" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.operator")}</span>
|
<span className="label">{t("shifts.operator")}</span>
|
||||||
<input
|
<input
|
||||||
@@ -67,22 +131,8 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|||||||
placeholder={t("shifts.allOperators")}
|
placeholder={t("shifts.allOperators")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
)}
|
||||||
<span className="label">{t("shifts.filterFrom")}</span>
|
</div>
|
||||||
<input type="date" className="input w-44" value={from} onChange={(e) => setFrom(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<span className="label">{t("shifts.filterTo")}</span>
|
|
||||||
<input type="date" className="input w-44" value={to} onChange={(e) => setTo(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={apply}>
|
|
||||||
{t("shifts.apply")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-sm" onClick={clear}>
|
|
||||||
{t("shifts.clear")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{q.isError && (
|
{q.isError && (
|
||||||
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">
|
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">
|
||||||
@@ -90,82 +140,152 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-term border border-term-border">
|
{/* Two-pane: shift list (left) + selected shift's activity log (right). */}
|
||||||
<table className="w-full text-[12px] tabular-nums">
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
||||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
{/* LEFT — shift list */}
|
||||||
<tr>
|
<div className="flex flex-col gap-1.5">
|
||||||
{isAdmin && <th className="px-3 py-1.5 text-left">{t("shifts.operator")}</th>}
|
{!q.isLoading && shifts.length === 0 && (
|
||||||
<th className="px-3 py-1.5 text-left">{t("shifts.started")}</th>
|
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
||||||
<th className="px-3 py-1.5 text-left">{t("shifts.ended")}</th>
|
)}
|
||||||
<th className="px-3 py-1.5 text-right">{t("shifts.payments")}</th>
|
{shifts.map((s) => (
|
||||||
<th className="px-3 py-1.5 text-right">{t("shifts.cash")}</th>
|
<ShiftCard
|
||||||
<th className="px-3 py-1.5 text-right">{t("shifts.card")}</th>
|
key={s.id}
|
||||||
<th className="px-3 py-1.5 text-right">{t("shifts.expectedDrawer")}</th>
|
s={s}
|
||||||
</tr>
|
showOperator={isAdmin}
|
||||||
</thead>
|
selected={selected?.id === s.id}
|
||||||
<tbody>
|
onClick={() => setSelected(s)}
|
||||||
{shifts.map((s) => (
|
/>
|
||||||
<ShiftRow key={s.id} s={s} showOperator={isAdmin} colSpan={isAdmin ? 7 : 6} />
|
))}
|
||||||
))}
|
</div>
|
||||||
{!q.isLoading && shifts.length === 0 && (
|
|
||||||
<tr>
|
{/* RIGHT — activity log for the selected shift */}
|
||||||
<td colSpan={isAdmin ? 7 : 6} className="px-3 py-3 text-term-muted">
|
<div className="rounded-term border border-term-border">
|
||||||
{t("shifts.none")}
|
{selected ? (
|
||||||
</td>
|
<ShiftActivityLog shift={selected} showOperator={isAdmin} />
|
||||||
</tr>
|
) : (
|
||||||
)}
|
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||||
</tbody>
|
)}
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const cur = s.currency;
|
const cur = s.currency;
|
||||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<button
|
||||||
<tr
|
type="button"
|
||||||
className="cursor-pointer border-t border-term-border hover:bg-term-panel-2"
|
onClick={onClick}
|
||||||
onClick={() => setOpen((o) => !o)}
|
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${
|
||||||
>
|
selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"
|
||||||
{showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>}
|
}`}
|
||||||
<td className="px-3 py-1.5">{when(s.startedAt)}</td>
|
>
|
||||||
<td className="px-3 py-1.5">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{when(s.endedAt)}
|
<span className="font-semibold text-term-text">
|
||||||
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
{showOperator ? s.operator : when(s.startedAt)}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
|
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||||
<td className="px-3 py-1.5 text-right text-term-green">{money(s.cashTotalMinor, cur)}</td>
|
</div>
|
||||||
<td className="px-3 py-1.5 text-right text-term-cyan">{money(s.cardTotalMinor, cur)}</td>
|
{showOperator && <div className="text-term-muted">{when(s.startedAt)}</div>}
|
||||||
<td className="px-3 py-1.5 text-right font-semibold">{money(s.expectedDrawerMinor, cur)}</td>
|
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
||||||
</tr>
|
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
||||||
{open && (
|
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
||||||
<tr className="border-t border-term-border/50 bg-term-bg">
|
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
||||||
<td colSpan={colSpan} className="px-3 py-2">
|
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>
|
||||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("shifts.drawerSection")}</div>
|
{money(s.expectedDrawerMinor, cur)}
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-8 gap-y-0.5 sm:grid-cols-4">
|
</span>
|
||||||
<Figure label={t("shifts.openingFloat")} value={money(s.openingFloatMinor, cur)} />
|
</div>
|
||||||
<Figure label={t("shifts.cashTaken")} value={money(s.cashTotalMinor, cur)} />
|
</button>
|
||||||
<Figure label={t("shifts.cashAdded")} value={money(s.cashAddedMinor, cur)} />
|
|
||||||
<Figure label={t("shifts.cashRemoved")} value={money(s.cashRemovedMinor, cur)} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex justify-between gap-2">
|
<div>
|
||||||
<span className="text-term-muted">{label}</span>
|
{/* Header — the shift's drawer reconciliation. */}
|
||||||
<span className="text-term-text">{value}</span>
|
<div className="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="font-semibold text-term-text">
|
||||||
|
{showOperator && `${shift.operator} · `}
|
||||||
|
{formatRelativeDateTime(shift.startedAt, t)} → {formatRelativeDateTime(shift.endedAt, t)}
|
||||||
|
</span>
|
||||||
|
<span className="text-term-muted">{formatDuration(shift.startedAt, shift.endedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
||||||
|
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
|
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||||
|
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
||||||
|
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||||
|
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity log */}
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
|
{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} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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 }: { label: string; value: string; bold?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="text-term-muted">{label}</span>
|
||||||
|
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,9 +862,11 @@ export type { AppLogRecord };
|
|||||||
export function fetchEvents(
|
export function fetchEvents(
|
||||||
limit = 100,
|
limit = 100,
|
||||||
since?: string,
|
since?: string,
|
||||||
|
until?: string,
|
||||||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||||
const qs = new URLSearchParams({ limit: String(limit) });
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
if (since) qs.set("since", since);
|
if (since) qs.set("since", since);
|
||||||
|
if (until) qs.set("until", until);
|
||||||
return apiFetch(`/api/events?${qs.toString()}`);
|
return apiFetch(`/api/events?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -632,6 +632,14 @@ export const en: Catalog = {
|
|||||||
allOperators: "All operators",
|
allOperators: "All operators",
|
||||||
apply: "Apply",
|
apply: "Apply",
|
||||||
clear: "Clear",
|
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",
|
drawerSection: "Drawer",
|
||||||
openingFloat: "Opening float",
|
openingFloat: "Opening float",
|
||||||
cashTaken: "Cash taken",
|
cashTaken: "Cash taken",
|
||||||
|
|||||||
@@ -645,6 +645,14 @@ export const sq = {
|
|||||||
allOperators: "Të gjithë operatorët",
|
allOperators: "Të gjithë operatorët",
|
||||||
apply: "Apliko",
|
apply: "Apliko",
|
||||||
clear: "Pastro",
|
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.
|
// Expanded drawer detail.
|
||||||
drawerSection: "Arka",
|
drawerSection: "Arka",
|
||||||
openingFloat: "Bilanci fillestar",
|
openingFloat: "Bilanci fillestar",
|
||||||
|
|||||||
+13
-3
@@ -344,9 +344,19 @@ const shiftRoute = createRoute({
|
|||||||
path: "/shift",
|
path: "/shift",
|
||||||
component: function ShiftRoute() {
|
component: function ShiftRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
// The drawer-voucher form is operator-RAISED (shift:create); an admin still has
|
// Top: the shift CONTROL (open/close, drawer vouchers, X-report). The drawer-voucher
|
||||||
// to authorize each voucher with their password server-side.
|
// form is operator-RAISED (shift:create); an admin authorizes with their password.
|
||||||
return <ShiftControl canVoucher={can(user, "shift:create")} />;
|
// Below: the shift LIST + per-shift activity log (scoped server-side by permission).
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-4">
|
||||||
|
<ShiftControl canVoucher={can(user, "shift:create")} />
|
||||||
|
{can(user, "shift:read") && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ShiftsHistory user={user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user