makearmy-app/components/forms/SettingsSubmit.tsx
2025-10-05 12:35:24 -04:00

856 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Opt[]>([]);
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,00011,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<any>;
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 (
<div>
<label className="block text-sm mb-1">
{label} {required ? <span className="text-red-600">*</span> : null}
</label>
<input
className="w-full border rounded px-2 py-1 mb-1"
placeholder="Type to filter…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
<option value="">{placeholder}{loading ? " (loading…)" : ""}</option>
{filtered.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
</div>
);
}
function BoolBox({ label, name, register }: { label: string; name: string; register: UseFormRegister<any> }) {
return (
<label className="flex items-center gap-1 text-sm">
<input type="checkbox" {...register(name)} /> {label}
</label>
);
}
function LabeledInput({
label,
name,
type = "text",
step,
register,
required = false,
min,
max,
}: {
label: string;
name: string;
type?: "text" | "number";
step?: string | number;
register: UseFormRegister<any>;
required?: boolean;
min?: number;
max?: number;
}) {
return (
<div>
<label className="block text-xs mb-1">
{label}{required ? " *" : ""}
</label>
<input
type={type}
step={step}
min={min}
max={max}
className="w-full border rounded px-2 py-1"
{...register(name, { required })}
/>
</div>
);
}
/* ─────────────────────────────────────────────────────────────
* 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<File | null>(null);
const [screenFile, setScreenFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string>("");
const [screenPreview, setScreenPreview] = useState<string>("");
// UX error for auth/submit
const [submitErr, setSubmitErr] = useState<string | null>(null);
// Current signed-in user (banner + uploader)
const [me, setMe] = useState<Me | null>(null);
const [meErr, setMeErr] = useState<string | null>(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<any>({
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 (
<div className="max-w-3xl mx-auto space-y-5">
{/* Header */}
<header className="space-y-1">
<h1 className="text-xl lg:text-2xl font-semibold">{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}</h1>
{me ? (
<div className="text-sm text-muted-foreground">
Submitting as <span className="font-medium">{meLabel}</span>.
</div>
) : meErr ? (
<div className="border border-yellow-600 bg-yellow-50 text-yellow-800 rounded p-2 text-sm">
Youre not signed in. Submissions will fail until you sign in.
</div>
) : null}
{submitErr ? <div className="border border-red-500 text-red-600 bg-red-50 rounded p-2 text-sm">{submitErr}</div> : null}
</header>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Section: Overview */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Overview</h2>
<div className="grid gap-3">
<div>
<label className="block text-sm mb-1">Title <span className="text-red-600">*</span></label>
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
</div>
<div>
<label className="block text-sm mb-1">Notes</label>
<textarea rows={4} className="w-full border rounded px-2 py-1" {...register("setting_notes")} />
</div>
</div>
</section>
{/* Section: Images */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Images</h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm mb-1">
Result Photo {!currentPhotoId ? <span className="text-red-600">*</span> : null}
</label>
{currentPhotoId ? (
<p className="text-xs text-muted-foreground mb-1">Current: <span className="font-mono">{currentPhotoId}</span></p>
) : null}
<input
type="file"
accept="image/*"
data-role="photo"
required={!currentPhotoId && !photoFile}
onChange={(e) => onPick(e.target.files?.[0] ?? null, setPhotoFile, setPhotoPreview)}
/>
<p className="text-xs text-muted-foreground mt-1">
{photoFile ? <>Selected: <span className="font-mono">{photoFile.name}</span></> : "Max 25 MB. JPG/PNG/WebP recommended."}
</p>
{photoPreview ? <img src={photoPreview} alt="Result preview" className="mt-2 rounded border" /> : null}
</div>
<div>
<label className="block text-sm mb-1">Settings Screenshot (optional)</label>
{currentScreenId ? (
<p className="text-xs text-muted-foreground mb-1">Current: <span className="font-mono">{currentScreenId}</span></p>
) : null}
<input
type="file"
accept="image/*"
onChange={(e) => onPick(e.target.files?.[0] ?? null, setScreenFile, setScreenPreview)}
/>
<p className="text-xs text-muted-foreground mt-1">
{screenFile ? <>Selected: <span className="font-mono">{screenFile.name}</span></> : "Max 25 MB. JPG/PNG/WebP recommended."}
</p>
{screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null}
</div>
</div>
</section>
{/* Section: Material */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Material</h2>
<div className="grid md:grid-cols-2 gap-3">
<FilterableSelect label="Material" name="mat" register={register} options={mats.opts} loading={mats.loading} onQuery={mats.setQ} required />
<FilterableSelect label="Coating" name="mat_coat" register={register} options={coats.opts} loading={coats.loading} onQuery={coats.setQ} required />
<FilterableSelect label="Color" name="mat_color" register={register} options={colors.opts} loading={colors.loading} onQuery={colors.setQ} required />
<FilterableSelect label="Opacity" name="mat_opacity" register={register} options={opacs.opts} loading={opacs.loading} onQuery={opacs.setQ} required />
<LabeledInput label="Material Thickness (mm)" name="mat_thickness" type="number" step="0.01" register={register} />
</div>
</section>
{/* Section: Optics */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Optics</h2>
<div className="grid md:grid-cols-2 gap-3">
<FilterableSelect label="Software" name="laser_soft" register={register} options={soft.opts} loading={soft.loading} onQuery={soft.setQ} required />
<FilterableSelect label="Laser Source" name="source" register={register} options={srcs.opts} loading={srcs.loading} onQuery={srcs.setQ} required />
<FilterableSelect label="Scan Lens" name="lens" register={register} options={lens.opts} loading={lens.loading} onQuery={lens.setQ} required />
</div>
<div className="grid md:grid-cols-3 gap-3">
<LabeledInput label="Focus (mm)" name="focus" type="number" min={-10} max={10} step="1" register={register} required />
<LabeledInput label="Repeat All" name="repeat_all" type="number" step="1" register={register} required />
</div>
<p className="text-xs text-muted-foreground">0 = in focus. Negative = closer. Positive = further.</p>
</section>
{/* Section: Lens Options (CO₂ Galvo fixed lists) */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Lens Options</h2>
<div className="grid md:grid-cols-3 gap-3">
<FilterableSelect
label="Lens Configuration"
name="lens_conf"
register={register}
options={lensConf.opts}
loading={lensConf.loading}
onQuery={lensConf.setQ}
required
/>
<FilterableSelect
label="Scan Head Aperture"
name="lens_apt"
register={register}
options={lensApt.opts}
loading={lensApt.loading}
onQuery={lensApt.setQ}
required
/>
<FilterableSelect
label="Beam Expander"
name="lens_exp"
register={register}
options={lensExp.opts}
loading={lensExp.loading}
onQuery={lensExp.setQ}
required
/>
</div>
</section>
{/* Section: Process Settings */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Process Settings</h2>
{/* FILL */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Fill</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => fills.append({ type: "uni" })}>+ Add</button>
</div>
{fills.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<LabeledInput label="Name" name={`fill_settings.${i}.name`} register={register} />
<FilterableSelect label="Type" name={`fill_settings.${i}.type`} register={register} options={fillType.opts} loading={false} onQuery={() => {}} placeholder="Select type" />
<LabeledInput label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`fill_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<LabeledInput label="Power (%)" name={`fill_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`fill_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Interval (mm)" name={`fill_settings.${i}.interval`} type="number" step="0.001" register={register} />
<LabeledInput label="Pass" name={`fill_settings.${i}.pass`} type="number" step="1" register={register} />
<LabeledInput label="Angle (°)" name={`fill_settings.${i}.angle`} type="number" step="1" register={register} />
<LabeledInput label="Increment" name={`fill_settings.${i}.increment`} type="number" step="0.001" register={register} />
<div className="flex items-center gap-3">
<BoolBox label="Auto" name={`fill_settings.${i}.auto`} register={register} />
<BoolBox label="Cross" name={`fill_settings.${i}.cross`} register={register} />
</div>
<div className="flex items-center gap-3">
<BoolBox label="Flood" name={`fill_settings.${i}.flood`} register={register} />
<BoolBox label="Air" name={`fill_settings.${i}.air`} register={register} />
</div>
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => fills.remove(i)}>Remove</button>
</div>
))}
</fieldset>
{/* LINE */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Line</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => lines.append({})}>+ Add</button>
</div>
{lines.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<LabeledInput label="Name" name={`line_settings.${i}.name`} register={register} />
<LabeledInput label="Frequency (kHz)" name={`line_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`line_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<LabeledInput label="Power (%)" name={`line_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`line_settings.${i}.speed`} type="number" step="0.1" register={register} />
<BoolBox label="Perf" name={`line_settings.${i}.perf`} register={register} />
<BoolBox label="Cut" name={`line_settings.${i}.cut`} register={register} />
<BoolBox label="Skip" name={`line_settings.${i}.skip`} register={register} />
<LabeledInput label="Pass" name={`line_settings.${i}.pass`} type="number" step="1" register={register} />
<LabeledInput label="Step" name={`line_settings.${i}.step`} type="number" step="0.001" register={register} />
<LabeledInput label="Size" name={`line_settings.${i}.size`} type="number" step="0.001" register={register} />
<BoolBox label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
<BoolBox label="Air" name={`line_settings.${i}.air`} register={register} />
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => lines.remove(i)}>Remove</button>
</div>
))}
</fieldset>
{/* RASTER */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">Raster</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => rasters.append({ type: "uni", dither: "threshold" })}>+ Add</button>
</div>
{rasters.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
<LabeledInput label="Name" name={`raster_settings.${i}.name`} register={register} />
<LabeledInput label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} type="number" step="0.1" register={register} />
<LabeledInput label="Pulse (ns)" name={`raster_settings.${i}.pulse`} type="number" step="0.1" register={register} />
<FilterableSelect label="Type" name={`raster_settings.${i}.type`} register={register} options={rasterType.opts} loading={false} onQuery={() => {}} placeholder="Select type" />
<FilterableSelect label="Dither" name={`raster_settings.${i}.dither`} register={register} options={rasterDither.opts} loading={false} onQuery={() => {}} placeholder="Select dither" />
<LabeledInput label="Power (%)" name={`raster_settings.${i}.power`} type="number" step="0.1" register={register} />
<LabeledInput label="Speed (mm/s)" name={`raster_settings.${i}.speed`} type="number" step="0.1" register={register} />
<LabeledInput label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} type="number" step="1" register={register} />
<LabeledInput label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} type="number" step="1" register={register} />
<LabeledInput label="Interval (mm)" name={`raster_settings.${i}.interval`} type="number" step="0.001" register={register} />
<LabeledInput label="Dot" name={`raster_settings.${i}.dot`} type="number" step="0.1" register={register} />
<LabeledInput label="Pass" name={`raster_settings.${i}.pass`} type="number" step="1" register={register} />
<BoolBox label="Cross" name={`raster_settings.${i}.cross`} register={register} />
<div className="flex items-center gap-3">
<BoolBox label="Inversion" name={`raster_settings.${i}.inversion`} register={register} />
<BoolBox label="Air" name={`raster_settings.${i}.air`} register={register} />
</div>
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => rasters.remove(i)}>Remove</button>
</div>
))}
</fieldset>
</section>
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50">
{isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings"}
</button>
</form>
</div>
);
}