"use client"; /* Pieces every Settings section shares: the result line, the debounced field saver, the section * heading, and the few layout rules the side list and the route board need. */ import { useCallback, useEffect, useId, useRef, useState } from "react"; import { useSnap } from "@/lib/client"; import { Field, LiveRegion } from "@/components/ui"; import { LEGACY_SETTINGS_TAB } from "@/lib/manual-links"; import type { Settings } from "@/lib/compute"; export const SECTIONS = [ { id: "facility", label: "Facility" }, { id: "issuing", label: "Issuing rules" }, { id: "catalogue", label: "Catalogue & suppliers" }, { id: "places", label: "Places & cost centres" }, { id: "people", label: "People & sign-in" }, { id: "data", label: "Data & audit log" }, { id: "plan", label: "Plan" }, ] as const; export type SectionId = (typeof SECTIONS)[number]["id"]; /** A ?tab= value (new id, old tab name, or #hash form) to the section it opens, and whether the * audit log should be scrolled into view. Unknown values open Facility. */ export function resolveSection(raw: string | null | undefined): { section: SectionId; audit: boolean } { const want = (raw || "").trim().toLowerCase(); const mapped = LEGACY_SETTINGS_TAB[want] || want; if (mapped === "audit") return { section: "data", audit: true }; const hit = SECTIONS.find((x) => x.id === mapped); return { section: hit ? hit.id : "facility", audit: false }; } /* Module scope, so a live region is not rebuilt (and re-announced) on every keystroke. */ export function Msg({ text }: { text?: string }) { return ; } export function TextField({ label, hint, ph, value, onChange, disabled, style }: { label: string; hint?: string; ph?: string; value: string; onChange: (v: string) => void; disabled: boolean; style?: React.CSSProperties }) { return {(c) => onChange(e.target.value)} disabled={disabled} />}; } /** An h2 in the board's section style, with an optional meta line and right-hand content. */ export function SectionHead({ children, meta, right, divider = true, id }: { children: React.ReactNode; meta?: React.ReactNode; right?: React.ReactNode; divider?: boolean; id?: string }) { return (

{children}

{meta && {meta}} {right && {right}}
); } /* Debounced saves, one message map per section. * * Sections unmount when the side list moves on, so a save still waiting out its pause is sent * straight away on unmount rather than dropped: typing a figure and clicking the next section must * not lose it. */ export function useSaver() { const { mutate } = useSnap(); const [msg, setMsg] = useState>({}); const say = useCallback((k: string, v: string) => setMsg((m) => ({ ...m, [k]: v })), []); const [draft, setDraft] = useState>({}); const pending = useRef; run: () => void }>>({}); const draftNow = useRef(draft); useEffect(() => { draftNow.current = draft; }); useEffect(() => { const p = pending.current; return () => { for (const k of Object.keys(p)) { clearTimeout(p[k].t); p[k].run(); } }; }, []); const schedule = useCallback((k: string, run: () => void | Promise, ms: number) => { if (pending.current[k]) clearTimeout(pending.current[k].t); const go = () => { delete pending.current[k]; void run(); }; pending.current[k] = { t: setTimeout(go, ms), run: go }; }, []); /** Take a refused edit back out of the draft, but only if the box still says what was refused. */ const forgetDraft = useCallback((k: string, only?: string) => setDraft((d) => { if (only !== undefined && d[k] !== only) return d; const next = { ...d }; delete next[k]; return next; }), []); const debounced = useCallback((k: string, v: string, op: string, payload: Record, msgKey = "fields") => { setDraft((d) => ({ ...d, [k]: v })); schedule(k, async () => { const r = await mutate(op, payload); say(msgKey, r.ok ? "Saved." : r.error); }, 500); }, [mutate, say, schedule]); return { msg, say, draft, setDraft, draftNow, forgetDraft, debounced, schedule }; } const NUMERIC = new Set(["defaultEntitlement", "initialSets", "defaultReorder", "exceptionHigh", "capSets", "varianceReason"]); /** Reading and saving facility settings fields through settings.update. */ export function useSettingsFields(saver: ReturnType) { const { s } = useSnap(); const { draft, setDraft, debounced } = saver; const val = (k: keyof Settings) => (draft[k] !== undefined ? draft[k] : String(s.settings[k] ?? "")); const setField = (k: keyof Settings, v: string) => { // An emptied number box saves nothing; the stored figure stands until a number is typed. if (NUMERIC.has(k) && v === "") { setDraft((d) => ({ ...d, [k]: v })); return; } debounced(k, v, "settings.update", { [k]: v }); }; return { val, setField }; } /** A small number box with a label above and a unit after, as on the Issuing rules board. */ export function InlineNumber({ label, unit, value, onChange, disabled }: { label: string; unit: string; value: string; onChange: (v: string) => void; disabled: boolean }) { const id = useId(); return (
onChange(e.target.value.replace(/[^0-9]/g, ""))} /> {unit}
); } /* Layout rules for this screen only. Scoped to class names nothing else wears. */ export function SettingsStyles() { return null; // the rules are in app/globals.css under portal redesign }