"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 (
);
}
/* 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
}