import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { closeShift, fetchEvents, fetchShiftReport, fetchShiftTills, fetchShifts, openShift, type ShiftReport, type ShiftSummary, type SessionUser, type TillId, } from "./api.js"; import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { Modal } from "./ui/Modal.js"; import { Spinner } from "./ui/Spinner.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. TILLS: a shift belongs to a till (booth / wash desk); // every open shift (one per till) lists on top, cards carry a till badge when the site // has more than one, and the list can be filtered by till. 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) }; } type CurrentShift = ShiftSummary & { open: true; isMine: boolean }; /** The CURRENT (open) shifts — one per till at most — each synthesized from its till's * X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open` * marks it for the badge + the action pane. Also returns every till the site has, so * the hub can offer "start shift" per till and show badges only when there are two. */ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workable: TillId[]; refetch: () => void } { const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills }); const openTills = (status.data?.tills ?? []).filter((t) => t.open != null); // One X-report per open till (the key carries the till list so a newly opened // shift refetches). const reports = useQuery({ queryKey: ["shift", "xreport", "hub", openTills.map((t) => t.till).join(",")], queryFn: async () => Promise.all(openTills.map((t) => fetchShiftReport(t.till))), enabled: openTills.length > 0, }); const refetch = () => { void status.refetch(); void reports.refetch(); }; const tills = status.data?.tills.map((t) => t.till) ?? []; const workable = status.data?.tills.filter((t) => t.canWork).map((t) => t.till) ?? []; const current: CurrentShift[] = []; openTills.forEach((t, i) => { const x = reports.data?.[i]; if (!x) return; current.push({ id: `__current__${t.till}`, index: Number.MAX_SAFE_INTEGER, till: x.till, 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, isMine: t.isMine, }); }); return { current, tills, workable, refetch }; } 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 [tillFilter, setTillFilter] = useState(""); const { current, tills, workable, refetch: refetchCurrent } = useCurrentShifts(); const multiTill = tills.length > 1; 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, till: tillFilter || undefined, }; // keepPreviousData: every filter change makes a NEW query key; without it the // data (and with it `scope`) goes undefined for the fetch round-trip, which // unmounted the admin filter controls mid-interaction and blanked the list. const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied), placeholderData: keepPreviousData, }); const isAdmin = q.data?.scope === "all"; const closed = q.data?.shifts ?? []; const operators = q.data?.operators ?? []; // The current/open shifts sit at the TOP of the list (those visible to me: mine, or // all for an admin), honouring the till filter. const visibleCurrent = current.filter((c) => (c.isMine || isAdmin) && (!tillFilter || c.till === tillFilter)); const list: (ShiftSummary & { open?: boolean; isMine?: boolean })[] = [...visibleCurrent, ...closed]; const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null; const currentIds = visibleCurrent.map((c) => c.id).join(","); // 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, currentIds]); // Tills this role may WORK with no open shift → offer "start" for each. const openOn = new Set(current.map((c) => c.till)); const startable = workable.filter((x) => !openOn.has(x)); 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")}

{/* A till with no open shift → the action is to start one (gated on shift:create). */} {canManage && startable.length > 0 && ( {startable.map((x) => ( ))} )}
{/* 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)} />
)} {multiTill && (
)} {isAdmin && (
{/* {t("shifts.operator")} */} {/* A select over operators that HAVE shifts — the server filter is an exact username match, so free text could only miss. */}
)}
{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({ till, named, onDone }: { till: TillId; named: boolean; 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(till); onDone(); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } } return ( {err && {err}} ); } /** Which drawer a shift reconciled — shown only when the site has more than one. */ function TillBadge({ till }: { till: TillId }) { const { t } = useTranslation(); return {t(`till.${till}`)}; } function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: 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, showTill, canManage, onChanged, }: { shift: ShiftSummary; isCurrent: boolean; isMine: boolean; showOperator: boolean; showTill: 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")}} {showTill && } {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(shift.till)); 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({ till, onClose }: { till: TillId; onClose: () => void }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) }); const x = q.data; return ( {!x ? (

{t("common.loading")}

) : (
{t("shift.asOf")} {formatDateTime(x.asOf, t)}
{/* 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}
); }