// components/forms/SettingsSubmit.tsx "use client"; import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; import { useRouter, useSearchParams } from "next/navigation"; /** CO₂ Galvo implementation only (per spec). Other targets intentionally omitted. */ type Target = "settings_co2gal"; // Accept any of the known targets from callers (e.g., SettingsSwitcher), // but we still hard-lock behavior to CO₂ Galvo internally. type ExternalTarget = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; type Opt = { id: string; label: string }; type Me = { id: string; username?: string; email?: string }; const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); /* ───────────────────────────────────────────────────────────── * Local enums (simple, stable lists that UI controls use) * ───────────────────────────────────────────────────────────── */ const FILL_TYPE_OPTIONS = [ { label: "UniDirectional", value: "uni" }, { label: "BiDirectional", value: "bi" }, { label: "Offset Fill", value: "offset" }, ]; const RASTER_TYPE_OPTIONS = [ { label: "UniDirectional", value: "uni" }, { label: "BiDirectional", value: "bi" }, { label: "Offset Fill", value: "offset" }, ]; const RASTER_DITHER_OPTIONS = [ { label: "Threshold", value: "threshold" }, { label: "Ordered", value: "ordered" }, { label: "Atkinson", value: "atkinson" }, { label: "Dither", value: "dither" }, { label: "Stucki", value: "stucki" }, { label: "Jarvis", value: "jarvis" }, { label: "Newsprint", value: "newsprint" }, { label: "Halftone", value: "halftone" }, { label: "Sketch", value: "sketch" }, { label: "Grayscale", value: "grayscale" }, ]; const toOpts = (arr: { label: string; value: string }[]): Opt[] => arr.map((x) => ({ id: x.value, label: x.label })); /* ───────────────────────────────────────────────────────────── * Edit-mode initial values * ───────────────────────────────────────────────────────────── */ type EditInitialValues = { submission_id: string | number; setting_title?: string; setting_notes?: string; photo?: string | { id?: string } | null; screen?: string | { id?: string } | null; /* material / optics */ mat?: any; mat_coat?: any; mat_color?: any; mat_opacity?: any; mat_thickness?: number | null; source?: any; lens?: any; focus?: number | null; /* dropdown relations (CO₂ Galvo) */ lens_conf?: any; // laser_scan_lens_config (Lens Configuration) lens_apt?: any; // laser_scan_lens_apt (Scan Head Aperture) lens_exp?: any; // laser_scan_lens_exp (Beam Expander) /* shared */ laser_soft?: any; repeat_all?: number | null; /* repeaters */ fill_settings?: any[] | null; line_settings?: any[] | null; raster_settings?: any[] | null; }; type BaseProps = { initialTarget?: ExternalTarget }; type CreateProps = BaseProps & { mode?: "create"; submissionId?: never; initialValues?: never }; type EditProps = BaseProps & { mode: "edit"; submissionId: string | number; initialValues: EditInitialValues }; function isEditProps(p: CreateProps | EditProps): p is EditProps { return (p as any)?.mode === "edit"; } /* ───────────────────────────────────────────────────────────── * Helpers * ───────────────────────────────────────────────────────────── */ function idToString(v: any): string { if (v == null || v === "") return ""; if (typeof v === "object") { if ((v as any).id != null) return String((v as any).id); if ((v as any).submission_id != null) return String((v as any).submission_id); } return String(v); } function normalizeEnums(value: any, allowed: string[], fallback: string) { const v = value == null ? "" : String(value).toLowerCase(); return allowed.includes(v) ? v : fallback; } function normalizeForReset(iv: EditInitialValues) { return { ...iv, mat: idToString(iv.mat), mat_coat: idToString(iv.mat_coat), mat_color: idToString(iv.mat_color), mat_opacity: idToString(iv.mat_opacity), source: idToString(iv.source), lens: idToString(iv.lens), laser_soft: idToString(iv.laser_soft), lens_conf: idToString(iv.lens_conf), lens_apt: idToString(iv.lens_apt), lens_exp: idToString(iv.lens_exp), fill_settings: (iv.fill_settings ?? []).map((r: any) => ({ ...r, type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni") })), raster_settings: (iv.raster_settings ?? []).map((r: any) => ({ ...r, type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"), dither: normalizeEnums( r?.dither, ["threshold", "ordered", "atkinson", "dither", "stucki", "jarvis", "newsprint", "halftone", "sketch", "grayscale"], "threshold" ), })), line_settings: (iv.line_settings ?? []).map((r: any) => ({ ...r })), }; } /* ───────────────────────────────────────────────────────────── * Options loader (Materials, Software, Source, Lens + fixed lens lists) * ───────────────────────────────────────────────────────────── */ function useOptions(path: string, forceIncludeId?: string) { const [opts, setOpts] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); useEffect(() => { let alive = true; setLoading(true); (async () => { let url = ""; let normalize: (rows: any[]) => Opt[] = (rows) => rows.map((r) => ({ id: String(r.id ?? r.submission_id ?? r.value), label: String(r.name ?? r.label ?? r.title ?? r.value ?? r.id) })); if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`; else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`; else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`; else if (path === "material_opacity") { url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`; normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) })); } else if (path === "laser_software") { url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; } else if (path === "laser_source_co2_galvo") { // Only show CO₂-range sources (10,000–11,000 nm) to match galvo url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`; normalize = (rows) => { const toNum = (v: any): number | null => { const m = String(v ?? "").match(/-?\d+(\.\d+)?/); return m ? Number(m[0]) : null; }; return rows .filter((r: any) => { const nm = toNum(r.nm); return nm != null && nm >= 10000 && nm <= 11000; }) .map((r: any) => ({ id: String(r.submission_id), label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id), })); }; } else if (path === "laser_scan_lens") { url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`; normalize = (rows) => { const toNum = (v: any) => { const m = String(v ?? "").match(/-?\d+(\.\d+)?/); return m ? parseFloat(m[0]) : Number.POSITIVE_INFINITY; }; const sorted = [...rows].sort((a, b) => toNum(a.focal_length) - toNum(b.focal_length)); return sorted.map((r) => { const fs = r.field_size != null ? `${r.field_size}` : ""; const fl = r.focal_length != null ? `${r.focal_length}` : ""; const composed = [fs && `${fs} mm`, fl && `${fl} mm`].filter(Boolean).join(" — "); return { id: String(r.id), label: composed || String(r.id) }; }); }; } else if (path === "laser_scan_lens_config") { url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`; } else if (path === "laser_scan_lens_apt") { url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; } else if (path === "laser_scan_lens_exp") { url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; } else { setOpts([]); setLoading(false); return; } const res = await fetch(url, { cache: "no-store", credentials: "include" }); if (!res.ok) throw new Error(`Directus ${res.status} fetching ${url}`); const json = await res.json(); const rows = json?.data ?? []; let mapped = normalize(rows); if (forceIncludeId && !mapped.some((o) => String(o.id) === String(forceIncludeId))) { mapped = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped]; } const needle = (q || "").trim().toLowerCase(); const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; if (alive) setOpts(filtered); })() .catch(() => setOpts([])) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [path, q, forceIncludeId]); return { opts, loading, setQ }; } /* ───────────────────────────────────────────────────────────── * Small UI bits * ───────────────────────────────────────────────────────────── */ function FilterableSelect({ label, name, register, options, loading, onQuery, placeholder = "—", required = false, }: { label: string; name: string; register: UseFormRegister; options: Opt[]; loading?: boolean; onQuery?: (q: string) => void; placeholder?: string; required?: boolean; }) { const [filter, setFilter] = useState(""); useEffect(() => { onQuery?.(filter); }, [filter, onQuery]); const filtered = useMemo(() => { if (!filter) return options; const f = filter.toLowerCase(); return options.filter((o) => o.label.toLowerCase().includes(f)); }, [options, filter]); return (
setFilter(e.target.value)} />
); } function BoolBox({ label, name, register }: { label: string; name: string; register: UseFormRegister }) { return ( ); } function LabeledInput({ label, name, type = "text", step, register, required = false, min, max, }: { label: string; name: string; type?: "text" | "number"; step?: string | number; register: UseFormRegister; required?: boolean; min?: number; max?: number; }) { return (
); } /* ───────────────────────────────────────────────────────────── * Component (CO₂ Galvo only) * ───────────────────────────────────────────────────────────── */ export default function SettingsSubmit(props: CreateProps | EditProps) { const router = useRouter(); const sp = useSearchParams(); const isEdit = isEditProps(props); const edit = isEdit ? props : null; // CO₂ Galvo is the only target in this implementation. const target: Target = "settings_co2gal"; // Image inputs const [photoFile, setPhotoFile] = useState(null); const [screenFile, setScreenFile] = useState(null); const [photoPreview, setPhotoPreview] = useState(""); const [screenPreview, setScreenPreview] = useState(""); // UX error for auth/submit const [submitErr, setSubmitErr] = useState(null); // Current signed-in user (banner + uploader) const [me, setMe] = useState(null); const [meErr, setMeErr] = useState(null); useEffect(() => { let alive = true; fetch(`/api/me`, { cache: "no-store", credentials: "include" }) .then((r) => (r.ok ? r.json() : Promise.reject(r))) .then((j) => { if (!alive) return; setMe(j || null); }) .catch(() => { if (alive) setMeErr("not-signed-in"); }); return () => { alive = false; }; }, []); const meLabel = me?.username ?? me?.email ?? ""; // For edit-mode, compute normalized current values once const current = useMemo( () => (isEdit && edit?.initialValues ? normalizeForReset(edit.initialValues) : null), [isEdit, edit?.initialValues] ); // Options (CO₂ Galvo) const mats = useOptions("material", current?.mat || undefined); const coats = useOptions("material_coating", current?.mat_coat || undefined); const colors= useOptions("material_color", current?.mat_color || undefined); const opacs = useOptions("material_opacity", current?.mat_opacity || undefined); const soft = useOptions("laser_software", current?.laser_soft || undefined); const srcs = useOptions("laser_source_co2_galvo", current?.source || undefined); const lens = useOptions("laser_scan_lens", current?.lens || undefined); // Fixed lists (server-backed) const lensConf = useOptions("laser_scan_lens_config", current?.lens_conf || undefined); // Lens Configuration const lensApt = useOptions("laser_scan_lens_apt", current?.lens_apt || undefined); // Scan Head Aperture const lensExp = useOptions("laser_scan_lens_exp", current?.lens_exp || undefined); // Beam Expander // Repeater choice options (local) const fillType = { opts: toOpts(FILL_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} }; const rasterType = { opts: toOpts(RASTER_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} }; const rasterDither= { opts: toOpts(RASTER_DITHER_OPTIONS), loading: false, setQ: (_: string) => {} }; const { register, handleSubmit, control, reset, setValue, getValues, formState: { isSubmitting }, } = useForm({ defaultValues: { setting_title: "", setting_notes: "", // material / optics mat: "", mat_coat: "", mat_color: "", mat_opacity: "", mat_thickness: "", source: "", lens: "", focus: "", laser_soft: "", repeat_all: "", // CO₂ Galvo lens dropdowns lens_conf: "", lens_apt: "", lens_exp: "", // repeaters fill_settings: [], line_settings: [], raster_settings: [], }, }); const fills = useFieldArray({ control, name: "fill_settings" }); const lines = useFieldArray({ control, name: "line_settings" }); const rasters = useFieldArray({ control, name: "raster_settings" }); // Prefill the form in edit mode useEffect(() => { if (isEdit && edit?.initialValues) { const iv = normalizeForReset(edit.initialValues); reset({ setting_title: iv.setting_title ?? "", setting_notes: iv.setting_notes ?? "", photo: iv.photo ?? null, screen: iv.screen ?? null, mat: iv.mat ?? "", mat_coat: iv.mat_coat ?? "", mat_color: iv.mat_color ?? "", mat_opacity: iv.mat_opacity ?? "", mat_thickness: iv.mat_thickness ?? "", source: iv.source ?? "", lens: iv.lens ?? "", focus: iv.focus ?? "", laser_soft: iv.laser_soft ?? "", repeat_all: iv.repeat_all ?? "", lens_conf: iv.lens_conf ?? "", lens_apt: iv.lens_apt ?? "", lens_exp: iv.lens_exp ?? "", fill_settings: iv.fill_settings ?? [], line_settings: iv.line_settings ?? [], raster_settings: iv.raster_settings ?? [], }); } }, [isEdit, edit?.initialValues, reset]); // After reset, force selects once to show current ids useEffect(() => { if (!isEdit || !current) return; const names = ["laser_soft","mat","mat_coat","mat_color","mat_opacity","source","lens","lens_conf","lens_apt","lens_exp"] as const; const values = getValues(); names.forEach((name) => { const cur = (current as any)[name]; const now = (values as any)[name]; if (cur && (now == null || now === "")) { setValue(name as any, cur, { shouldDirty: false, shouldValidate: false }); } }); }, [isEdit, current, getValues, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, soft.opts, srcs.opts, lens.opts, lensConf.opts, lensApt.opts, lensExp.opts]); function num(v: any) { return v === "" || v == null ? null : Number(v); } const bool = (v: any) => !!v; /* ───────────────────────────────────────────────────────────── * SUBMIT (multipart + payload JSON). CO₂ Galvo requireds enforced server-side, * but we mark client-required for UX. * ───────────────────────────────────────────────────────────── */ async function onSubmit(values: any) { setSubmitErr(null); // In create mode, require either existing id (not present here) or a fresh file. const hasExistingPhotoId = isEdit && typeof edit!.initialValues?.photo === "string" && !!edit!.initialValues.photo; if (!hasExistingPhotoId && !photoFile) { (document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus(); return; } const payload: any = { target, // "settings_co2gal" ...(isEdit ? { mode: "edit" as const, submission_id: edit!.submissionId } : {}), /* required basics */ setting_title: values.setting_title, setting_notes: values.setting_notes || "", /* material / optics */ mat: values.mat || null, mat_coat: values.mat_coat || null, mat_color: values.mat_color || null, mat_opacity: values.mat_opacity || null, mat_thickness: num(values.mat_thickness), source: values.source || null, lens: values.lens || null, focus: num(values.focus), /* CO₂-only dropdowns */ lens_conf: values.lens_conf || null, // Lens Configuration lens_apt: values.lens_apt || null, // Scan Head Aperture lens_exp: values.lens_exp || null, // Beam Expander /* shared */ laser_soft: values.laser_soft || null, repeat_all: num(values.repeat_all), /* repeaters */ fill_settings: (values.fill_settings || []).map((r: any) => ({ name: r.name || "", power: num(r.power), speed: num(r.speed), interval: num(r.interval), pass: num(r.pass), type: r.type || "", frequency: num(r.frequency), pulse: num(r.pulse), angle: num(r.angle), auto: bool(r.auto), increment: num(r.increment), cross: bool(r.cross), flood: bool(r.flood), air: bool(r.air), })), line_settings: (values.line_settings || []).map((r: any) => ({ name: r.name || "", power: num(r.power), speed: num(r.speed), perf: bool(r.perf), cut: bool(r.cut), skip: bool(r.skip), pass: num(r.pass), air: bool(r.air), frequency: num(r.frequency), pulse: num(r.pulse), wobble: bool(r.wobble), step: num(r.step), size: num(r.size), })), raster_settings: (values.raster_settings || []).map((r: any) => ({ name: r.name || "", power: num(r.power), speed: num(r.speed), type: r.type || "", dither: r.dither || "", halftone_cell: num(r.halftone_cell), halftone_angle: num(r.halftone_angle), inversion: bool(r.inversion), interval: num(r.interval), dot: num(r.dot), pass: num(r.pass), air: bool(r.air), frequency: num(r.frequency), pulse: num(r.pulse), cross: bool(r.cross), })), }; try { const form = new FormData(); form.set("payload", JSON.stringify(payload)); if (photoFile) form.set("photo", photoFile, photoFile.name || "photo"); if (screenFile) form.set("screen", screenFile, screenFile.name || "screen"); const res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" }); const data = await res.json().catch(() => ({})); if (!res.ok) { if (res.status === 401 || res.status === 403) throw new Error("You must be signed in to submit settings."); throw new Error(data?.error || "Submission failed"); } if (!isEdit) { reset(); setPhotoFile(null); setScreenFile(null); setPhotoPreview(""); setScreenPreview(""); } const id = (data as any)?.id ? String((data as any).id) : String(edit?.submissionId ?? ""); if (isEdit) { const q = new URLSearchParams(sp.toString()); q.delete("edit"); router.replace(`/portal/laser-settings?${q.toString()}`); } else { router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`); } } catch (e: any) { setSubmitErr(e?.message || "Submission failed"); } } function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) { setFile(file); if (!file) { setPreview(""); return; } const reader = new FileReader(); reader.onload = () => setPreview(String(reader.result || "")); reader.readAsDataURL(file); } const currentPhotoId = isEdit && typeof edit?.initialValues?.photo === "string" ? (edit!.initialValues.photo as string) : null; const currentScreenId = isEdit && typeof edit?.initialValues?.screen === "string" ? (edit!.initialValues.screen as string) : null; /* ───────────────────────────────────────────────────────────── * RENDER: Sectioned form (CO₂ Galvo) * ───────────────────────────────────────────────────────────── */ return (
{/* Header */}

{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}

{me ? (
Submitting as {meLabel}.
) : meErr ? (
You’re not signed in. Submissions will fail until you sign in.
) : null} {submitErr ?
{submitErr}
: null}
{/* Section: Overview */}

Overview