692dff5f89
In-park merchants discharge customers' parking: a merchant user scans the ticket on their device (/validate; validation:create + program↔user binding) and applies their program — comp / first-N-minutes free / amount-off (capped, typed at scan) / percent. All money stays at the booth: the quote folds live validations in a canonical order (timeCredit → percent → fixed → comp, net floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and CONSUMES the validation ids (an overstay's fresh period never re-applies them), the receipt prints the gross → lines → net story, and the Z/X-report carries discountTotalMinor leakage. Every apply/void is a signed, attributed ledger event (refId = append-only void); program config is /setup/site master data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route integration tests + priceSession fold suite. See wiki/concepts/validation-discounts.md for the full design record. Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
232 lines
8.5 KiB
TypeScript
232 lines
8.5 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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 two well-known stations the checkboxes toggle. */
|
|
export const STATIONS = ["bar", "lavazh"] as const;
|
|
export type StationId = (typeof STATIONS)[number];
|
|
|
|
/** A blank program draft for a station enabled for the first time. */
|
|
export function defaultProgram(id: StationId, 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;
|
|
};
|
|
|
|
function StationForm({
|
|
program,
|
|
onSaved,
|
|
}: {
|
|
program: ValidationProgramView;
|
|
onSaved: (p: ValidationProgramView) => void;
|
|
}) {
|
|
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 === "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) : 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)}>
|
|
<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>
|
|
</select>
|
|
</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 === "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>
|
|
<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(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<StationForm program={current} onSaved={onSaved} />
|
|
</section>
|
|
);
|
|
}
|