feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission

Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-05 13:23:09 +02:00
parent 23d6379be8
commit a9ccf9e20c
46 changed files with 3966 additions and 510 deletions
+17
View File
@@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div>
)}
{/* Module charges folded into the settlement (e.g. a car wash ordered
with "pay at booth") — one "+" line each; the Total below includes
them. See wiki/decisions/venue-modules.md. */}
{!isSubscription &&
(s.chargeLines ?? []).length > 0 &&
s.currency != null && (
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="text-term-muted">{t("booth.charges")}</div>
{(s.chargeLines ?? []).map((c, i) => (
<div key={i} className="flex justify-between text-term-text">
<span>{c.label}</span>
<span className="tabular-nums">+{formatMoney(c.amountMinor, s.currency!)}</span>
</div>
))}
</div>
)}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
+46 -24
View File
@@ -13,18 +13,21 @@ import {
type DrawerMovement,
type MovementStatus,
type ShiftSummary,
type TillId,
} from "./api.js";
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { shiftKey } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared";
import { tillOf, type LedgerEvent } from "@parking/shared";
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
// answers "what's in the till and why": the CURRENT drawer balance with the open
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
// activity (every cash payment and voucher, live), the movement record/review flow
// (unchanged), and the closed-shift drawer history. All figures come from the signed
// chain — the drawer is a single site-wide till that carries across shifts. See
// wiki/concepts/shift.md.
// chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
// shows one till at a time — a switch appears when the site has more than one — and
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
@@ -53,6 +56,11 @@ function StatusBadge({ status }: { status: MovementStatus }) {
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [till, setTill] = useState<TillId>("booth");
// Which tills exist here (the booth + effective money-taking modules') — from the
// booth's status read, which every till answer carries.
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
const tills = status.data?.tills ?? ["booth"];
const refresh = () => {
void qc.invalidateQueries({ queryKey: ["drawer"] });
// A voucher moves the open shift's added/removed figures too (the X-report).
@@ -61,17 +69,28 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
{/* Till switch — only when there is more than one drawer to look at. */}
{tills.length > 1 && (
<div className="flex shrink-0 items-center gap-1.5">
{tills.map((x) => (
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}>
{t(`till.${x}Long`)}
</button>
))}
</div>
)}
{/* Row 1: the till NOW + the record form. */}
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
<StatePanel />
{canCreate && <RecordPanel onDone={refresh} />}
<StatePanel till={till} />
{canCreate && <RecordPanel till={till} onDone={refresh} />}
</div>
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
<TodayPanel />
<MovementsPanel canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel />
<TodayPanel till={till} />
<MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel till={till} />
</div>
</div>
);
@@ -81,13 +100,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
// Balance from the chain + the open shift's running X-report breakdown, so the big
// number is always explainable: float + cash takings + in − out = expected = balance.
function StatePanel() {
function StatePanel({ till }: { till: TillId }) {
const { t } = useTranslation();
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
const report = useQuery({
queryKey: ["shift", "xreport"],
queryFn: fetchShiftReport,
queryKey: ["shift", "xreport", till],
queryFn: () => fetchShiftReport(till),
enabled: status.data?.open != null,
refetchInterval: 10_000,
});
@@ -149,7 +168,7 @@ function StatePanel() {
// Every drawer-touching event since local midnight: cash payments (the current
// shift's incomings, live) + vouchers. Card payments never enter the till.
function TodayPanel() {
function TodayPanel({ till }: { till: TillId }) {
const { t } = useTranslation();
const q = useQuery({
queryKey: ["drawer", "today"],
@@ -157,9 +176,12 @@ function TodayPanel() {
refetchInterval: 15_000,
});
// This till's drawer-touching events only (a bay payment is wash-till money; a
// parking payment is booth money — tillOf() is the one shared rule).
const rows = (q.data?.events ?? []).filter((e) => {
if (tillOf(e.payload) !== till) return false;
if (e.type === "cash_in" || e.type === "cash_out") return true;
if (e.type !== "payment") return false;
if (e.type !== "payment" && e.type !== "carwash_payment") return false;
return (e.payload as { tender?: string } | null)?.tender !== "card";
});
@@ -171,7 +193,7 @@ function TodayPanel() {
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
const amt = pl.amountMinor ?? 0;
if (pl.currency) cur = pl.currency;
if (e.type === "payment") {
if (e.type === "payment" || e.type === "carwash_payment") {
cashIn += amt;
payments++;
} else {
@@ -225,7 +247,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
const time = formatClock(e.occurredAt);
const label =
e.type === "payment"
e.type === "payment" || e.type === "carwash_payment"
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
return (
@@ -243,13 +265,13 @@ function TodayRow({ e }: { e: LedgerEvent }) {
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) {
const { t } = useTranslation();
// Reviewers can filter the list (the pending queue); operators always see their own, all.
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
const q = useQuery({
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
queryKey: ["drawer", "movements", canReview ? statusFilter : "", till],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
});
const movements = q.data?.movements ?? [];
const pendingCount = movements.filter((m) => m.status === "pending").length;
@@ -316,9 +338,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange
// --- Closed shifts, drawer-focused -------------------------------------------
// Scope follows /api/shifts: operators see their own, admins all.
function ShiftHistoryPanel() {
function ShiftHistoryPanel({ till }: { till: TillId }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
const shifts = (q.data?.shifts ?? []).slice(0, 50);
const showOperator = q.data?.scope === "all";
@@ -377,14 +399,14 @@ function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: b
// --- Record form (unchanged from the pre-redesign feature) ------------------
function RecordPanel({ onDone }: { onDone: () => void }) {
function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) {
const { t } = useTranslation();
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const record = useMutation({
mutationFn: (type: "cash_in" | "cash_out") =>
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }),
onSuccess: (r) => {
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
setAmount("");
+202
View File
@@ -0,0 +1,202 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
/**
* Shift control for ONE TILL — the till's single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift on the till)
* - 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).
* The header renders it for the booth; the wash desk renders it for the carwash till
* (its labels then name the till, so the two are never confused). On open/close it
* invalidates the shift status, the per-shift log, and occupancy.
* See wiki/concepts/shift.md "Tills".
*/
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
const { t } = useTranslation();
const qc = useQueryClient();
const { isOpen, isMine, blockedByOther, heldBy } = useShift(till);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Closing a shift signs the Z-report and is irreversible, so the button never
// closes directly (a stray click would end the shift) — it opens a confirm modal that
// shows the live X-report first. Opening a shift has no such risk → immediate.
const [confirmingClose, setConfirmingClose] = useState(false);
function onClick() {
if (isMine) {
setConfirmingClose(true);
} else {
void act("open");
}
}
async function act(kind: "open" | "close") {
setBusy(true);
setErr(null);
try {
if (kind === "open") await openShift(till);
else await closeShift(till);
// The shift boundary moves: refresh status, the per-shift log window, drawer.
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: ["shifts"] });
void qc.invalidateQueries({ queryKey: ["drawer"] });
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// The booth keeps its historical wording; any other till names itself.
const tillName = t(`till.${till}`);
const label = blockedByOther
? till === "booth"
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
: t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })
: isMine
? till === "booth"
? t("shift.headerClose")
: t("shift.tillClose", { till: tillName })
: till === "booth"
? t("shift.headerOpen")
: t("shift.tillOpen", { till: tillName });
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={onClick}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
</span>
) : (
label
)}
</button>
{!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
</span>
)}
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
till={till}
busy={busy}
onCancel={() => setConfirmingClose(false)}
onConfirm={async () => {
await act("close");
setConfirmingClose(false);
}}
/>
)}
</div>
);
}
/** Confirm-before-close modal for the shift button. Fetches the till's live X-report so
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
* expected drawer before committing the irreversible Z-report. */
function CloseShiftConfirm({
till,
busy,
onCancel,
onConfirm,
}: {
till: TillId;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm", till], queryFn: () => fetchShiftReport(till) });
const x = q.data;
const cur = x?.currency ?? null;
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
return (
<Modal open onClose={onCancel} title={till === "booth" ? t("shift.endShift") : t("shift.tillClose", { till: t(`till.${till}`) })} width="max-w-md">
<div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
) : (
<>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
{/* Split by source — only meaningful on the booth (a wash till has no
tickets or subscriptions; its takings are the bay payments). */}
{till === "booth" && (
<>
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */}
<span />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</>
)}
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
)}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.ending")}
</span>
) : (
t("shift.endShift")
)}
</button>
</div>
</div>
</Modal>
);
}
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
>
{label}
</span>
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div>
);
}
+86 -37
View File
@@ -4,13 +4,14 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
closeShift,
fetchEvents,
fetchShift,
fetchShiftReport,
fetchShiftTills,
fetchShifts,
openShift,
type ShiftReport,
type ShiftSummary,
type SessionUser,
type TillId,
} from "./api.js";
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
@@ -24,7 +25,9 @@ import type { LedgerEvent } from "@parking/shared";
// selected shift's signed activity log (every ledger event in its window). The current
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
// an admin (shift:cash) sees all. See wiki/concepts/shift.md.
// an admin (shift:cash) sees all. TILLS: a shift belongs to a till (booth / wash desk);
// every open shift (one per till) lists on top, cards carry a till badge when the site
// has more than one, and the list can be filtered by till. See wiki/concepts/shift.md.
function money(minor: number, currency: string | null): string {
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
@@ -47,28 +50,35 @@ function presetRange(p: Preset): { from: string; to: string } | null {
return { from: iso(from), to: iso(now) };
}
/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed
* shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when
* no shift is open (or not visible to the requester). */
function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } {
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
const report = useQuery({
queryKey: ["shift", "xreport"],
queryFn: fetchShiftReport,
enabled: status.data?.open != null,
type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
/** The CURRENT (open) shifts — one per till at most — each synthesized from its till's
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
* marks it for the badge + the action pane. Also returns every till the site has, so
* the hub can offer "start shift" per till and show badges only when there are two. */
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch: () => void } {
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
// One X-report per open till (the key carries the till list so a newly opened
// shift refetches).
const reports = useQuery({
queryKey: ["shift", "xreport", "hub", openTills.map((t) => t.till).join(",")],
queryFn: async () => Promise.all(openTills.map((t) => fetchShiftReport(t.till))),
enabled: openTills.length > 0,
});
const refetch = () => {
void status.refetch();
void report.refetch();
void reports.refetch();
};
if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch };
const x = report.data;
return {
isMine: status.data.isMine,
refetch,
current: {
id: "__current__",
const tills = status.data?.tills.map((t) => t.till) ?? ["booth"];
const current: CurrentShift[] = [];
openTills.forEach((t, i) => {
const x = reports.data?.[i];
if (!x) return;
current.push({
id: `__current__${t.till}`,
index: Number.MAX_SAFE_INTEGER,
till: x.till,
operator: x.operator,
startedAt: x.startedAt,
endedAt: x.asOf,
@@ -85,8 +95,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
cashRemovedMinor: x.cashRemovedMinor,
expectedDrawerMinor: x.expectedDrawerMinor,
open: true,
},
};
isMine: t.isMine,
});
});
return { current, tills, refetch };
}
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
@@ -96,14 +108,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [tillFilter, setTillFilter] = useState<TillId | "">("");
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
const { current, tills, refetch: refetchCurrent } = useCurrentShifts();
const multiTill = tills.length > 1;
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
const applied = {
operator: operator.trim() || undefined,
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
till: tillFilter || undefined,
};
// keepPreviousData: every filter change makes a NEW query key; without it the
@@ -118,16 +133,23 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
const closed = q.data?.shifts ?? [];
const operators = q.data?.operators ?? [];
// The current/open shift sits at the TOP of the list (when present + visible to me).
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
// The current/open shifts sit at the TOP of the list (those visible to me: mine, or
// all for an admin), honouring the till filter.
const visibleCurrent = current.filter((c) => (c.isMine || isAdmin) && (!tillFilter || c.till === tillFilter));
const list: (ShiftSummary & { open?: boolean; isMine?: boolean })[] = [...visibleCurrent, ...closed];
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
const currentIds = visibleCurrent.map((c) => c.id).join(",");
// Default the selection to the current shift (if any), else the newest closed one.
useEffect(() => {
if (list.length === 0) setSelectedId(null);
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.data, current?.id]);
}, [q.data, currentIds]);
// Tills with no open shift → offer "start" for each (gated on shift:create).
const openOn = new Set(current.map((c) => c.till));
const startable = tills.filter((x) => !openOn.has(x));
function refreshAll() {
void q.refetch();
@@ -145,9 +167,13 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
</h1>
{/* No shift open → the only action is to start one (gated on shift:create). */}
{canManage && !current && (
<StartShiftButton onDone={refreshAll} />
{/* A till with no open shift → the action is to start one (gated on shift:create). */}
{canManage && startable.length > 0 && (
<span className="flex flex-wrap items-center gap-2">
{startable.map((x) => (
<StartShiftButton key={x} till={x} named={multiTill} onDone={refreshAll} />
))}
</span>
)}
</div>
@@ -175,6 +201,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
</div>
</>
)}
{multiTill && (
<div className="field">
<select className="input w-40" value={tillFilter} onChange={(e) => setTillFilter(e.target.value as TillId | "")}>
<option value="">{t("till.all")}</option>
{tills.map((x) => (
<option key={x} value={x}>{t(`till.${x}Long`)}</option>
))}
</select>
</div>
)}
{isAdmin && (
<div className="field">
{/* <span className="label">{t("shifts.operator")}</span> */}
@@ -202,7 +238,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
)}
{list.map((s) => (
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
<ShiftCard key={s.id} s={s} showOperator={isAdmin} showTill={multiTill} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
))}
</div>
@@ -211,8 +247,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<ShiftActivityLog
shift={selected}
isCurrent={!!selected.open}
isMine={isMine}
isMine={!!selected.isMine}
showOperator={isAdmin}
showTill={multiTill}
canManage={canManage}
onChanged={refreshAll}
/>
@@ -225,7 +262,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
);
}
function StartShiftButton({ onDone }: { onDone: () => void }) {
function StartShiftButton({ till, named, onDone }: { till: TillId; named: boolean; onDone: () => void }) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -233,7 +270,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
setBusy(true);
setErr(null);
try {
await openShift();
await openShift(till);
onDone();
} catch (e) {
setErr((e as Error).message);
@@ -249,6 +286,8 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.starting")}
</span>
) : named ? (
t("shift.tillOpen", { till: t(`till.${till}`) })
) : (
t("shift.startShift")
)}
@@ -257,7 +296,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
);
}
function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) {
/** Which drawer a shift reconciled — shown only when the site has more than one. */
function TillBadge({ till }: { till: TillId }) {
const { t } = useTranslation();
return <span className="rounded border border-term-cyan/60 px-1 text-[0.625rem] uppercase tracking-wider text-term-cyan">{t(`till.${till}`)}</span>;
}
function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) {
const { t } = useTranslation();
const cur = s.currency;
const when = (iso: string) => formatRelativeDateTime(iso, t);
@@ -270,6 +315,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-semibold text-term-text">
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showTill && <TillBadge till={s.till} />}
{showOperator ? s.operator : when(s.startedAt)}
</span>
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
@@ -290,6 +336,7 @@ function ShiftActivityLog({
isCurrent,
isMine,
showOperator,
showTill,
canManage,
onChanged,
}: {
@@ -297,6 +344,7 @@ function ShiftActivityLog({
isCurrent: boolean;
isMine: boolean;
showOperator: boolean;
showTill: boolean;
canManage: boolean;
onChanged: () => void;
}) {
@@ -322,6 +370,7 @@ function ShiftActivityLog({
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
<span className="flex items-center gap-2 font-semibold text-term-text">
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showTill && <TillBadge till={shift.till} />}
{showOperator && `${shift.operator} · `}
{formatRelativeDateTime(shift.startedAt, t)}
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
@@ -358,7 +407,7 @@ function ShiftActivityLog({
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
{modal === "takings" && <TakingsModal till={shift.till} onClose={() => setModal(null)} />}
</div>
);
}
@@ -376,7 +425,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
setBusy(true);
setErr(null);
try {
setReport(await closeShift());
setReport(await closeShift(shift.till));
onDone();
} catch (e) {
setErr((e as Error).message);
@@ -447,9 +496,9 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
);
}
function TakingsModal({ onClose }: { onClose: () => void }) {
function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) });
const x = q.data;
return (
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
+41 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { MERCHANT_VALIDATION_MODES } from "@parking/shared";
import {
fetchUsers,
saveValidationProgram,
@@ -30,7 +31,7 @@ export function stationLabelKey(id: StationId): string {
}
/** A blank program draft for a station enabled for the first time. */
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
return {
name: label,
mode: "comp",
@@ -56,13 +57,36 @@ const toInt = (s: string): number | null => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
/** Like toInt but 0 is valid (a tolerance of "not a minute more"). */
const toNonNeg = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isInteger(n) && n >= 0 ? n : null;
};
const MODE_LABEL_KEY: Record<ValidationMode, string> = {
comp: "val.modeComp",
timeCredit: "val.modeTimeCredit",
fixed: "val.modeFixed",
percent: "val.modePercent",
doneTolerance: "val.modeDoneTolerance",
washPrice: "val.modeWashPrice",
};
function StationForm({
/** One validation program's editor. Also reused by the Car Wash module for its
* sponsorship program (`hideUsers`: that program is applied by the wash flow, not by
* bound merchant users). */
export function StationForm({
program,
onSaved,
hideUsers = false,
modes = MERCHANT_VALIDATION_MODES,
}: {
program: ValidationProgramView;
onSaved: (p: ValidationProgramView) => void;
hideUsers?: boolean;
/** Which discount modes to offer (merchant stations vs the car wash differ). */
modes?: readonly ValidationMode[];
}) {
const { t } = useTranslation();
const [name, setName] = useState(program.name);
@@ -96,6 +120,7 @@ function StationForm({
const valid = useMemo(() => {
if (!name.trim()) return false;
if (mode === "timeCredit") return toInt(minutes) != null;
if (mode === "doneTolerance") return toNonNeg(minutes) != null;
if (mode === "percent") {
const p = toInt(percent);
return p != null && p <= 100;
@@ -110,7 +135,7 @@ function StationForm({
const saved = await saveValidationProgram(program.id, {
name: name.trim(),
mode,
minutes: mode === "timeCredit" ? toInt(minutes) : null,
minutes: mode === "timeCredit" ? toInt(minutes) : mode === "doneTolerance" ? toNonNeg(minutes) : null,
percent: mode === "percent" ? toInt(percent) : null,
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
maxPerDay: toInt(maxPerDay),
@@ -140,11 +165,12 @@ function StationForm({
<div className="field">
<span className="label">{t("val.mode")}</span>
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
<option value="comp">{t("val.modeComp")}</option>
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
<option value="fixed">{t("val.modeFixed")}</option>
<option value="percent">{t("val.modePercent")}</option>
{modes.map((m) => (
<option key={m} value={m}>{t(MODE_LABEL_KEY[m])}</option>
))}
</select>
{mode === "doneTolerance" && <span className="hint">{t("val.modeDoneToleranceHint")}</span>}
{mode === "washPrice" && <span className="hint">{t("val.modeWashPriceHint")}</span>}
</div>
{mode === "timeCredit" && (
<div className="field">
@@ -152,6 +178,12 @@ function StationForm({
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
</div>
)}
{mode === "doneTolerance" && (
<div className="field">
<span className="label">{t("val.toleranceMinutes")}</span>
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="15" />
</div>
)}
{mode === "percent" && (
<div className="field">
<span className="label">{t("val.percent")}</span>
@@ -168,6 +200,7 @@ function StationForm({
<span className="label">{t("val.maxPerDay")}</span>
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
</div>
{!hideUsers && (
<div>
<div className="label">{t("val.users")}</div>
<span className="hint block">{t("val.usersHint")}</span>
@@ -192,6 +225,7 @@ function StationForm({
)}
</div>
</div>
)}
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
{t("site.save")}
+58 -22
View File
@@ -16,7 +16,7 @@ import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"
import { logFailedRequest } from "./lib/logger.js";
import { apiUrl, platformFetch } from "./lib/origin.js";
import { inTauri } from "./lib/tauri-env.js";
import type { AppLogRecord, ModuleId, ValidationLine, ValidationMode } from "@parking/shared";
import type { AppLogRecord, ChargeLine, ModuleId, TillId, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token";
@@ -1062,18 +1062,30 @@ export function deleteSubscription(id: string): Promise<void> {
}
// --- Shifts ---------------------------------------------------------------
// A shift is opened ON A TILL (booth | carwash …): one open shift per till, each with
// its own drawer and Z-report. Every call below takes the till, defaulting to the
// booth. See wiki/concepts/shift.md "Tills".
export interface ShiftStatus {
/** The requesting (logged-in) operator. */
operator: string;
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
export type { TillId };
/** One till's shift state (at most one shift open per till). */
export interface TillShiftStatus {
till: TillId;
/** The till's 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 of this till (cash payments + cash movements). */
drawerMinor: number;
currency: string | null;
}
export interface ShiftStatus extends TillShiftStatus {
/** The requesting (logged-in) operator. */
operator: string;
/** Every till addressable at this site (the booth + effective modules' tills). */
tills: TillId[];
}
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
@@ -1085,6 +1097,7 @@ export interface ShiftSourceSplit {
}
export interface ShiftReport extends ShiftSourceSplit {
till: TillId;
operator: string;
startedAt: string;
endedAt: string;
@@ -1100,20 +1113,27 @@ export interface ShiftReport extends ShiftSourceSplit {
printed: boolean;
}
export function fetchShift(): Promise<ShiftStatus> {
return apiFetch("/api/shift/current");
const tillQs = (till?: TillId) => (till && till !== "booth" ? `?till=${till}` : "");
export function fetchShift(till: TillId = "booth"): Promise<ShiftStatus> {
return apiFetch(`/api/shift/current${tillQs(till)}`);
}
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
return apiFetch("/api/shift/open", { method: "POST" });
/** Every till's shift state in one read (the shift hub lists each open shift). */
export function fetchShiftTills(): Promise<{ operator: string; tills: TillShiftStatus[] }> {
return apiFetch("/api/shift/tills");
}
export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" });
export function openShift(till: TillId = "booth"): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
return apiFetch("/api/shift/open", { method: "POST", body: JSON.stringify({ till }) });
}
export function closeShift(till: TillId = "booth"): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST", body: JSON.stringify({ till }) });
}
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
* event is appended). Same figures the Z-report will print at close. `asOf` is the
* snapshot instant. */
export interface XReport extends ShiftSourceSplit {
till: TillId;
operator: string;
startedAt: string;
endedAt: string; // = asOf
@@ -1129,8 +1149,8 @@ export interface XReport extends ShiftSourceSplit {
}
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
export async function fetchShiftReport(): Promise<XReport | null> {
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
export async function fetchShiftReport(till: TillId = "booth"): Promise<XReport | null> {
return (await apiFetch<XReport | undefined>(`/api/shift/report${tillQs(till)}`)) ?? null;
}
// --- Drawer cash movements (operator records, admin reviews) ---------------------
@@ -1144,6 +1164,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
export interface DrawerMovement {
id: string;
type: "cash_in" | "cash_out";
/** Which drawer the cash moved in/out of. */
till: TillId;
/** Positive magnitude; direction is the type. */
amountMinor: number;
currency: string | null;
@@ -1165,8 +1187,11 @@ export function recordDrawerMovement(args: {
amountMinor: number;
reason: string;
currency?: string;
/** Which drawer (default: the booth). */
till?: TillId;
}): Promise<{
type: "cash_in" | "cash_out";
till: TillId;
amountMinor: number;
voucherNo: string;
balanceMinor: number;
@@ -1177,18 +1202,21 @@ export function recordDrawerMovement(args: {
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
* and may filter by status (the pending review queue). */
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
export function fetchDrawerMovements(status?: MovementStatus, till?: TillId): Promise<{
movements: DrawerMovement[];
scope: "all" | "self";
}> {
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
return apiFetch(`/api/drawer/movements${qs}`);
const qs = new URLSearchParams();
if (status) qs.set("status", status);
if (till) qs.set("till", till);
const q = qs.toString();
return apiFetch(`/api/drawer/movements${q ? `?${q}` : ""}`);
}
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
* the amount that carries across shifts). */
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
return apiFetch("/api/drawer/balance");
/** A till's physical drawer balance NOW (cash payments + vouchers over the whole chain
* — the amount that carries across that till's shifts). */
export function fetchDrawerBalance(till: TillId = "booth"): Promise<{ till: TillId; balanceMinor: number; currency: string | null }> {
return apiFetch(`/api/drawer/balance${tillQs(till)}`);
}
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
@@ -1204,6 +1232,8 @@ export function reviewDrawerMovement(args: {
export interface ShiftSummary extends ShiftSourceSplit {
id: string;
index: number;
/** The till this shift reconciled. */
till: TillId;
operator: string;
startedAt: string;
endedAt: string;
@@ -1221,16 +1251,19 @@ export interface ShiftSummary extends ShiftSourceSplit {
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
* filtered by operator + a from/to window over the shift start. `scope` echoes
* which the server applied, so the UI can show/hide the filter. */
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
export function fetchShifts(params: { operator?: string; from?: string; to?: string; till?: TillId } = {}): Promise<{
shifts: ShiftSummary[];
scope: "all" | "self";
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
operators?: string[];
/** Every till addressable at this site — more than one → show the till filter/badges. */
tills: TillId[];
}> {
const qs = new URLSearchParams();
if (params.operator) qs.set("operator", params.operator);
if (params.from) qs.set("from", params.from);
if (params.to) qs.set("to", params.to);
if (params.till) qs.set("till", params.till);
const q = qs.toString();
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
}
@@ -1359,6 +1392,9 @@ export interface SessionLookup {
grossMinor: number | null;
discountMinor: number | null;
validationLines: ValidationLine[];
/** Module charges folded into `amountMinor` (e.g. a car wash paid at the booth). */
chargeLines: ChargeLine[];
chargesMinor: number | null;
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
+18 -6
View File
@@ -16,10 +16,24 @@ export function formatMoney(amountMinor: number, currency: string): string {
export function formatDuration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
return formatMinutesLong(Math.floor(ms / 60_000));
}
/** "Xy Xd Xh Xm" with the leading zero units dropped — a stay of 1797h reads as
* "74d 21h 23m", not a wall of hours (a stale/forgotten ticket is a real case on a
* booth; the number should still be readable at a glance). Years only past 365 days. */
export function formatMinutesLong(totalMinutes: number): string {
const mins = Math.max(0, Math.floor(totalMinutes));
const y = Math.floor(mins / (365 * 24 * 60));
const d = Math.floor((mins % (365 * 24 * 60)) / (24 * 60));
const h = Math.floor((mins % (24 * 60)) / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
const parts: string[] = [];
if (y > 0) parts.push(`${y}y`);
if (y > 0 || d > 0) parts.push(`${d}d`);
if (y > 0 || d > 0 || h > 0) parts.push(`${h}h`);
parts.push(`${m}m`);
return parts.join(" ");
}
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
@@ -41,9 +55,7 @@ export function formatCountdown(untilIso: string | null, nowMs: number = Date.no
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
export function formatMinutes(mins: number): string {
if (!Number.isFinite(mins) || mins < 0) return "—";
const m = Math.round(mins);
const h = Math.floor(m / 60);
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
return formatMinutesLong(Math.round(mins));
}
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
+81
View File
@@ -64,13 +64,74 @@ export const en: Catalog = {
name: {
parking: "Parking",
validation: "Merchant validations (Bar)",
carwash: "Car wash",
},
},
wash: {
tillTitle: "Wash till",
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
tillOtherHint: "{{operator}} holds the wash shift; only they can take money at the bay.",
drawerNow: "Wash drawer now",
intake: "New wash",
ticketPh: "Parking ticket (scan or type)",
lookup: "Look up",
notFound: "No session for this ticket.",
closed: "This session is already closed.",
ticket: "Ticket",
plate: "Plate",
enteredAt: "Entered",
alreadyOpen: "This ticket already has an open wash order.",
category: "Vehicle category",
service: "Service",
price: "Price",
noPrice: "no price set for this pair",
payAt: "Payment",
payAtBooth: "At the booth",
payAtBay: "At the bay",
payAtBoothHint: "Added to the parking settlement; the exit barrier opens after the booth payment.",
payAtBayHint: "You take the money here; the customer leaves by scanning the ticket at the exit reader (the parking sponsorship must cover the fee).",
create: "Create order",
created: "Order created.",
queue: "Open orders",
empty: "Nothing to wash.",
time: "Time",
what: "Wash",
status: "Status",
statusOpen: "in progress",
statusDone: "done",
paid: "paid",
unpaid: "unpaid",
done: "Done",
payCash: "Paid cash",
payCard: "Paid card",
void: "Void",
voidReason: "Reason",
categories: "Vehicle categories",
services: "Services",
prices: "Prices",
pricesHint: "One price per category × service. Leave a cell blank to make that pair unsellable.",
addCategory: "category",
addService: "service",
active: "active",
save: "Save",
saved: "Saved.",
finished: "Finished",
finishedEmpty: "No finished washes yet.",
voided: "voided",
by: "By",
cash: "cash",
card: "card",
sponsorship: "Parking discount",
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
sponsorshipLabel: "Car wash",
},
update: {
available: "Update available",
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
},
nav: {
wash: "Car wash",
carwash: "Car wash",
booth: "Booth",
shift: "Shift",
setup: "Setup",
@@ -256,6 +317,9 @@ export const en: Catalog = {
evtCashReview: "REVIEW",
evtConfigChange: "CONFIG",
evtValidation: "VALIDATION",
evtCarwashOrder: "CAR WASH",
evtCarwashPayment: "WASH PAYMENT",
charges: "Extra charges",
decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
@@ -775,6 +839,11 @@ export const en: Catalog = {
modeTimeCredit: "First minutes free",
modeFixed: "Amount off (typed at scan)",
modePercent: "Percent off",
modeDoneTolerance: "Free while the wash runs (+ tolerance)",
modeDoneToleranceHint: "The time from the wash order to \"done\", plus the tolerance minutes, comes off the parking. Time parked before the order and after the tolerance is charged at the tariff.",
modeWashPrice: "Wash price off the parking fee",
modeWashPriceHint: "The parking fee minus the wash price; never below zero.",
toleranceMinutes: "Tolerance after done (minutes)",
minutes: "Free minutes",
percent: "Percent (%)",
maxAmount: "Cap per validation",
@@ -903,6 +972,18 @@ export const en: Catalog = {
"{{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…",
// Tills — a shift belongs to a drawer (booth / wash desk), not the site.
tillOpen: "Open {{till}} shift",
tillClose: "Close {{till}} shift",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "No {{till}} shift",
},
till: {
booth: "Booth",
carwash: "Wash",
boothLong: "Booth till",
carwashLong: "Wash till",
all: "All tills",
},
shifts: {
title: "Shift history",
+81
View File
@@ -67,13 +67,74 @@ export const sq = {
name: {
parking: "Parkimi",
validation: "Validime tregtare (Bar)",
carwash: "Lavazh",
},
},
wash: {
tillTitle: "Arka e lavazhit",
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
tillOtherHint: "{{operator}} e ka turnin e lavazhit; vetëm ai mund të marrë para te lavazhi.",
drawerNow: "Arka e lavazhit tani",
intake: "Lavazh i ri",
ticketPh: "Bileta e parkimit (skano ose shkruaj)",
lookup: "Kërko",
notFound: "Nuk ka sesion për këtë biletë.",
closed: "Ky sesion është mbyllur.",
ticket: "Bileta",
plate: "Targa",
enteredAt: "Hyri",
alreadyOpen: "Kjo biletë ka tashmë një porosi lavazhi të hapur.",
category: "Kategoria e mjetit",
service: "Shërbimi",
price: "Çmimi",
noPrice: "nuk ka çmim për këtë kombinim",
payAt: "Pagesa",
payAtBooth: "Në kabinë",
payAtBay: "Në lavazh",
payAtBoothHint: "Shtohet në llogarinë e parkimit; barriera e daljes hapet pas pagesës në kabinë.",
payAtBayHint: "Paratë merren këtu; klienti del duke skanuar biletën te lexuesi i daljes (sponsorizimi i parkimit duhet ta mbulojë tarifën).",
create: "Krijo porosinë",
created: "Porosia u krijua.",
queue: "Porositë e hapura",
empty: "Asgjë për të larë.",
time: "Ora",
what: "Lavazhi",
status: "Statusi",
statusOpen: "në proces",
statusDone: "mbaroi",
paid: "paguar",
unpaid: "papaguar",
done: "Mbaroi",
payCash: "Paguar cash",
payCard: "Paguar me kartë",
void: "Anulo",
voidReason: "Arsyeja",
categories: "Kategoritë e mjeteve",
services: "Shërbimet",
prices: "Çmimet",
pricesHint: "Një çmim për çdo kategori × shërbim. Lëre bosh një qelizë që ai kombinim të mos shitet.",
addCategory: "kategori",
addService: "shërbim",
active: "aktiv",
save: "Ruaj",
saved: "U ruajt.",
finished: "Të mbaruara",
finishedEmpty: "Ende asnjë lavazh i mbaruar.",
voided: "anuluar",
by: "Nga",
cash: "cash",
card: "kartë",
sponsorship: "Zbritje parkimi",
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
sponsorshipLabel: "Lavazh",
},
update: {
available: "Përditësim i disponueshëm",
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)",
},
nav: {
wash: "Lavazh",
carwash: "Lavazh",
booth: "Kabina",
shift: "Turni",
setup: "Konfigurimi",
@@ -261,6 +322,9 @@ export const sq = {
evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG",
evtValidation: "VALIDIM",
evtCarwashOrder: "LAVAZH",
evtCarwashPayment: "PAGESË LAVAZHI",
charges: "Shtesa",
decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
@@ -788,6 +852,11 @@ export const sq = {
modeTimeCredit: "Minutat e para falas",
modeFixed: "Zbritje shume (shkruhet në skanim)",
modePercent: "Zbritje në përqindje",
modeDoneTolerance: "Falas gjatë lavazhit (+ tolerancë)",
modeDoneToleranceHint: "Koha nga porosia e lavazhit deri te \"mbaroi\", plus minutat e tolerancës, zbritet nga parkimi. Koha e parkuar para porosisë dhe pas tolerancës paguhet sipas tarifës.",
modeWashPrice: "Çmimi i lavazhit zbritet nga parkimi",
modeWashPriceHint: "Tarifa e parkimit minus çmimin e lavazhit; asnjëherë nën zero.",
toleranceMinutes: "Toleranca pas mbarimit (minuta)",
minutes: "Minuta falas",
percent: "Përqindja (%)",
maxAmount: "Tavani i zbritjes për validim",
@@ -917,6 +986,18 @@ export const sq = {
"{{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…",
// Arkat — turni i përket një arke (kabina / lavazhi), jo gjithë sitit.
tillOpen: "Hap turnin e {{till}}",
tillClose: "Mbyll turnin e {{till}}",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "Pa turn {{till}}",
},
till: {
booth: "kabinës",
carwash: "lavazhit",
boothLong: "Arka e kabinës",
carwashLong: "Arka e lavazhit",
all: "Të gjitha arkat",
},
shifts: {
title: "Historiku i turneve",
+8
View File
@@ -31,7 +31,15 @@ export interface WebModule {
id: ModuleId;
/** Header nav entries, in display order. */
nav: readonly WebModuleNav[];
/** Where a user whose role has NO booth (`session:read`) lands after login, if the
* module is on and the role holds `perm` — e.g. the wash desk for a wash operator,
* the scan screen for a merchant. First match in WEB_MODULES order wins. */
landing?: WebModuleNav;
/** Build this module's routes under the given root. Called once at router
* assembly; each route's own beforeLoad must gate on moduleOn + permission. */
routes(root: RootRoute): AnyRoute[];
/** Setup tabs (under /setup), if the module has admin configuration. */
setupNav?: readonly WebModuleNav[];
/** Build this module's routes under the /setup layout route. */
setupRoutes?(setup: AnyRoute): AnyRoute[];
}
+2 -1
View File
@@ -99,7 +99,8 @@ export function useLiveFeed(enabled: boolean = true): void {
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement" ||
msg.event.type === "cash_in" ||
msg.event.type === "cash_out"
msg.event.type === "cash_out" ||
msg.event.type === "carwash_payment"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
+12 -7
View File
@@ -1,18 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { fetchShift, type ShiftStatus } from "../api.js";
import { fetchShift, type ShiftStatus, type TillId } 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.
// per-TILL single-open accountability period (at most one open per till). The
// default till is the booth; the wash desk reads its own (`useShift("carwash")`).
// The WS invalidates qk.shift (a prefix, so every till) on shift_open/shift_z_report/
// cash movements, 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? */
/** Is a shift open on this till? */
isOpen: boolean;
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
isMine: boolean;
@@ -25,8 +26,12 @@ export interface ShiftState {
isLoading: boolean;
}
export function useShift(): ShiftState {
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
/** Query key of one till's shift status — under the qk.shift prefix so the WS
* invalidation reaches every till. */
export const shiftKey = (till: TillId) => [...qk.shift, "current", till] as const;
export function useShift(till: TillId = "booth"): ShiftState {
const q = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
const s = q.data;
const isOpen = s?.open != null;
const isMine = s?.isMine ?? false;
@@ -0,0 +1,239 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared";
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
// Setup → Car wash: the master data (vehicle categories, services, the category ×
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
// validation program (id "carwash"), composed with the same editor the merchant
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
type Item = { id?: string; name: string; active: boolean };
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
const toMinor = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
};
function ListEditor({
title,
items,
onChange,
addLabel,
}: {
title: string;
items: Item[];
onChange: (items: Item[]) => void;
addLabel: string;
}) {
const { t } = useTranslation();
return (
<div className="grid gap-1.5">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
{items.map((it, i) => (
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2">
<input
className="input flex-1"
value={it.name}
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
/>
<label className="flex items-center gap-1 text-[0.75rem] text-term-muted">
<input
type="checkbox"
className="accent-term-amber"
checked={it.active}
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, active: e.target.checked } : x)))}
/>
{t("wash.active")}
</label>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onChange(items.filter((_, j) => j !== i))}>
✕
</button>
</div>
))}
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
+ {addLabel}
</button>
</div>
);
}
export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
const [categories, setCategories] = useState<Item[]>([]);
const [services, setServices] = useState<Item[]>([]);
/** Price inputs keyed "categoryId|serviceId" (major units as typed). New rows have no
* id yet, so the matrix keys use the row INDEX until saved. */
const [prices, setPrices] = useState<Record<string, string>>({});
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
const [msg, setMsg] = useState<string | null>(null);
const [program, setProgram] = useState<ValidationProgramView | null>(null);
function load() {
fetchCarwashSettings()
.then((s) => {
setSettings(s);
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {};
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p);
setPayAt(s.payAt);
})
.catch((e) => setMsg((e as Error).message));
fetchValidationPrograms()
.then((r) => {
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
setProgram(existing ?? { id: CARWASH_PROGRAM_ID, ...defaultProgram(CARWASH_PROGRAM_ID, t("wash.sponsorshipLabel")) });
})
.catch(() => {});
}
useEffect(load, []); // eslint-disable-line react-hooks/exhaustive-deps
const keyOf = (c: Item, ci: number, s: Item, si: number) => `${c.id ?? `#${ci}`}|${s.id ?? `#${si}`}`;
async function save() {
setMsg(null);
try {
const listBody = {
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
};
// New rows have no id until the server assigns one, and the price matrix is keyed
// by ids — so save the lists first, map each new row to the id that came back (the
// server returns rows in the order sent), then save the prices in a second call.
// One button, two requests; the user just sees "Saved."
let cats = categories;
let svcs = services;
if (categories.some((c) => !c.id) || services.some((s) => !s.id)) {
// Keep only the prices whose rows survive this save (a removed row's prices
// would be refused as unknown ids).
const keepC = new Set(categories.map((c) => c.id).filter(Boolean));
const keepS = new Set(services.map((s) => s.id).filter(Boolean));
const first = await saveCarwashSettings({
...listBody,
prices: (settings?.prices ?? []).filter((p) => keepC.has(p.categoryId) && keepS.has(p.serviceId)),
});
cats = categories.map((c, i) => ({ ...c, id: c.id ?? first.categories[i]?.id }));
svcs = services.map((s, i) => ({ ...s, id: s.id ?? first.services[i]?.id }));
}
const priceRows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
categories.forEach((c, ci) =>
services.forEach((s, si) => {
const v = toMinor(prices[keyOf(c, ci, s, si)] ?? "");
const cid = cats[ci]?.id;
const sid = svcs[si]?.id;
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
}),
);
const saved = await saveCarwashSettings({
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
prices: priceRows,
payAt,
});
setSettings(saved);
setPayAt(saved.payAt);
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {};
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p);
setMsg(t("wash.saved"));
} catch (e) {
setMsg((e as Error).message);
}
}
const currency = settings?.currency ?? "";
return (
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-2xl p-4">
<div className="grid gap-4">
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} />
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
<div>
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("wash.prices")} {currency && <span className="normal-case tracking-normal">({currency})</span>}
</div>
<span className="hint">{t("wash.pricesHint")}</span>
{categories.length > 0 && services.length > 0 && (
<div className="mt-2 overflow-x-auto">
<table className="text-[0.75rem]">
<thead>
<tr>
<th className="py-1 pr-3 text-left text-term-muted"></th>
{services.map((s, si) => (
<th key={s.id ?? `#${si}`} className="py-1 pr-3 text-left">{s.name || "…"}</th>
))}
</tr>
</thead>
<tbody>
{categories.map((c, ci) => (
<tr key={c.id ?? `#${ci}`}>
<td className="py-1 pr-3 font-semibold">{c.name || "…"}</td>
{services.map((s, si) => {
const k = keyOf(c, ci, s, si);
return (
<td key={k} className="py-1 pr-3">
<input
className="input w-24 text-right tabular-nums"
value={prices[k] ?? ""}
disabled={!canEdit}
onChange={(e) => setPrices((p) => ({ ...p, [k]: e.target.value }))}
placeholder="—"
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="field">
<span className="label">{t("wash.payAt")}</span>
<div className="flex gap-4 text-[0.75rem]">
{CARWASH_PAY_AT.map((v) => (
<label key={v} className="flex items-center gap-1.5">
<input type="radio" name="carwash-payAt" className="accent-term-amber" checked={payAt === v} disabled={!canEdit} onChange={() => setPayAt(v)} />
{t(v === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}
</label>
))}
</div>
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
</div>
{canEdit && (
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("wash.save")}</button>
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div>
)}
</div>
</section>
{canEdit && program && (
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
<span className="hint">{t("wash.sponsorshipHint")}</span>
<div className="mt-2">
<StationForm program={program} onSaved={setProgram} hideUsers modes={CARWASH_VALIDATION_MODES} />
</div>
</section>
)}
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Tender } from "@parking/shared";
import { formatMoney } from "../../lib/format.js";
import { useShift } from "../../lib/use-shift.js";
import { ShiftButton } from "../../ShiftControl.js";
import {
createCarwashOrder,
fetchCarwashOrders,
fetchCarwashSettings,
lookupCarwashTicket,
markCarwashDone,
payCarwashAtBay,
voidCarwashOrder,
type CarwashOrderView,
type CarwashSettingsView,
type CarwashTicketLookup,
} from "./api.js";
// The wash desk (/wash): intake a wash against a parking ticket (category × service →
// price, where the money is taken), then work the queue — a plain list of open orders,
// oldest first: Done / Pay at bay / Void. Bay money lands on the WASH TILL: the desk
// carries that till's own shift control, and the pay buttons are gated on the wash
// operator's shift (the booth's shift does not cover the bay — the two drawers
// reconcile separately). See wiki/decisions/venue-modules.md + shift.md "Tills".
const QK = ["carwash", "orders"] as const;
function timeOf(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function WashDesk() {
const { t } = useTranslation();
const qc = useQueryClient();
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
const [ticket, setTicket] = useState("");
const [lookup, setLookup] = useState<CarwashTicketLookup | null>(null);
const [categoryId, setCategoryId] = useState("");
const [serviceId, setServiceId] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [voiding, setVoiding] = useState<{ id: string; reason: string } | null>(null);
useEffect(() => {
fetchCarwashSettings().then(setSettings).catch((e) => setMsg((e as Error).message));
}, []);
const orders = useQuery({
queryKey: QK,
queryFn: () => fetchCarwashOrders("open"),
refetchInterval: 5000,
});
// Finished washes (done + paid, or voided) — the most recent ones, newest first, so
// the desk can answer "did we wash that car?" without leaving the screen.
const finished = useQuery({
queryKey: [...QK, "recent"],
queryFn: () => fetchCarwashOrders("recent"),
refetchInterval: 15000,
select: (r) => r.orders.filter((o) => o.closed).slice(0, 50),
});
const categories = useMemo(() => (settings?.categories ?? []).filter((c) => c.active), [settings]);
const services = useMemo(() => (settings?.services ?? []).filter((s) => s.active), [settings]);
const price = useMemo(
() => settings?.prices.find((p) => p.categoryId === categoryId && p.serviceId === serviceId) ?? null,
[settings, categoryId, serviceId],
);
const currency = settings?.currency ?? lookup?.currency ?? null;
async function doLookup(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (!ticket.trim()) return;
try {
setLookup(await lookupCarwashTicket(ticket));
} catch (err) {
setMsg((err as Error).message);
}
}
const invalidate = () => qc.invalidateQueries({ queryKey: QK }); // also matches [...QK, "recent"]
const create = useMutation({
mutationFn: () =>
createCarwashOrder({ identity: lookup!.identity, categoryId, serviceId }),
onSuccess: () => {
setMsg(t("wash.created"));
setLookup(null);
setTicket("");
void invalidate();
},
onError: (e) => setMsg((e as Error).message),
});
const done = useMutation({
mutationFn: (id: string) => markCarwashDone(id),
onSuccess: () => void invalidate(),
onError: (e) => setMsg((e as Error).message),
});
const pay = useMutation({
mutationFn: ({ id, tender }: { id: string; tender: Tender }) => payCarwashAtBay(id, tender),
onSuccess: () => void invalidate(),
onError: (e) => setMsg((e as Error).message),
});
const voidIt = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) => voidCarwashOrder(id, reason),
onSuccess: () => {
setVoiding(null);
void invalidate();
},
onError: (e) => setMsg((e as Error).message),
});
const canCreate =
lookup?.found && lookup.open && !!categoryId && !!serviceId && price != null && !create.isPending;
// The wash till's shift: money at the bay is only takeable while MY wash shift is open.
const washShift = useShift("carwash");
const canTakeMoney = washShift.isMine;
return (
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="flex w-full flex-wrap items-center gap-3 rounded-term border border-term-border bg-term-panel-2 px-4 py-2">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.tillTitle")}</span>
<ShiftButton till="carwash" />
{washShift.status && (
<span className="text-[0.75rem] tabular-nums text-term-muted">
{t("wash.drawerNow")}{" "}
<span className="font-semibold text-term-text">
{formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")}
</span>
</span>
)}
<span className="basis-full text-[0.6875rem] text-term-muted">
{washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")}
</span>
</section>
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.intake")}</div>
<form onSubmit={doLookup} className="mt-3 flex gap-2">
<input
className="input flex-1"
value={ticket}
onChange={(e) => {
setTicket(e.target.value);
setLookup(null);
}}
placeholder={t("wash.ticketPh")}
autoFocus
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button type="submit" className="btn btn-sm" disabled={!ticket.trim()}>
{t("wash.lookup")}
</button>
</form>
{lookup && !lookup.found && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.notFound")}</p>}
{lookup?.found && !lookup.open && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.closed")}</p>}
{lookup?.found && lookup.open && (
<div className="mt-3 grid gap-3">
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.ticket")}</span>
<span className="font-mono">{lookup.identity}</span>
</div>
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.plate")}</span>
<span className="font-mono">{lookup.plate ?? "—"}</span>
</div>
{lookup.enteredAt && (
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.enteredAt")}</span>
<span className="tabular-nums">{timeOf(lookup.enteredAt)}</span>
</div>
)}
{lookup.orders.filter((o) => !o.closed).length > 0 && (
<div className="mt-1 text-term-amber">{t("wash.alreadyOpen")}</div>
)}
</div>
<div className="field">
<span className="label">{t("wash.category")}</span>
<select className="select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
<option value="">—</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="field">
<span className="label">{t("wash.service")}</span>
<select className="select" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
<option value="">—</option>
{services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="flex items-center justify-between text-[0.8125rem]">
<span className="text-term-muted">{t("wash.price")}</span>
<span className="font-semibold tabular-nums">
{price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"}
</span>
</div>
{/* Where the money is taken is the SITE's setting (Setup → Car wash), shown
here so the operator knows what this order will do — never chosen per order. */}
<div className="flex items-center justify-between text-[0.75rem]">
<span className="text-term-muted">{t("wash.payAt")}</span>
<span>{settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"}</span>
</div>
<button type="button" className="btn btn-primary btn-sm" disabled={!canCreate} onClick={() => create.mutate()}>
{t("wash.create")}
</button>
</div>
)}
{msg && <p className="mt-3 text-[0.75rem] text-term-muted">{msg}</p>}
</section>
<section className="card w-full max-w-3xl p-4">
<div className="flex items-center justify-between">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.queue")}</div>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void orders.refetch()}>↻</button>
</div>
{(orders.data?.orders ?? []).length === 0 ? (
<p className="mt-3 text-[0.75rem] text-term-muted">{t("wash.empty")}</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="py-1 pr-3">{t("wash.time")}</th>
<th className="py-1 pr-3">{t("wash.ticket")}</th>
<th className="py-1 pr-3">{t("wash.plate")}</th>
<th className="py-1 pr-3">{t("wash.what")}</th>
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
<th className="py-1 pr-3">{t("wash.payAt")}</th>
<th className="py-1 pr-3">{t("wash.status")}</th>
<th className="py-1"></th>
</tr>
</thead>
<tbody>
{(orders.data?.orders ?? []).map((o: CarwashOrderView) => (
<tr key={o.id} className="border-t border-term-border/50">
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.createdAt)}</td>
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
<td className="py-1.5 pr-3">
<span className={o.status === "done" ? "text-term-green" : "text-term-amber"}>
{t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")}
</span>
<span className="text-term-muted"> · </span>
<span className={o.paidAt ? "text-term-green" : "text-term-muted"}>
{t(o.paidAt ? "wash.paid" : "wash.unpaid")}
</span>
</td>
<td className="py-1.5">
<div className="flex flex-wrap justify-end gap-1">
{o.status === "open" && (
<button type="button" className="btn btn-sm btn-primary" disabled={done.isPending} onClick={() => done.mutate(o.id)}>
{t("wash.done")}
</button>
)}
{o.payAt === "bay" && !o.paidAt && (
<>
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "cash" })}>
{t("wash.payCash")}
</button>
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "card" })}>
{t("wash.payCard")}
</button>
</>
)}
{!o.paidAt && (
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setVoiding({ id: o.id, reason: "" })}>
{t("wash.void")}
</button>
)}
</div>
{voiding?.id === o.id && (
<div className="mt-1 flex gap-1">
<input
className="input flex-1"
value={voiding.reason}
placeholder={t("wash.voidReason")}
onChange={(e) => setVoiding({ id: o.id, reason: e.target.value })}
/>
<button type="button" className="btn btn-sm btn-danger" disabled={voidIt.isPending} onClick={() => voidIt.mutate(voiding)}>
{t("wash.void")}
</button>
<button type="button" className="btn btn-sm" onClick={() => setVoiding(null)}>
{t("subs.cancel")}
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-6 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.finished")}</div>
{(finished.data ?? []).length === 0 ? (
<p className="mt-2 text-[0.75rem] text-term-muted">{t("wash.finishedEmpty")}</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="py-1 pr-3">{t("wash.time")}</th>
<th className="py-1 pr-3">{t("wash.ticket")}</th>
<th className="py-1 pr-3">{t("wash.plate")}</th>
<th className="py-1 pr-3">{t("wash.what")}</th>
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
<th className="py-1 pr-3">{t("wash.payAt")}</th>
<th className="py-1 pr-3">{t("wash.status")}</th>
<th className="py-1">{t("wash.by")}</th>
</tr>
</thead>
<tbody className="text-term-muted">
{(finished.data ?? []).map((o: CarwashOrderView) => (
<tr key={o.id} className="border-t border-term-border/50">
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}</td>
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
<td className="py-1.5 pr-3">
{o.status === "void" ? (
<span className="text-term-red">{t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""}</span>
) : (
<span className="text-term-green">
{t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""}
</span>
)}
</td>
<td className="py-1.5">{o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared";
import { apiFetch } from "../../api.js";
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
// client) never learns about wash endpoints. Shapes come from @parking/shared.
export type { CarWashPayAt, CarwashOrderView, CarwashSettingsView };
export interface CarwashTicketLookup {
identity: string;
found: boolean;
open: boolean;
subscription: boolean;
plate: string | null;
enteredAt: string | null;
currency: string | null;
orders: CarwashOrderView[];
}
export interface CarwashSettingsBody {
categories?: { id?: string; name: string; active?: boolean }[];
services?: { id?: string; name: string; active?: boolean }[];
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
payAt?: CarWashPayAt;
}
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
return apiFetch("/api/carwash/settings");
}
export function saveCarwashSettings(body: CarwashSettingsBody): Promise<CarwashSettingsView> {
return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) });
}
export function lookupCarwashTicket(identity: string): Promise<CarwashTicketLookup> {
return apiFetch(`/api/carwash/session/${encodeURIComponent(identity.trim())}`);
}
export function fetchCarwashOrders(scope: "open" | "recent" = "open"): Promise<{ orders: CarwashOrderView[] }> {
return apiFetch(`/api/carwash/orders?scope=${scope}`);
}
export function createCarwashOrder(body: {
identity: string;
categoryId: string;
serviceId: string;
}): Promise<CarwashOrderView> {
return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) });
}
export function markCarwashDone(id: string): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" });
}
export function payCarwashAtBay(id: string, tender: Tender): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) });
}
export function voidCarwashOrder(id: string, reason: string): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) });
}
+49
View File
@@ -0,0 +1,49 @@
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
import type { AnyRoute } from "@tanstack/react-router";
import { can } from "../../api.js";
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
import type { RouterContext } from "../../router.js";
import { CarWashSetup } from "./CarWashSetup.js";
import { WashDesk } from "./WashDesk.js";
// Car Wash — the pilot venue module, web side (wiki/decisions/venue-modules.md).
// Two screens: the wash desk (/wash, carwash:read) and Setup → Car wash
// (/setup/carwash, site:read; editing needs site:update). Both gate on the module
// being effective at this site AND the permission; the server enforces the same.
function gate(perm: string) {
return ({ context }: { context: unknown }) => {
const ctx = context as RouterContext;
// Bounce to the landing resolver, never straight to the booth (a wash-only role
// has no booth to land on).
if (!moduleOn(ctx.user, "carwash") || !can(ctx.user, perm)) throw redirect({ to: "/" });
};
}
export const carwashModule: WebModule = {
id: "carwash",
nav: [{ to: "/wash", labelKey: "nav.wash", perm: "carwash:read" }],
landing: { to: "/wash", labelKey: "nav.wash", perm: "carwash:read" },
routes(root: RootRoute) {
const washRoute = createRoute({
getParentRoute: () => root,
path: "/wash",
beforeLoad: gate("carwash:read"),
component: WashDesk,
});
return [washRoute];
},
setupNav: [{ to: "/setup/carwash", labelKey: "nav.carwash", perm: "site:read" }],
setupRoutes(setup: AnyRoute) {
const setupCarwashRoute = createRoute({
getParentRoute: () => setup,
path: "/carwash",
beforeLoad: gate("site:read"),
component: function CarWashSetupRoute() {
const { user } = useRouteContext({ strict: false }) as RouterContext;
return <CarWashSetup canEdit={can(user, "site:update")} />;
},
});
return [setupCarwashRoute];
},
};
+2 -1
View File
@@ -1,4 +1,5 @@
import type { WebModule } from "../lib/modules.js";
import { carwashModule } from "./carwash/index.js";
import { validationModule } from "./validation/index.js";
// The web-side module registry, in display order. Adding a module = its folder here
@@ -6,4 +7,4 @@ import { validationModule } from "./validation/index.js";
// into the nav and the route tree and never names a module's screens itself.
// `parking` has no folder yet — its screens are still declared directly in
// router.tsx; they move behind this seam subsystem by subsystem.
export const WEB_MODULES: readonly WebModule[] = [validationModule];
export const WEB_MODULES: readonly WebModule[] = [validationModule, carwashModule];
+2 -1
View File
@@ -13,6 +13,7 @@ import { ValidateScreen } from "../../ValidateScreen.js";
export const validationModule: WebModule = {
id: "validation",
nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }],
landing: { to: "/validate", labelKey: "nav.validate", perm: "validation:create" },
routes(root: RootRoute) {
const validateRoute = createRoute({
getParentRoute: () => root,
@@ -20,7 +21,7 @@ export const validationModule: WebModule = {
beforeLoad: ({ context }) => {
const ctx = context as RouterContext;
if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) {
throw redirect({ to: "/booth" });
throw redirect({ to: "/" });
}
},
component: function ValidateRoute() {
+36 -190
View File
@@ -8,15 +8,12 @@ import {
} from "@tanstack/react-router";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
import {
can,
closeShift,
fetchShiftReport,
fetchVersion,
logout,
openShift,
setLanguagePref,
setThemePref,
setFontScalePref,
@@ -24,15 +21,15 @@ import {
FONT_SCALE_MAX,
FONT_SCALE_STEP,
} from "./api.js";
import { qk, queryClient } from "./lib/query.js";
import { queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { inTauri } from "./lib/origin.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { ShiftButton } from "./ShiftControl.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
@@ -45,7 +42,6 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { DrawerManager } from "./DrawerManager.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { WEB_MODULES } from "./modules/index.js";
@@ -198,6 +194,12 @@ function SetupLayout() {
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{/* Venue-module setup tabs (e.g. Car wash) — module on AND permission. */}
{WEB_MODULES.flatMap((m) =>
(m.setupNav ?? [])
.filter((n) => moduleOn(user, m.id) && show(n.perm))
.map((n) => <SetupTab key={n.to} to={n.to} label={t(n.labelKey)} />),
)}
{show("site:read") && <VersionBadge />}
<DesktopVersionBadge />
<DesktopServerButton />
@@ -363,176 +365,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se
);
}
/**
* 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);
// Closing a shift signs the Z-report and is irreversible, so the header button never
// closes directly (a stray click would end the shift) — it opens a confirm modal that
// shows the live X-report first. Opening a shift has no such risk → immediate.
const [confirmingClose, setConfirmingClose] = useState(false);
function onClick() {
if (isMine) {
setConfirmingClose(true);
} else {
void act("open");
}
}
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={onClick}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
</span>
) : (
label
)}
</button>
{!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
busy={busy}
onCancel={() => setConfirmingClose(false)}
onConfirm={async () => {
await act("close");
setConfirmingClose(false);
}}
/>
)}
</div>
);
}
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
* expected drawer before committing the irreversible Z-report. */
function CloseShiftConfirm({
busy,
onCancel,
onConfirm,
}: {
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
const x = q.data;
const cur = x?.currency ?? null;
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
<div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
) : (
<>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */}
<span />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
)}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.ending")}
</span>
) : (
t("shift.endShift")
)}
</button>
</div>
</div>
</Modal>
);
}
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
>
{label}
</span>
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div>
);
}
// Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till).
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
@@ -589,7 +422,10 @@ function RootLayout() {
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && show("shift:read") && <ShiftButton />}
{/* The header button is the BOOTH till's; a role that cannot work the booth
(no session:read — e.g. the wash operator, who has their own control on
the wash desk) does not get it. The server refuses the same (403). */}
{user && show("shift:read") && show("session:read") && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />}
@@ -630,23 +466,32 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: ({ context }) => {
// A merchant-only user (validation:create without the booth's session:read)
// lands on their scan-and-validate screen — if the validation module is on at
// this site; everyone else on the booth.
if (
moduleOn(context.user, "validation") &&
can(context.user, "validation:create") &&
!can(context.user, "session:read")
) {
throw redirect({ to: "/validate" });
}
throw redirect({ to: "/booth" });
// Landing = the first screen this role can actually use. The booth for anyone
// with the booth's permission; otherwise the first venue-module landing the role
// holds (wash desk for a wash operator, scan screen for a merchant); otherwise
// the shift hub; otherwise the profile. Every guard that bounces sends people
// HERE (never straight to the booth) so a booth-less role never dead-ends.
throw redirect({ to: landingFor(context.user) });
},
});
function landingFor(user: SessionUser | null): string {
if (can(user, "session:read")) return "/booth";
for (const m of WEB_MODULES) {
if (m.landing && moduleOn(user, m.id) && can(user, m.landing.perm)) return m.landing.to;
}
if (can(user, "shift:read")) return "/shifts";
return "/profile";
}
const boothRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/booth",
// The booth is the parking operator's screen; a role without session:read (a wash
// operator, a merchant) goes to its own landing instead of a screen that 403s.
beforeLoad: ({ context }) => {
if (!can(context.user, "session:read")) throw redirect({ to: "/" });
},
component: BoothScreen,
});
@@ -717,7 +562,7 @@ const drawerRoute = createRoute({
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
beforeLoad: ({ context }) => {
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
throw redirect({ to: "/booth" });
throw redirect({ to: "/" });
}
},
component: function DrawerRoute() {
@@ -916,6 +761,7 @@ const routeTree = rootRoute.addChildren([
recycleBinRoute,
logsRoute,
backupRoute,
...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []),
]),
]);
+2
View File
@@ -26,6 +26,8 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
carwash_order: { labelKey: "booth.evtCarwashOrder", color: "text-term-cyan" },
carwash_payment: { labelKey: "booth.evtCarwashPayment", color: "text-term-cyan" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};