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:
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, ledgerEvents, type Db } from "@parking/db";
|
||||
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
@@ -17,12 +17,22 @@ export async function eventRoutes(
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
|
||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||
app.get<{ Querystring: { limit?: string } }>(
|
||||
// Optional `since` (ISO) scopes the page to events at/after that instant — the
|
||||
// booth passes the current shift's start so the live feed shows ONLY this shift's
|
||||
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
||||
app.get<{ Querystring: { limit?: string; since?: string } }>(
|
||||
"/api/events",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const rows = db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
|
||||
.orderBy(desc(ledgerEvents.index))
|
||||
.limit(limit)
|
||||
.all();
|
||||
return { events: rows };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PayStation,
|
||||
} from "../pay-station.js";
|
||||
import type { ExitFlow } from "../exit-flow.js";
|
||||
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||
import { printExitVoucher } from "../booth-print.js";
|
||||
|
||||
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
||||
@@ -38,10 +39,31 @@ export async function payRoutes(
|
||||
db: Db,
|
||||
payStation: PayStation,
|
||||
exitFlow: ExitFlow,
|
||||
shift: ShiftService,
|
||||
): Promise<void> {
|
||||
// Cashier/operator/admin operate the booth; readonly may not.
|
||||
const guard = requireRole("admin", "operator", "cashier");
|
||||
|
||||
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
|
||||
// re-open is processed, so every taking is attributed to a shift (one operator's
|
||||
// accountability period). Read-only lookups (session/active/quote) stay ungated so
|
||||
// the modal can still DISPLAY the session and prompt the operator to open a shift.
|
||||
// Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift"
|
||||
// prompt rather than a generic failure. See wiki/concepts/shift.md.
|
||||
const requireShift = async (
|
||||
_req: import("fastify").FastifyRequest,
|
||||
reply: import("fastify").FastifyReply,
|
||||
) => {
|
||||
try {
|
||||
shift.requireOpenShift();
|
||||
} catch (err) {
|
||||
if (err instanceof NoShiftOpenError) {
|
||||
return reply.code(409).send({ error: err.message, code: "no_shift" });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Active sessions for the booth list: still-open OR exited-but-within-grace
|
||||
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
|
||||
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
|
||||
@@ -69,7 +91,7 @@ export async function payRoutes(
|
||||
// - clean exit → 200 { opened:true }.
|
||||
app.post<{ Body: ExitBody }>(
|
||||
"/api/exit",
|
||||
{ preHandler: guard },
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
@@ -85,7 +107,7 @@ export async function payRoutes(
|
||||
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
|
||||
app.post<{ Body: ExitBody }>(
|
||||
"/api/barrier/reopen",
|
||||
{ preHandler: guard },
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
@@ -114,7 +136,7 @@ export async function payRoutes(
|
||||
// Pay: take payment and append the signed `payment` event.
|
||||
app.post<{ Body: PayBody }>(
|
||||
"/api/pay",
|
||||
{ preHandler: guard },
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||
@@ -138,7 +160,7 @@ export async function payRoutes(
|
||||
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
|
||||
app.post<{ Body: VoucherBody }>(
|
||||
"/api/voucher",
|
||||
{ preHandler: guard },
|
||||
{ preHandler: [guard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
|
||||
@@ -22,15 +22,21 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
||||
// Cashier/operator/admin run shifts; readonly can't.
|
||||
const guard = requireRole("admin", "operator", "cashier");
|
||||
|
||||
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
||||
// Also returns the live drawer balance so the UI can show what's in the till.
|
||||
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
||||
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||
// - open: the open shift { startedAt, operator } or null (site-wide)
|
||||
// - isMine: true iff the open shift belongs to the requesting operator
|
||||
// - operator: the requesting user (for the UI's own identity)
|
||||
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||
const operator = req.user.username;
|
||||
const open = shift.openShiftFor(operator);
|
||||
const me = req.user.username;
|
||||
const open = shift.currentOpenShift();
|
||||
const heldBy = open?.identity ?? null;
|
||||
const drawer = shift.drawerBalance();
|
||||
return {
|
||||
operator,
|
||||
open: open ? { startedAt: open.occurredAt } : null,
|
||||
operator: me,
|
||||
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||
isMine: open != null && heldBy === me,
|
||||
drawerMinor: drawer.balanceMinor,
|
||||
currency: drawer.currency,
|
||||
};
|
||||
|
||||
@@ -132,10 +132,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||
await qrReaderRoutes(app, db, readDispatcher);
|
||||
|
||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||
// (sum payments by tender, print the Z-report). Constructed before the pay routes
|
||||
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
|
||||
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||
|
||||
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||
const payStation = new PayStation(db, eventLog, app.log);
|
||||
await payRoutes(app, db, payStation, exitFlow);
|
||||
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
||||
|
||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
@@ -144,9 +150,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||
await permitRoutes(app, db);
|
||||
|
||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
||||
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
|
||||
@@ -11,9 +11,16 @@ import type { EventLog } from "./event-log.js";
|
||||
// See wiki/concepts/shift.md.
|
||||
|
||||
export class ShiftAlreadyOpenError extends Error {
|
||||
constructor(operator: string) {
|
||||
super(`operator ${operator} already has an open shift`);
|
||||
/** The operator who currently holds the open shift (may be someone else). */
|
||||
readonly heldBy: string;
|
||||
constructor(operator: string, heldBy: string) {
|
||||
super(
|
||||
heldBy === operator
|
||||
? `operator ${operator} already has an open shift`
|
||||
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
||||
);
|
||||
this.name = "ShiftAlreadyOpenError";
|
||||
this.heldBy = heldBy;
|
||||
}
|
||||
}
|
||||
export class NoOpenShiftError extends Error {
|
||||
@@ -22,6 +29,14 @@ export class NoOpenShiftError extends Error {
|
||||
this.name = "NoOpenShiftError";
|
||||
}
|
||||
}
|
||||
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
||||
* must open a shift before any payment/exit can be attributed to a shift. */
|
||||
export class NoShiftOpenError extends Error {
|
||||
constructor() {
|
||||
super("no shift is open — open a shift before processing tickets");
|
||||
this.name = "NoShiftOpenError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShiftReport {
|
||||
readonly operator: string;
|
||||
@@ -82,6 +97,31 @@ export class ShiftService {
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
||||
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
||||
* attributed to one operator). It's open iff the most recent shift event on the
|
||||
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
||||
* appended yet). Returns that row so callers can read its operator/startedAt.
|
||||
*/
|
||||
currentOpenShift() {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||
const last = rows[rows.length - 1];
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
|
||||
/** Require an open shift for the booth money path; returns it or throws. */
|
||||
requireOpenShift() {
|
||||
const open = this.currentOpenShift();
|
||||
if (!open) throw new NoShiftOpenError();
|
||||
return open;
|
||||
}
|
||||
|
||||
/**
|
||||
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||||
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
|
||||
@@ -149,7 +189,11 @@ export class ShiftService {
|
||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||
* inherited from the chain = the drawer balance at the start instant. */
|
||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
||||
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
||||
// operator's own (double-open) or another operator's (handover not done). Only
|
||||
// one accountability period at a time.
|
||||
const current = this.currentOpenShift();
|
||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
||||
const startedAt = new Date().toISOString();
|
||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||
await this.#log.append({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
|
||||
@@ -24,6 +25,10 @@ function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The audited barrier re-open is a money-path action (server-gated on an open
|
||||
// shift); disable it unless this operator's shift is open.
|
||||
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
|
||||
const shiftReady = shiftOpen && shiftMine;
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.activeSessions,
|
||||
queryFn: fetchActiveSessions,
|
||||
@@ -97,10 +102,10 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
{s.paidAt ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending}
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={() => handleReopen(s)}
|
||||
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
|
||||
title={t("booth.openBarrierTitle")}
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
boothExit,
|
||||
fetchSiteConfig,
|
||||
lookupSession,
|
||||
openShift,
|
||||
paySession,
|
||||
printVoucher,
|
||||
type SessionLookup,
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
|
||||
@@ -28,18 +30,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
|
||||
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
|
||||
|
||||
// A shift must be open (and mine) before any pay/exit/voucher action — the booth
|
||||
// money path is gated. The server enforces this too (409 no_shift); the modal
|
||||
// surfaces it up front and offers a one-click open. See wiki/concepts/shift.md.
|
||||
const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift();
|
||||
const shiftReady = shiftOpen && shiftMine;
|
||||
|
||||
const [tender, setTender] = useState<"cash" | "card">("cash");
|
||||
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("review");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [openingShift, setOpeningShift] = useState(false);
|
||||
|
||||
const s: SessionLookup | undefined = session.data;
|
||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||
|
||||
const alreadyPaid = s?.paidAt != null;
|
||||
const canPay = s?.found && s.open && !alreadyPaid;
|
||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid;
|
||||
|
||||
async function handleOpenShift() {
|
||||
setOpeningShift(true);
|
||||
setError(null);
|
||||
try {
|
||||
await openShift();
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setOpeningShift(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayAndExit() {
|
||||
if (!s) return;
|
||||
@@ -91,6 +114,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{/* Shift gate — block all actions until THIS operator has a shift open.
|
||||
Another operator's open shift can't be operated under (no shared
|
||||
till); only an "open mine" path when no shift is open at all. */}
|
||||
{!shiftReady && (
|
||||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||
{blockedByOther ? (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateOtherTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">
|
||||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("shift.gateTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShift}
|
||||
disabled={openingShift}
|
||||
className="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{openingShift ? t("shift.opening") : t("shift.openNow")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
|
||||
|
||||
{s && !s.found && (
|
||||
@@ -200,7 +256,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={phase === "paying" || phase === "finishing"}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
|
||||
>
|
||||
{phase === "paying"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useLiveStore } from "./lib/live-store.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothPayModal } from "./BoothPayModal.js";
|
||||
@@ -124,9 +125,19 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
|
||||
export function BoothScreen() {
|
||||
const { t } = useTranslation();
|
||||
// Initial load via Query (also the fallback if the WS is briefly down).
|
||||
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
|
||||
// window (per-shift logs, not all history). When no shift is open, the feed is
|
||||
// empty and the operator is prompted to open one.
|
||||
const { isOpen: shiftOpen, startedAt: shiftStart } = useShift();
|
||||
|
||||
// Initial load via Query (also the fallback if the WS is briefly down). The events
|
||||
// query is scoped to the current shift's start so it never shows prior shifts.
|
||||
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: [...qk.events, shiftStart ?? "none"],
|
||||
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
|
||||
enabled: shiftOpen,
|
||||
});
|
||||
|
||||
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||
@@ -138,10 +149,16 @@ export function BoothScreen() {
|
||||
// Prefer the live-pushed occupancy; fall back to the query.
|
||||
const occ = liveOcc ?? occQuery.data ?? null;
|
||||
|
||||
// Merge: live events first (newest), then the queried history, de-duped by id.
|
||||
// Merge: live events first (newest), then the queried history, de-duped by id —
|
||||
// then clip to the current shift window (the live store spans shifts; the feed
|
||||
// must not show events from before this shift's start). No shift → no feed.
|
||||
const seen = new Set(liveFeed.map((e) => e.id));
|
||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||
const events = [...liveFeed, ...history].slice(0, 200);
|
||||
const merged = [...liveFeed, ...history].slice(0, 200);
|
||||
const events =
|
||||
shiftOpen && shiftStart
|
||||
? merged.filter((e) => e.occurredAt >= shiftStart)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||
@@ -176,7 +193,9 @@ export function BoothScreen() {
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
{events.length === 0 ? (
|
||||
{!shiftOpen ? (
|
||||
<div className="text-term-amber">{t("shift.gateTitle")}</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
||||
) : (
|
||||
events.map((e) => <EventRow key={e.id} e={e} />)
|
||||
|
||||
+15
-4
@@ -317,8 +317,12 @@ export function deletePermit(id: string): Promise<void> {
|
||||
// --- Shifts ---------------------------------------------------------------
|
||||
|
||||
export interface ShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
operator: string;
|
||||
open: { startedAt: string } | null;
|
||||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||||
open: { startedAt: string; operator: string | null } | null;
|
||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||
isMine: boolean;
|
||||
/** Live physical drawer balance (cash payments + cash movements). */
|
||||
drawerMinor: number;
|
||||
currency: string | null;
|
||||
@@ -394,9 +398,16 @@ export function fetchOccupancy(): Promise<Occupancy> {
|
||||
export type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||
* booth feed's initial load; live updates then arrive over the WS. */
|
||||
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||
return apiFetch(`/api/events?limit=${limit}`);
|
||||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||
* scopes to events at/after that instant — the booth passes the current shift's
|
||||
* start so the feed shows ONLY this shift's activity. */
|
||||
export function fetchEvents(
|
||||
limit = 100,
|
||||
since?: string,
|
||||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (since) qs.set("since", since);
|
||||
return apiFetch(`/api/events?${qs.toString()}`);
|
||||
}
|
||||
|
||||
// --- Booth: session lookup, payment, exit ---------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+69
-2
@@ -6,12 +6,15 @@ import {
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lang, SessionUser } from "./api.js";
|
||||
import { logout, setLanguagePref } from "./api.js";
|
||||
import { queryClient } from "./lib/query.js";
|
||||
import { closeShift, logout, openShift, setLanguagePref } from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
import { BoothScreen } from "./BoothScreen.js";
|
||||
import { SetupWizard } from "./SetupWizard.js";
|
||||
@@ -82,6 +85,69 @@ function LanguageToggle({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
||||
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
||||
* - another's shift open → disabled, labelled with who holds it (you can neither
|
||||
* open yours nor close theirs until they hand over).
|
||||
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
|
||||
*/
|
||||
function ShiftButton() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function act(kind: "open" | "close") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
if (kind === "open") await openShift();
|
||||
else await closeShift();
|
||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled when another operator holds the shift (can't open or close).
|
||||
const label = blockedByOther
|
||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||
: isMine
|
||||
? t("shift.headerClose")
|
||||
: t("shift.headerOpen");
|
||||
const tone = blockedByOther
|
||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||
: isMine
|
||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={() => act(isMine ? "close" : "open")}
|
||||
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
||||
>
|
||||
{busy ? t("shift.opening") : label}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
)}
|
||||
{err && <span className="text-[10px] text-term-red">{err}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
@@ -102,6 +168,7 @@ function RootLayout() {
|
||||
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
|
||||
Reference in New Issue
Block a user