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 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 { requireRole } from "../auth.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
@@ -17,12 +17,22 @@ export async function eventRoutes(
|
|||||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||||
|
|
||||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
// 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",
|
"/api/events",
|
||||||
{ preHandler: guard },
|
{ preHandler: guard },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
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 };
|
return { events: rows };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type PayStation,
|
type PayStation,
|
||||||
} from "../pay-station.js";
|
} from "../pay-station.js";
|
||||||
import type { ExitFlow } from "../exit-flow.js";
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||||
import { printExitVoucher } from "../booth-print.js";
|
import { printExitVoucher } from "../booth-print.js";
|
||||||
|
|
||||||
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
||||||
@@ -38,10 +39,31 @@ export async function payRoutes(
|
|||||||
db: Db,
|
db: Db,
|
||||||
payStation: PayStation,
|
payStation: PayStation,
|
||||||
exitFlow: ExitFlow,
|
exitFlow: ExitFlow,
|
||||||
|
shift: ShiftService,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Cashier/operator/admin operate the booth; readonly may not.
|
// Cashier/operator/admin operate the booth; readonly may not.
|
||||||
const guard = requireRole("admin", "operator", "cashier");
|
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
|
// Active sessions for the booth list: still-open OR exited-but-within-grace
|
||||||
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
|
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
|
||||||
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
|
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
|
||||||
@@ -69,7 +91,7 @@ export async function payRoutes(
|
|||||||
// - clean exit → 200 { opened:true }.
|
// - clean exit → 200 { opened:true }.
|
||||||
app.post<{ Body: ExitBody }>(
|
app.post<{ Body: ExitBody }>(
|
||||||
"/api/exit",
|
"/api/exit",
|
||||||
{ preHandler: guard },
|
{ preHandler: [guard, requireShift] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const identity = (req.body?.identity ?? "").trim();
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
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.
|
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
|
||||||
app.post<{ Body: ExitBody }>(
|
app.post<{ Body: ExitBody }>(
|
||||||
"/api/barrier/reopen",
|
"/api/barrier/reopen",
|
||||||
{ preHandler: guard },
|
{ preHandler: [guard, requireShift] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const identity = (req.body?.identity ?? "").trim();
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
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.
|
// Pay: take payment and append the signed `payment` event.
|
||||||
app.post<{ Body: PayBody }>(
|
app.post<{ Body: PayBody }>(
|
||||||
"/api/pay",
|
"/api/pay",
|
||||||
{ preHandler: guard },
|
{ preHandler: [guard, requireShift] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { identity, tender, overrideMinor } = req.body ?? {};
|
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||||
if (!identity || (tender !== "cash" && tender !== "card")) {
|
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.
|
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
|
||||||
app.post<{ Body: VoucherBody }>(
|
app.post<{ Body: VoucherBody }>(
|
||||||
"/api/voucher",
|
"/api/voucher",
|
||||||
{ preHandler: guard },
|
{ preHandler: [guard, requireShift] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const identity = (req.body?.identity ?? "").trim();
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
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.
|
// Cashier/operator/admin run shifts; readonly can't.
|
||||||
const guard = requireRole("admin", "operator", "cashier");
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
||||||
// Also returns the live drawer balance so the UI can show what's in the till.
|
// 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) => {
|
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||||
const operator = req.user.username;
|
const me = req.user.username;
|
||||||
const open = shift.openShiftFor(operator);
|
const open = shift.currentOpenShift();
|
||||||
|
const heldBy = open?.identity ?? null;
|
||||||
const drawer = shift.drawerBalance();
|
const drawer = shift.drawerBalance();
|
||||||
return {
|
return {
|
||||||
operator,
|
operator: me,
|
||||||
open: open ? { startedAt: open.occurredAt } : null,
|
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||||
|
isMine: open != null && heldBy === me,
|
||||||
drawerMinor: drawer.balanceMinor,
|
drawerMinor: drawer.balanceMinor,
|
||||||
currency: drawer.currency,
|
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.
|
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||||
await qrReaderRoutes(app, db, readDispatcher);
|
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 +
|
// 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);
|
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
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
// 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.
|
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||||
await permitRoutes(app, db);
|
await permitRoutes(app, db);
|
||||||
|
|
||||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||||
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
|
||||||
const shiftService = new ShiftService(db, eventLog, app.log);
|
|
||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, shiftService);
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
// 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.
|
// See wiki/concepts/shift.md.
|
||||||
|
|
||||||
export class ShiftAlreadyOpenError extends Error {
|
export class ShiftAlreadyOpenError extends Error {
|
||||||
constructor(operator: string) {
|
/** The operator who currently holds the open shift (may be someone else). */
|
||||||
super(`operator ${operator} already has an open shift`);
|
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.name = "ShiftAlreadyOpenError";
|
||||||
|
this.heldBy = heldBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class NoOpenShiftError extends Error {
|
export class NoOpenShiftError extends Error {
|
||||||
@@ -22,6 +29,14 @@ export class NoOpenShiftError extends Error {
|
|||||||
this.name = "NoOpenShiftError";
|
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 {
|
export interface ShiftReport {
|
||||||
readonly operator: string;
|
readonly operator: string;
|
||||||
@@ -82,6 +97,31 @@ export class ShiftService {
|
|||||||
return last && last.type === "shift_open" ? last : null;
|
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
|
* 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
|
* 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-
|
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||||
* inherited from the chain = the drawer balance at the start instant. */
|
* inherited from the chain = the drawer balance at the start instant. */
|
||||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
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 startedAt = new Date().toISOString();
|
||||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatTime } from "./lib/format.js";
|
import { formatDuration, formatTime } from "./lib/format.js";
|
||||||
import { Panel } from "./ui/Panel.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 }) {
|
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: qk.activeSessions,
|
queryKey: qk.activeSessions,
|
||||||
queryFn: fetchActiveSessions,
|
queryFn: fetchActiveSessions,
|
||||||
@@ -97,10 +102,10 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
{s.paidAt ? (
|
{s.paidAt ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reopen.isPending}
|
disabled={reopen.isPending || !shiftReady}
|
||||||
onClick={() => handleReopen(s)}
|
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"
|
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")}
|
{t("booth.openBarrier")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
boothExit,
|
boothExit,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
lookupSession,
|
lookupSession,
|
||||||
|
openShift,
|
||||||
paySession,
|
paySession,
|
||||||
printVoucher,
|
printVoucher,
|
||||||
type SessionLookup,
|
type SessionLookup,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.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 session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
|
||||||
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
|
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 [tender, setTender] = useState<"cash" | "card">("cash");
|
||||||
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
|
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
|
||||||
const [phase, setPhase] = useState<Phase>("review");
|
const [phase, setPhase] = useState<Phase>("review");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<string | null>(null);
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
const [openingShift, setOpeningShift] = useState(false);
|
||||||
|
|
||||||
const s: SessionLookup | undefined = session.data;
|
const s: SessionLookup | undefined = session.data;
|
||||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||||
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
|
||||||
|
|
||||||
const alreadyPaid = s?.paidAt != null;
|
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() {
|
async function handlePayAndExit() {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
@@ -91,6 +114,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<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>}
|
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
|
||||||
|
|
||||||
{s && !s.found && (
|
{s && !s.found && (
|
||||||
@@ -200,7 +256,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePayAndExit}
|
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"
|
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"
|
{phase === "paying"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useLiveStore } from "./lib/live-store.js";
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
import { BoothPayModal } from "./BoothPayModal.js";
|
import { BoothPayModal } from "./BoothPayModal.js";
|
||||||
@@ -124,9 +125,19 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
|
|
||||||
export function BoothScreen() {
|
export function BoothScreen() {
|
||||||
const { t } = useTranslation();
|
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 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).
|
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||||
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||||
@@ -138,10 +149,16 @@ export function BoothScreen() {
|
|||||||
// Prefer the live-pushed occupancy; fall back to the query.
|
// Prefer the live-pushed occupancy; fall back to the query.
|
||||||
const occ = liveOcc ?? occQuery.data ?? null;
|
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 seen = new Set(liveFeed.map((e) => e.id));
|
||||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(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 (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
<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"
|
className="min-h-0"
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-y-auto pr-1">
|
<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>
|
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
||||||
) : (
|
) : (
|
||||||
events.map((e) => <EventRow key={e.id} e={e} />)
|
events.map((e) => <EventRow key={e.id} e={e} />)
|
||||||
|
|||||||
+15
-4
@@ -317,8 +317,12 @@ export function deletePermit(id: string): Promise<void> {
|
|||||||
// --- Shifts ---------------------------------------------------------------
|
// --- Shifts ---------------------------------------------------------------
|
||||||
|
|
||||||
export interface ShiftStatus {
|
export interface ShiftStatus {
|
||||||
|
/** The requesting (logged-in) operator. */
|
||||||
operator: string;
|
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). */
|
/** Live physical drawer balance (cash payments + cash movements). */
|
||||||
drawerMinor: number;
|
drawerMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
@@ -394,9 +398,16 @@ export function fetchOccupancy(): Promise<Occupancy> {
|
|||||||
export type { LedgerEvent } from "@parking/shared";
|
export type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||||
* booth feed's initial load; live updates then arrive over the WS. */
|
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||||
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
* scopes to events at/after that instant — the booth passes the current shift's
|
||||||
return apiFetch(`/api/events?limit=${limit}`);
|
* 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 ---------------------------------
|
// --- Booth: session lookup, payment, exit ---------------------------------
|
||||||
|
|||||||
@@ -183,6 +183,20 @@ export const en: Catalog = {
|
|||||||
expectedDrawer: "Expected drawer:",
|
expectedDrawer: "Expected drawer:",
|
||||||
printedToReceipt: "Printed to booth receipt.",
|
printedToReceipt: "Printed to booth receipt.",
|
||||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
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: {
|
pay: {
|
||||||
ticket: "Ticket",
|
ticket: "Ticket",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const sq = {
|
|||||||
site: "Vendi",
|
site: "Vendi",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "DREJTPËRDREJT",
|
live: "LIVE",
|
||||||
connecting: "DUKE U LIDHUR",
|
connecting: "DUKE U LIDHUR",
|
||||||
offline: "JASHTË LINJE",
|
offline: "JASHTË LINJE",
|
||||||
},
|
},
|
||||||
@@ -43,7 +43,7 @@ export const sq = {
|
|||||||
uncapped: "pa kufi",
|
uncapped: "pa kufi",
|
||||||
free: "lirë",
|
free: "lirë",
|
||||||
lotFull: "● parkimi plot",
|
lotFull: "● parkimi plot",
|
||||||
liveFeed: "Aktiviteti i drejtpërdrejtë",
|
liveFeed: "Aktiviteti live",
|
||||||
events: "ngjarje",
|
events: "ngjarje",
|
||||||
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||||
activeSessions: "Sesionet aktive",
|
activeSessions: "Sesionet aktive",
|
||||||
@@ -185,6 +185,20 @@ export const sq = {
|
|||||||
expectedDrawer: "Arka e pritshme:",
|
expectedDrawer: "Arka e pritshme:",
|
||||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
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: {
|
pay: {
|
||||||
ticket: "Bileta",
|
ticket: "Bileta",
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ export const qk = {
|
|||||||
events: ["events"] as const,
|
events: ["events"] as const,
|
||||||
activeSessions: ["active-sessions"] as const,
|
activeSessions: ["active-sessions"] as const,
|
||||||
siteConfig: ["site-config"] as const,
|
siteConfig: ["site-config"] as const,
|
||||||
|
shift: ["shift"] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ export function useLiveFeed(): void {
|
|||||||
void qc.invalidateQueries({ queryKey: qk.events });
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
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") {
|
} else if (msg.kind === "printer-status") {
|
||||||
void qc.invalidateQueries({ queryKey: ["printers"] });
|
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,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, SessionUser } from "./api.js";
|
import type { Lang, SessionUser } from "./api.js";
|
||||||
import { logout, setLanguagePref } from "./api.js";
|
import { closeShift, logout, openShift, setLanguagePref } from "./api.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { qk, queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
import { BoothScreen } from "./BoothScreen.js";
|
import { BoothScreen } from "./BoothScreen.js";
|
||||||
import { SetupWizard } from "./SetupWizard.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() {
|
function RootLayout() {
|
||||||
const { user, setUser } = rootRoute.useRouteContext();
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -102,6 +168,7 @@ function RootLayout() {
|
|||||||
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{user && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
<span className="text-[11px] text-term-muted">
|
<span className="text-[11px] text-term-muted">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, and, desc, sql } from "drizzle-orm";
|
export { eq, and, desc, gte, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ a right column with the **live event ticker**. Submitting/clicking a ticket open
|
|||||||
modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
|
modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
|
||||||
the WS.
|
the WS.
|
||||||
|
|
||||||
|
## The shift control (header) + the booth gate
|
||||||
|
|
||||||
|
The header carries a single **shift button** that expresses the [[shift|site-wide single-open
|
||||||
|
shift]] (added 2026-06-18):
|
||||||
|
|
||||||
|
- **No shift open** → "Open shift" (green, enabled).
|
||||||
|
- **My shift open** → "Close shift" (red, enabled — signs + prints the Z-report).
|
||||||
|
- **Another operator's shift open** → **disabled**, titled with who holds it. You can neither open
|
||||||
|
yours nor close theirs until they hand over.
|
||||||
|
|
||||||
|
State comes from one shared Query (`useShift()` → `GET /api/shift/current`, returning `{ open:
|
||||||
|
{startedAt, operator} | null, isMine }`); the WS invalidates it on `shift_open` / `shift_z_report` /
|
||||||
|
`cash_movement`, so the button (and the per-shift log scope) update live without polling.
|
||||||
|
|
||||||
|
The **booth screen gates on this**: the pay/exit modal shows an "open a shift" banner (with a
|
||||||
|
one-click *Open shift now*) and disables pay/exit/voucher until **this operator's** shift is open;
|
||||||
|
the Active-Sessions "Open barrier" is disabled the same way. The server enforces it regardless
|
||||||
|
(`requireShift` 409 `no_shift`) — the UI just front-runs the rejection. The live feed is **scoped to
|
||||||
|
the open shift's window** (empty when no shift is open). See [[shift]] for the rule and the routes.
|
||||||
|
|
||||||
## Dev notes
|
## Dev notes
|
||||||
- Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the
|
- Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the
|
||||||
dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA
|
dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA
|
||||||
|
|||||||
+28
-3
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-06-18
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,6 +21,29 @@ is **no operator and no shift**; what replaces it is the pay station's **cash-co
|
|||||||
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
|
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
|
||||||
don't force one model across both.
|
don't force one model across both.
|
||||||
|
|
||||||
|
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
|
||||||
|
|
||||||
|
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
|
||||||
|
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
|
||||||
|
falls inside exactly one operator's window. Consequences:
|
||||||
|
|
||||||
|
- **Login ≠ shift.** An operator may log in **off-shift** (e.g. to review their own past activity);
|
||||||
|
logging in never opens a shift. Conversely a shift can't be opened by two people at once.
|
||||||
|
- **Opening is refused when ANY shift is open** — whether the operator's own (double-open) or
|
||||||
|
*another* operator's (handover not done). `ShiftService.open()` checks `currentOpenShift()` (the
|
||||||
|
single site-wide open shift = most recent shift event on the whole chain is a `shift_open`), and
|
||||||
|
throws `ShiftAlreadyOpenError` carrying `heldBy` so the UI can name who holds it. Operator B can
|
||||||
|
only start once operator A closes — that's the handover.
|
||||||
|
- **The booth money path is GATED on an open shift.** `/api/pay`, `/api/exit`, `/api/voucher`,
|
||||||
|
`/api/barrier/reopen` run a `requireShift` preHandler that 409s `{ code: "no_shift" }` when none
|
||||||
|
is open. Read-only lookups (`/api/session/:id`, `/api/sessions/active`, `/api/pay/quote`) stay
|
||||||
|
ungated so the modal can still *display* a session and prompt "open a shift". The server is the
|
||||||
|
enforcement point; the UI mirrors it (see [[booth-console]]).
|
||||||
|
- **"Operate under someone else's shift" is deliberately disallowed.** B's takings would land in A's
|
||||||
|
Z-report and corrupt the attribution, so B is fully blocked until B's own shift is open.
|
||||||
|
- **Logs are per-shift.** The booth live feed shows only events from the open shift's window
|
||||||
|
(`GET /api/events?since=<shiftStart>`); no shift open → no feed, just the "open a shift" prompt.
|
||||||
|
|
||||||
## A shift is NOT time-based
|
## A shift is NOT time-based
|
||||||
|
|
||||||
It is delimited by **explicit operator action**, never by a clock:
|
It is delimited by **explicit operator action**, never by a clock:
|
||||||
@@ -56,8 +79,10 @@ no variance gate, no manager override.
|
|||||||
|
|
||||||
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
|
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
|
||||||
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
|
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
|
||||||
event `identity`; a shift is **open** iff that operator's most recent shift event is a
|
event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
||||||
`shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
> **Superseded 2026-06-18:** open-ness is now judged **site-wide** (`currentOpenShift()` — the most
|
||||||
|
> recent shift event on the *whole* chain), not per-operator. See "Site-wide single-open" above.
|
||||||
|
> `openShiftFor(operator)` survives only for `close()` (you close your own shift).
|
||||||
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
|
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
|
||||||
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
|
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
|
||||||
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
|
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
|
||||||
|
|||||||
@@ -796,3 +796,7 @@ Two languages via react-i18next, Albanian default/fallback. Language is a per-us
|
|||||||
## [2026-06-18] lint | Reconcile wiki with the session's work
|
## [2026-06-18] lint | Reconcile wiki with the session's work
|
||||||
|
|
||||||
Audited wiki vs. the session: three major builds (live WebSocket, frontend foundation, i18n) had NO log entry and NO concept page. Filed [[i18n]] (resolved a dangling code-comment link) and [[booth-console]] (operator-UI architecture: stack, /api/ws live feed, anti-CSWSH, booth screen). Updated stale [[react-vite-spa]] (the "plain React, no framework" claim is now qualified). Backfilled the three missing build log entries. Standing gaps flagged across pages: NO automated tests (front or back); ATECC608 not yet wired (software-HMAC signing is tamper-evident, not tamper-proof); pre-existing admin screens not on the terminal theme.
|
Audited wiki vs. the session: three major builds (live WebSocket, frontend foundation, i18n) had NO log entry and NO concept page. Filed [[i18n]] (resolved a dangling code-comment link) and [[booth-console]] (operator-UI architecture: stack, /api/ws live feed, anti-CSWSH, booth screen). Updated stale [[react-vite-spa]] (the "plain React, no framework" claim is now qualified). Backfilled the three missing build log entries. Standing gaps flagged across pages: NO automated tests (front or back); ATECC608 not yet wired (software-HMAC signing is tamper-evident, not tamper-proof); pre-existing admin screens not on the terminal theme.
|
||||||
|
|
||||||
|
## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs
|
||||||
|
|
||||||
|
Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.
|
||||||
|
|||||||
Reference in New Issue
Block a user