From ae736a9e3e255f45f384e2bb48707c7e686e5f90 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 23:44:27 +0200 Subject: [PATCH] feat(shift): current shift in the list + modal actions; full-width layout everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift screen: - The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now appears at the TOP of the shift list (CURRENT badge, live figures synthesized from the X-report), unified with history. Selecting it shows its live activity log. - Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL: End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out), takings-so-far (X-report). When no shift is open, a Start-shift button shows. - The current shift's log auto-refreshes (5s); a closed shift is bounded by its window. /setup/shifts stays read-only history (no manage props). Deleted the now- orphaned ShiftControl.tsx. Layout: - Every screen is now full-width like /booth — stripped the per-screen `mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup layout, Shifts). The shell
already provides padding. Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT list entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/web/src/LogsViewer.tsx | 2 +- apps/web/src/RolesManager.tsx | 2 +- apps/web/src/SetupWizard.tsx | 2 +- apps/web/src/ShiftControl.tsx | 252 -------------- apps/web/src/ShiftsHistory.tsx | 406 +++++++++++++++++----- apps/web/src/SubscriptionManager.tsx | 2 +- apps/web/src/SubscriptionPlansManager.tsx | 2 +- apps/web/src/TariffComposer.tsx | 2 +- apps/web/src/TariffLab.tsx | 2 +- apps/web/src/UsersManager.tsx | 2 +- apps/web/src/lib/i18n/en.ts | 2 + apps/web/src/lib/i18n/sq.ts | 2 + apps/web/src/router.tsx | 23 +- 13 files changed, 337 insertions(+), 364 deletions(-) delete mode 100644 apps/web/src/ShiftControl.tsx diff --git a/apps/web/src/LogsViewer.tsx b/apps/web/src/LogsViewer.tsx index 7b7a519..4fe903d 100644 --- a/apps/web/src/LogsViewer.tsx +++ b/apps/web/src/LogsViewer.tsx @@ -94,7 +94,7 @@ export function LogsViewer() { } return ( -
+

{t("logs.title")}

- - - ) : ( - <> - {t("shift.notStarted")} - - - )} -
- {/* Live drawer balance (what's in the till right now / inherited). */} - {drawerMinor != null && ( -
- {t("shift.drawer")} {money(drawerMinor, currency)} - {startedAt && {t("shift.openingFloatInherited")}} -
- )} - - {err &&

{err}

} - - {/* Drawer cash voucher: operator RAISES, an admin AUTHORIZES (name + password). - cash_in = Mandat Arkëtimi (pay-IN), cash_out = Mandat Pagese (pay-OUT). */} - {canVoucher && ( -
-
- {t("shift.drawerVoucher")} -
-
- setMoveAmount(e.target.value)} - placeholder={t("shift.amount")} - inputMode="decimal" - /> - setMoveReason(e.target.value)} - placeholder={t("shift.reasonPlaceholder")} - /> -
- {/* Admin sign-off — the float can only move with an admin's authorization. */} -
- setAuthName(e.target.value)} - placeholder={t("shift.authName")} - autoComplete="off" - /> - setAuthPassword(e.target.value)} - placeholder={t("shift.authPassword")} - autoComplete="off" - /> - - -
-
{t("shift.voucherHint")}
- {moveMsg &&
{moveMsg}
} -
- )} - - {/* Mid-shift X-report — read-only "takings so far" (no event appended). */} - {xReport && ( -
-
{t("shift.xReport")} — {xReport.operator}
-
- {t("shift.asOf")} {new Date(xReport.asOf).toLocaleString()} -
-
{t("shift.payments")} {xReport.paymentCount}
-
{t("shift.cash")} {money(xReport.cashTotalMinor, xReport.currency)}
-
{t("shift.card")} {money(xReport.cardTotalMinor, xReport.currency)}
-
{t("shift.drawerSection")}
-
{t("shift.openingFloat")} {money(xReport.openingFloatMinor, xReport.currency)}
-
{t("shift.cashTaken")} {money(xReport.cashTotalMinor, xReport.currency)}
-
{t("shift.cashAdded")} {money(xReport.cashAddedMinor, xReport.currency)}
-
{t("shift.cashRemoved")} {money(xReport.cashRemovedMinor, xReport.currency)}
-
- {t("shift.expectedDrawer")} {money(xReport.expectedDrawerMinor, xReport.currency)} -
-
{t("shift.xReportHint")}
-
- )} - - {report && ( -
-
{t("shift.zReport")} — {report.operator}
-
{t("shift.payments")} {report.paymentCount}
-
{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}
-
{t("shift.card")} {money(report.cardTotalMinor, report.currency)}
-
{t("shift.drawerSection")}
-
{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}
-
{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}
-
{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}
-
{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}
-
- {t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)} -
-
- {report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")} -
-
- )} - - ); -} diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index b3722b3..f761862 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -1,15 +1,28 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; -import { fetchEvents, fetchShifts, type ShiftSummary, type SessionUser } from "./api.js"; +import { + closeShift, + fetchEvents, + fetchShift, + fetchShiftReport, + fetchShifts, + openShift, + recordCashVoucher, + type ShiftReport, + type ShiftSummary, + type SessionUser, +} from "./api.js"; import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js"; +import { Modal } from "./ui/Modal.js"; import type { LedgerEvent } from "@parking/shared"; -// 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. +// 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); @@ -48,15 +61,54 @@ function presetRange(p: Preset): { from: string; to: string } | null { return { from: iso(from), to: iso(now) }; } -export function ShiftsHistory({ user }: { user: SessionUser | null }) { +/** 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, + openingFloatMinor: x.openingFloatMinor, + cashAddedMinor: x.cashAddedMinor, + cashRemovedMinor: x.cashRemovedMinor, + expectedDrawerMinor: x.expectedDrawerMinor, + open: true, + }, + }; +} + +export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) { const { t } = useTranslation(); const [preset, setPreset] = useState("week"); const [operator, setOperator] = useState(""); const [customFrom, setCustomFrom] = useState(""); const [customTo, setCustomTo] = useState(""); - const [selected, setSelected] = useState(null); + const [selectedId, setSelectedId] = useState(null); + + const { current, isMine, refetch: refetchCurrent } = useCurrentShift(); - // 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, @@ -64,32 +116,38 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) { to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined, }; - const q = useQuery({ - queryKey: ["shifts", applied], - queryFn: () => fetchShifts(applied), - }); - + const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) }); const isAdmin = q.data?.scope === "all"; - const shifts = q.data?.shifts ?? []; + const closed = q.data?.shifts ?? []; - // Keep a selection valid as the list changes; default to the newest shift. + // 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 (shifts.length === 0) { - setSelected(null); - } else if (!selected || !shifts.some((s) => s.id === selected.id)) { - setSelected(shifts[0]!); - } + 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]); + }, [q.data, current?.id]); + + function refreshAll() { + void q.refetch(); + refetchCurrent(); + } const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"]; 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). */} @@ -98,12 +156,7 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) { {t("shifts.timeframe")}
{PRESETS.map((p) => ( - ))} @@ -124,44 +177,37 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) { {isAdmin && (
{t("shifts.operator")} - setOperator(e.target.value)} - placeholder={t("shifts.allOperators")} - /> + setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
)}
{q.isError && ( -
- {t("shifts.loadFailed")} -
+
{t("shifts.loadFailed")}
)} {/* Two-pane: shift list (left) + selected shift's activity log (right). */} -
- {/* LEFT — shift list */} +
- {!q.isLoading && shifts.length === 0 && ( + {!q.isLoading && list.length === 0 && (

{t("shifts.none")}

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

{t("shifts.selectAShift")}

)} @@ -171,17 +217,33 @@ export function ShiftsHistory({ user }: { user: SessionUser | null }) { ); } -function ShiftCard({ - s, - showOperator, - selected, - onClick, -}: { - s: ShiftSummary; - showOperator: boolean; - selected: boolean; - onClick: () => void; -}) { +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); @@ -189,12 +251,11 @@ function ShiftCard({ ); } -function ShiftActivityLog({ shift, showOperator }: { shift: ShiftSummary; showOperator: boolean }) { +function ShiftActivityLog({ + shift, + isCurrent, + isMine, + showOperator, + canManage, + canVoucher, + onChanged, +}: { + shift: ShiftSummary; + isCurrent: boolean; + isMine: boolean; + showOperator: boolean; + canManage: boolean; + canVoucher: boolean; + onChanged: () => void; +}) { const { t } = useTranslation(); - // Every signed event in the shift's [start, end] window — the full audit trail. + const [modal, setModal] = 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], - queryFn: () => fetchEvents(1000, shift.startedAt, shift.endedAt), + 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; return (
- {/* Header — the shift's drawer reconciliation. */}
- + + {isCurrent && {t("shifts.current")}} {showOperator && `${shift.operator} · `} - {formatRelativeDateTime(shift.startedAt, t)} → {formatRelativeDateTime(shift.endedAt, t)} + {formatRelativeDateTime(shift.startedAt, t)} + {!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`} - {formatDuration(shift.startedAt, shift.endedAt)} + {/* Actions live on the CURRENT shift's pane (when it's mine), each → a modal. */} + {isCurrent && isMine && canManage && ( + + + {canVoucher && } + + + )}
@@ -243,38 +329,176 @@ function ShiftActivityLog({ shift, showOperator }: { shift: ShiftSummary; showOp
- {/* Activity log */} -
+
{q.isLoading &&

{t("common.loading")}

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

{t("shifts.noActivity")}

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

{t("shifts.noActivity")}

} {events.map((e) => ( ))}
+ + {modal === "end" && setModal(null)} onDone={onChanged} />} + {modal === "voucher" && 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}
+
+
+
+
+
+
+
+
+
+
+ {report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")} +
+
+ +
+
+ ) : ( + // Confirm — show the live takings/drawer before closing. +
+

{t("shift.endConfirm")}

+
+
+
+
+
+ {err &&

{err}

} +
+ + +
+
+ )} +
+ ); +} + +function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) { + const { t } = useTranslation(); + const [amount, setAmount] = useState(""); + const [reason, setReason] = useState(""); + const [authName, setAuthName] = useState(""); + const [authPassword, setAuthPassword] = useState(""); + const [msg, setMsg] = useState(null); + + async function submit(type: "cash_in" | "cash_out") { + setMsg(null); + const major = Number(amount); + if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive")); + if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired")); + try { + const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword }); + setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) })); + setAmount(""); + setReason(""); + setAuthPassword(""); + onDone(); + } catch (e) { + setMsg((e as Error).message); + } + } + + return ( + +
+
+ setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" /> + setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} /> +
+
+ setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" /> + setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" /> +
+
{t("shift.voucherHint")}
+ {msg &&
{msg}
} +
+ + + +
+
+
+ ); +} + +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()}
+
+
+
+
+
+
+
+
+
+
{t("shift.xReportHint")}
+
+ +
+
+ )} +
+ ); +} + 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 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 (
{time} - - {style.labelKey ? t(style.labelKey) : e.type} - + {style.labelKey ? t(style.labelKey) : e.type} {actor} {amount && {amount}}
diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx index b8d4487..cdb18e7 100644 --- a/apps/web/src/SubscriptionManager.tsx +++ b/apps/web/src/SubscriptionManager.tsx @@ -353,7 +353,7 @@ export function SubscriptionManager() { if (!subs) return null; return ( -
+

{t("subs.title")}

    {subs.map((s) => ( diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx index 1136458..ec0c74f 100644 --- a/apps/web/src/SubscriptionPlansManager.tsx +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -235,7 +235,7 @@ export function SubscriptionPlansManager() { } return ( -
    +

    {t("plans.title")}