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:
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user