import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { closeShift, fetchEvents, fetchShift, fetchShiftReport, fetchShifts, openShift, type ShiftReport, type ShiftSummary, type SessionUser, } from "./api.js"; import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.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 // completed shifts, filterable by a timeframe preset and (admin) by operator. RIGHT: the // selected shift's signed activity log (every ledger event in its window). The current // shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far), // each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own; // an admin (shift:cash) sees all. See wiki/concepts/shift.md. function money(minor: number, currency: string | null): string { return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2); } 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) }; } /** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed * shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when * no shift is open (or not visible to the requester). */ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } { const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift }); const report = useQuery({ queryKey: ["shift", "xreport"], queryFn: fetchShiftReport, enabled: status.data?.open != null, }); const refetch = () => { void status.refetch(); void report.refetch(); }; if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch }; const x = report.data; return { isMine: status.data.isMine, refetch, current: { id: "__current__", index: Number.MAX_SAFE_INTEGER, operator: x.operator, startedAt: x.startedAt, endedAt: x.asOf, cashTotalMinor: x.cashTotalMinor, cardTotalMinor: x.cardTotalMinor, currency: x.currency, paymentCount: x.paymentCount, ticketTotalMinor: x.ticketTotalMinor, subscriptionTotalMinor: x.subscriptionTotalMinor, subscriptionSalesMinor: x.subscriptionSalesMinor, subscriptionWindowMinor: x.subscriptionWindowMinor, openingFloatMinor: x.openingFloatMinor, cashAddedMinor: x.cashAddedMinor, cashRemovedMinor: x.cashRemovedMinor, expectedDrawerMinor: x.expectedDrawerMinor, open: true, }, }; } export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) { const { t } = useTranslation(); const [preset, setPreset] = useState("week"); const [operator, setOperator] = useState(""); const [customFrom, setCustomFrom] = useState(""); const [customTo, setCustomTo] = useState(""); const [selectedId, setSelectedId] = useState(null); const { current, isMine, refetch: refetchCurrent } = useCurrentShift(); 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], queryFn: () => fetchShifts(applied) }); const isAdmin = q.data?.scope === "all"; const closed = q.data?.shifts ?? []; // The current/open shift sits at the TOP of the list (when present + visible to me). const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed; const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null; // Default the selection to the current shift (if any), else the newest closed one. useEffect(() => { if (list.length === 0) setSelectedId(null); else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [q.data, current?.id]); function refreshAll() { void q.refetch(); refetchCurrent(); } 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 (

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

{/* No shift open → the only action is to start one (gated on shift:create). */} {canManage && !current && ( )}
{/* 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")} setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
)}
{q.isError && (
{t("shifts.loadFailed")}
)} {/* Two-pane: shift list (left) + selected shift's activity log (right). Both panes scroll independently and fill the remaining height (like the booth). */}
{!q.isLoading && list.length === 0 && (

{t("shifts.none")}

)} {list.map((s) => ( setSelectedId(s.id)} /> ))}
{selected ? ( ) : (

{t("shifts.selectAShift")}

)}
); } function StartShiftButton({ onDone }: { onDone: () => void }) { const { t } = useTranslation(); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); async function start() { setBusy(true); setErr(null); try { await openShift(); onDone(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } return ( {err && {err}} ); } function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) { const { t } = useTranslation(); const cur = s.currency; const when = (iso: string) => formatRelativeDateTime(iso, t); return ( ); } function ShiftActivityLog({ shift, isCurrent, isMine, showOperator, canManage, onChanged, }: { shift: ShiftSummary; isCurrent: boolean; isMine: boolean; showOperator: boolean; canManage: boolean; onChanged: () => void; }) { const { t } = useTranslation(); const [modal, setModal] = useState(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(null); // The current shift's log runs entry→now (no upper bound); a closed shift is bounded. const q = useQuery({ queryKey: ["shift-events", shift.id, shift.endedAt], queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt), refetchInterval: isCurrent ? 5000 : false, }); const events = q.data?.events ?? []; const cur = shift.currency; // Fill the pane: a fixed header + a scrollable activity list (matches the booth feed). return (
{isCurrent && {t("shifts.current")}} {showOperator && `${shift.operator} · `} {formatRelativeDateTime(shift.startedAt, t)} {!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`} {/* Actions live on the CURRENT shift's pane (when it's mine), each → a modal. */} {isCurrent && isMine && canManage && ( )}
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
{CARD_PAYMENTS_ENABLED &&
}
{q.isLoading &&

{t("common.loading")}

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

{t("shifts.noActivity")}

} {events.map((e) => ( ))}
{detailEvent && setDetailEvent(null)} />} {modal === "end" && setModal(null)} onDone={onChanged} />} {modal === "takings" && setModal(null)} />}
); } // --- Action modals --------------------------------------------------------- function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClose: () => void; onDone: () => void }) { const { t } = useTranslation(); const [busy, setBusy] = useState(false); const [report, setReport] = useState(null); const [err, setErr] = useState(null); const cur = shift.currency; async function confirm() { setBusy(true); setErr(null); try { setReport(await closeShift()); onDone(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } return ( {report ? ( // Result — the signed Z-report.
{t("shift.zReport")} — {report.operator}
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
{CARD_PAYMENTS_ENABLED &&
}
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
) : ( // Confirm — show the live takings (split by source) + drawer before closing.

{t("shift.endConfirm")}

{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
{CARD_PAYMENTS_ENABLED &&
} {/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
{err &&

{err}

}
)}
); } function TakingsModal({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport }); const x = q.data; return ( {!x ? (

{t("common.loading")}

) : (
{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
{CARD_PAYMENTS_ENABLED &&
}
{t("shift.xReportHint")}
)}
); } function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { return (
{label} {/* The money/number never splits across lines (e.g. "89,650 ALL"). */} {value}
); }