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 = { 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 { 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 = { 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(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>(new Set(program.userIds)); const [users, setUsers] = useState(null); const [msg, setMsg] = useState(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 (
{t("val.labelName")} setName(e.target.value)} placeholder={t("val.labelNamePh")} />
{t("val.mode")} {mode === "doneTolerance" && {t("val.modeDoneToleranceHint")}} {mode === "washPrice" && {t("val.modeWashPriceHint")}}
{mode === "timeCredit" && (
{t("val.minutes")} setMinutes(e.target.value)} placeholder="60" />
)} {mode === "doneTolerance" && (
{t("val.toleranceMinutes")} setMinutes(e.target.value)} placeholder="15" />
)} {mode === "percent" && (
{t("val.percent")} setPercent(e.target.value)} placeholder="100" />
)} {mode === "fixed" && (
{t("val.maxAmount")} setMaxAmount(e.target.value)} placeholder="1000" />
)}
{t("val.maxPerDay")} setMaxPerDay(e.target.value)} />
{!hideUsers && (
{t("val.users")}
{t("val.usersHint")}
{users == null ? ( … ) : users.length === 0 ? ( {t("val.noUsers")} ) : ( users.map((u) => ( )) )}
)}
{msg && {msg}}
); } /** 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(null); const current = enabled.find((p) => p.id === tab) ?? enabled[0]; if (!current) return null; return (
{t("val.sectionTitle")}
{enabled.length > 1 && (
{enabled.map((p) => ( ))}
)}
); }