a9ccf9e20c
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
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { MERCHANT_VALIDATION_MODES } from "@parking/shared";
|
|
import {
|
|
fetchUsers,
|
|
saveValidationProgram,
|
|
type ManagedUser,
|
|
type ValidationMode,
|
|
type ValidationProgramView,
|
|
} from "./api.js";
|
|
|
|
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
|
|
// The checkboxes on the left card toggle a station's `active`; this panel edits the
|
|
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
|
|
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
|
|
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
|
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
|
|
|
/** The well-known merchant stations the checkboxes toggle. Was `["bar", "lavazh"]`;
|
|
* the Lavazh (car-wash) station was retired 2026-09-05 — the Car Wash module
|
|
* sponsors parking through its own order flow instead (wiki/decisions/
|
|
* venue-modules.md). Existing `lavazh` program rows are untouched data; the server
|
|
* accepts any kebab slug, so they simply no longer have a checkbox. */
|
|
export const STATIONS = ["bar"] as const;
|
|
export type StationId = (typeof STATIONS)[number];
|
|
|
|
/** i18n label for a station's checkbox / tab. */
|
|
const STATION_LABEL_KEY: Record<StationId, string> = { bar: "val.enableBar" };
|
|
export function stationLabelKey(id: StationId): string {
|
|
return STATION_LABEL_KEY[id];
|
|
}
|
|
|
|
/** A blank program draft for a station enabled for the first time. */
|
|
export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
|
|
return {
|
|
name: label,
|
|
mode: "comp",
|
|
minutes: null,
|
|
percent: null,
|
|
maxAmountMinor: null,
|
|
maxPerDay: null,
|
|
active: true,
|
|
userIds: [],
|
|
};
|
|
}
|
|
|
|
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;
|
|
};
|
|
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
|
|
const toInt = (s: string): number | null => {
|
|
const v = s.trim();
|
|
if (v === "") return 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",
|
|
};
|
|
|
|
/** 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);
|
|
const [mode, setMode] = useState<ValidationMode>(program.mode);
|
|
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
|
|
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
|
|
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
|
|
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
|
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
|
|
const [users, setUsers] = useState<ManagedUser[] | null>(null);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
|
|
// Reset the form when the tab switches to another station.
|
|
useEffect(() => {
|
|
setName(program.name);
|
|
setMode(program.mode);
|
|
setMinutes(program.minutes == null ? "" : String(program.minutes));
|
|
setPercent(program.percent == null ? "" : String(program.percent));
|
|
setMaxAmount(fromMinor(program.maxAmountMinor));
|
|
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
|
setUserIds(new Set(program.userIds));
|
|
setMsg(null);
|
|
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
fetchUsers()
|
|
.then((r) => setUsers(r.users))
|
|
.catch(() => setUsers([]));
|
|
}, []);
|
|
|
|
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;
|
|
}
|
|
if (mode === "fixed") return toMinor(maxAmount) != null;
|
|
return true;
|
|
}, [name, mode, minutes, percent, maxAmount]);
|
|
|
|
async function save() {
|
|
setMsg(null);
|
|
try {
|
|
const saved = await saveValidationProgram(program.id, {
|
|
name: name.trim(),
|
|
mode,
|
|
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),
|
|
active: program.active,
|
|
userIds: [...userIds],
|
|
});
|
|
onSaved(saved);
|
|
setMsg(t("val.saved"));
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
const toggleUser = (id: string) =>
|
|
setUserIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
|
|
return (
|
|
<div className="mt-3 grid gap-3">
|
|
<div className="field">
|
|
<span className="label">{t("val.labelName")}</span>
|
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
|
|
</div>
|
|
<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)}>
|
|
{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">
|
|
<span className="label">{t("val.minutes")}</span>
|
|
<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>
|
|
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
|
|
</div>
|
|
)}
|
|
{mode === "fixed" && (
|
|
<div className="field">
|
|
<span className="label">{t("val.maxAmount")}</span>
|
|
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
|
|
</div>
|
|
)}
|
|
<div className="field">
|
|
<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>
|
|
<div className="mt-1 grid gap-1">
|
|
{users == null ? (
|
|
<span className="text-term-muted">…</span>
|
|
) : users.length === 0 ? (
|
|
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
|
|
) : (
|
|
users.map((u) => (
|
|
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-term-amber"
|
|
checked={userIds.has(u.id)}
|
|
onChange={() => toggleUser(u.id)}
|
|
/>
|
|
{u.username}
|
|
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
|
|
</label>
|
|
))
|
|
)}
|
|
</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")}
|
|
</button>
|
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The right-column panel: tabs across the ENABLED stations, one form each. */
|
|
export function ValidationStationsPanel({
|
|
programs,
|
|
onSaved,
|
|
}: {
|
|
programs: ValidationProgramView[];
|
|
onSaved: (p: ValidationProgramView) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
|
|
(p): p is ValidationProgramView => p != null && p.active,
|
|
);
|
|
const [tab, setTab] = useState<string | null>(null);
|
|
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
|
|
if (!current) return null;
|
|
|
|
return (
|
|
<section className="card w-full max-w-md p-4">
|
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
|
|
{enabled.length > 1 && (
|
|
<div className="mt-2 flex gap-1">
|
|
{enabled.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
type="button"
|
|
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
|
onClick={() => setTab(p.id)}
|
|
>
|
|
{t(stationLabelKey(p.id as StationId))}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<StationForm program={current} onSaved={onSaved} />
|
|
</section>
|
|
);
|
|
}
|