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