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
223 lines
9.2 KiB
TypeScript
223 lines
9.2 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
fetchOccupancy,
|
|
fetchSiteConfig,
|
|
fetchValidationPrograms,
|
|
saveSiteConfig,
|
|
saveValidationProgram,
|
|
type Occupancy,
|
|
type SiteConfig,
|
|
type ValidationProgramView,
|
|
} from "./api.js";
|
|
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
|
|
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
|
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
|
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
|
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
|
|
|
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
|
// (resolved at render); only `address` is multiline.
|
|
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
|
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
|
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
|
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
|
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
|
{ key: "phone", labelKey: "site.fieldPhone" },
|
|
{ key: "email", labelKey: "site.fieldEmail" },
|
|
];
|
|
|
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|
const { t } = useTranslation();
|
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
|
const [capInput, setCapInput] = useState("");
|
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
|
const [reserveSubs, setReserveSubs] = useState(false);
|
|
const [anprEntry, setAnprEntry] = useState(true);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
|
|
// station's `active` (persisted at once — each flip signs a config_change); the
|
|
// right-column panel edits the enabled stations. See validation-discounts.md.
|
|
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
|
|
|
function reload() {
|
|
fetchOccupancy().then(setOcc).catch(() => {});
|
|
}
|
|
useEffect(() => {
|
|
reload();
|
|
if (canEdit) {
|
|
fetchValidationPrograms()
|
|
.then((r) => setPrograms(r.programs))
|
|
.catch(() => {});
|
|
}
|
|
fetchSiteConfig()
|
|
.then((c) => {
|
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
|
setExitVoucherDefault(c.exitVoucherDefault);
|
|
setReserveSubs(c.reserveSubscriberSpots);
|
|
setAnprEntry(c.anprEntryEnabled);
|
|
const m: Record<string, string> = {};
|
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
|
setMeta(m);
|
|
})
|
|
.catch(() => {});
|
|
}, [canEdit]);
|
|
|
|
/** Flip a merchant station's checkbox: persist `active` at once (a signed
|
|
* config_change server-side), creating the well-known row with comp defaults on
|
|
* the first enable. Config details are edited in the right-column panel. */
|
|
async function toggleStation(id: StationId, active: boolean) {
|
|
const existing = programs.find((p) => p.id === id);
|
|
const body = existing
|
|
? { ...existing, active }
|
|
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
|
try {
|
|
const saved = await saveValidationProgram(id, body);
|
|
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
setMsg(null);
|
|
const raw = capInput.trim();
|
|
const patch: Partial<SiteConfig> = {
|
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
|
exitVoucherDefault,
|
|
reserveSubscriberSpots: reserveSubs,
|
|
anprEntryEnabled: anprEntry,
|
|
};
|
|
// Send each metadata field; "" → null is applied server-side.
|
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
|
try {
|
|
await saveSiteConfig(patch);
|
|
reload();
|
|
setMsg(t("site.saved"));
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
|
<section className="card w-full max-w-md p-4">
|
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
|
{occ == null ? (
|
|
<span className="text-term-muted">…</span>
|
|
) : (
|
|
<>
|
|
<span className="text-h5 font-semibold tabular-nums text-term-text">{occ.count}</span>
|
|
<span className="tabular-nums text-term-muted">
|
|
{occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")}
|
|
</span>
|
|
{occ.capacity != null && (
|
|
<span className="tabular-nums text-term-muted">· {occ.free} {t("site.free")}</span>
|
|
)}
|
|
{occ.full && <span className="font-semibold text-term-red">{t("site.full")}</span>}
|
|
<button type="button" className="btn btn-ghost btn-sm" onClick={reload}>↻</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{canEdit && (
|
|
<div className="mt-4 grid gap-3">
|
|
<div className="field">
|
|
<span className="label">{t("site.capacityLabel")}</span>
|
|
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
|
</div>
|
|
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-term-amber"
|
|
checked={exitVoucherDefault}
|
|
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
|
/>
|
|
{t("site.printExitDefault")}
|
|
<span className="hint">{t("site.printExitHint")}</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={reserveSubs}
|
|
onChange={(e) => setReserveSubs(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.reserveSubs")}
|
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={anprEntry}
|
|
onChange={(e) => setAnprEntry(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.anprEntry")}
|
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
|
</span>
|
|
</label>
|
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
{t("val.sectionTitle")}
|
|
</div>
|
|
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
|
<div className="flex gap-6">
|
|
{STATIONS.map((id) => (
|
|
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-term-amber"
|
|
checked={programs.find((p) => p.id === id)?.active ?? false}
|
|
onChange={(e) => toggleStation(id, e.target.checked)}
|
|
/>
|
|
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
{t("site.parkDetails")}
|
|
</div>
|
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
|
<div key={key} className="field">
|
|
<span className="label">{t(labelKey)}</span>
|
|
{multiline ? (
|
|
<textarea
|
|
className="textarea"
|
|
value={meta[key] ?? ""}
|
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
|
rows={2}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
) : (
|
|
<input
|
|
className="input"
|
|
value={meta[key] ?? ""}
|
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="flex items-center gap-3">
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
{canEdit && (
|
|
<ValidationStationsPanel
|
|
programs={programs}
|
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|