feat(shift): site-wide single-open shift + booth money-path gate

A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).

Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
  shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
  /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).

Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
  pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
  invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.

Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.

Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
This commit is contained in:
2026-06-18 12:13:17 +02:00
parent 48660d3ec8
commit 4e2e4feedb
19 changed files with 415 additions and 42 deletions
+14
View File
@@ -183,6 +183,20 @@ export const en: Catalog = {
expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).",
// Header shift control + the booth shift gate.
headerNoShift: "No shift",
headerOpen: "Open shift",
headerClose: "Close shift",
headerHeldBy: "Shift open — {{operator}}",
headerHeldByShort: "Shift: {{operator}}",
gateTitle: "Open a shift to process tickets",
gateBody:
"No shift is open. Open your shift so payments and exits are recorded against it.",
gateOtherTitle: "The open shift belongs to another operator",
gateOtherBody:
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
openNow: "Open shift now",
opening: "Opening…",
},
pay: {
ticket: "Ticket",
+16 -2
View File
@@ -28,7 +28,7 @@ export const sq = {
site: "Vendi",
},
status: {
live: "DREJTPËRDREJT",
live: "LIVE",
connecting: "DUKE U LIDHUR",
offline: "JASHTË LINJE",
},
@@ -43,7 +43,7 @@ export const sq = {
uncapped: "pa kufi",
free: "lirë",
lotFull: "● parkimi plot",
liveFeed: "Aktiviteti i drejtpërdrejtë",
liveFeed: "Aktiviteti live",
events: "ngjarje",
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
activeSessions: "Sesionet aktive",
@@ -185,6 +185,20 @@ export const sq = {
expectedDrawer: "Arka e pritshme:",
printedToReceipt: "Printuar te printeri i kabinës.",
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
// Header shift control + the booth shift gate.
headerNoShift: "Asnjë turn",
headerOpen: "Hap turnin",
headerClose: "Mbyll turnin",
headerHeldBy: "Turn i hapur nga {{operator}}",
headerHeldByShort: "Turni: {{operator}}",
gateTitle: "Hap një turn për të proceduar biletat",
gateBody:
"Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.",
gateOtherTitle: "Turni i hapur i përket një operatori tjetër",
gateOtherBody:
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
openNow: "Hap turnin tani",
opening: "Duke hapur…",
},
pay: {
ticket: "Bileta",
+1
View File
@@ -25,4 +25,5 @@ export const qk = {
events: ["events"] as const,
activeSessions: ["active-sessions"] as const,
siteConfig: ["site-config"] as const,
shift: ["shift"] as const,
} as const;
+9
View File
@@ -64,6 +64,15 @@ export function useLiveFeed(): void {
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
// A shift open/close (or a drawer movement) changes the header control
// state and the per-shift log window — refresh the shift status too.
if (
msg.event.type === "shift_open" ||
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
} else if (msg.kind === "printer-status") {
void qc.invalidateQueries({ queryKey: ["printers"] });
}
+42
View File
@@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query";
import { fetchShift, type ShiftStatus } from "../api.js";
import { qk } from "./query.js";
// Shared shift status for the whole app — the header control, the booth screen's
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
// they never disagree about whether a shift is open and whose it is. A shift is a
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
// live without polling. See wiki/concepts/shift.md.
export interface ShiftState {
/** Raw status from the server (null while loading / on error). */
status: ShiftStatus | undefined;
/** Is ANY shift open site-wide? */
isOpen: boolean;
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
isMine: boolean;
/** A shift is open but belongs to someone else — this operator is blocked. */
blockedByOther: boolean;
/** ISO start of the open shift, for scoping the per-shift log. */
startedAt: string | null;
/** Whoever holds the open shift (for "held by X" messaging). */
heldBy: string | null;
isLoading: boolean;
}
export function useShift(): ShiftState {
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
const s = q.data;
const isOpen = s?.open != null;
const isMine = s?.isMine ?? false;
return {
status: s,
isOpen,
isMine,
blockedByOther: isOpen && !isMine,
startedAt: s?.open?.startedAt ?? null,
heldBy: s?.open?.operator ?? null,
isLoading: q.isLoading,
};
}