import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouteContext } from "@tanstack/react-router"; import { fetchMe, fetchOccupancy, fetchSiteConfig, fetchValidationPrograms, saveSiteConfig, saveValidationProgram, type Occupancy, type SiteConfig, type ValidationProgramView, } from "./api.js"; import { STATIONS, ValidationStationsPanel, defaultProgram, stationLabelKey, type StationId } from "./ValidationSetup.js"; import { MODULES, type ModuleId } from "@parking/shared"; import type { RouterContext } from "./router.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(null); const [capInput, setCapInput] = useState(""); const [meta, setMeta] = useState>({}); const [exitVoucherDefault, setExitVoucherDefault] = useState(false); const [reserveSubs, setReserveSubs] = useState(false); const [anprEntry, setAnprEntry] = useState(true); const [msg, setMsg] = useState(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([]); // Venue modules: what this site is entitled to (vendor-set), what the admin has // activated, and the effective set. Toggling persists at once (the server signs a // config_change per module that flips and validates dependencies). See // wiki/decisions/venue-modules.md. const [mods, setMods] = useState<{ entitled: ModuleId[]; activated: ModuleId[]; effective: ModuleId[] } | null>(null); const [modMsg, setModMsg] = useState(null); const moduleOn = (id: ModuleId) => mods?.effective.includes(id) ?? false; // The header nav gates module entries on the SESSION's module set (/api/auth/me), // so a flip here must refresh the session too or the nav stays stale until reload // (App re-validates the router whenever `user` changes). const { setUser } = useRouteContext({ strict: false }) as RouterContext; function reload() { fetchOccupancy().then(setOcc).catch(() => {}); } /** The validation programs are a module route — only ask for them while the * module is effective (the server 403s otherwise, which would land in app_logs * as a failed request every time an admin opens this page). */ function loadPrograms(effective: ModuleId[]) { if (!canEdit || !effective.includes("validation")) { setPrograms([]); return; } fetchValidationPrograms() .then((r) => setPrograms(r.programs)) .catch(() => {}); } useEffect(() => { reload(); fetchSiteConfig() .then((c) => { setCapInput(c.capacity == null ? "" : String(c.capacity)); setExitVoucherDefault(c.exitVoucherDefault); setReserveSubs(c.reserveSubscriberSpots); setAnprEntry(c.anprEntryEnabled); setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules }); loadPrograms(c.modules); const m: Record = {}; 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(stationLabelKey(id))), active }; try { const saved = await saveValidationProgram(id, body); setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]); } catch (e) { setMsg((e as Error).message); } } /** Flip a module: send the full desired activation set; the server decides * (required always on, must be entitled, dependencies) and echoes the result. */ async function toggleModule(id: ModuleId, on: boolean) { if (!mods) return; setModMsg(null); const next = on ? [...new Set([...mods.activated, id])] : mods.activated.filter((m) => m !== id); try { const c = await saveSiteConfig({ modules: next }); setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules }); loadPrograms(c.modules); const me = await fetchMe(); if (me) setUser(me); } catch (e) { setModMsg((e as Error).message); } } async function save() { setMsg(null); const raw = capInput.trim(); const patch: Partial = { 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)[key] = meta[key] ?? ""; try { await saveSiteConfig(patch); reload(); setMsg(t("site.saved")); } catch (e) { setMsg((e as Error).message); } } return (
{t("site.occupancy")} {occ == null ? ( … ) : ( <> {occ.count} {occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")} {occ.capacity != null && ( · {occ.free} {t("site.free")} )} {occ.full && {t("site.full")}} )}
{canEdit && (
{t("site.capacityLabel")} setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
{t("modules.sectionTitle")}
{t("modules.sectionHint")}
{MODULES.filter((m) => mods?.entitled.includes(m.id)).map((m) => ( ))} {modMsg && {modMsg}}
{moduleOn("validation") && ( <>
{t("val.sectionTitle")}
{t("val.sectionHint")}
{STATIONS.map((id) => ( ))}
)}
{t("site.parkDetails")}
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
{t(labelKey)} {multiline ? (