makearmy-app/components/forms/SettingsSubmit.tsx

541 lines
24 KiB
TypeScript
Raw Normal View History

2025-10-03 21:45:57 -04:00
// components/forms/SettingsSubmit.tsx
2025-09-22 10:37:53 -04:00
"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form";
2025-09-22 16:11:08 -04:00
import { useRouter, useSearchParams } from "next/navigation";
2025-09-22 10:37:53 -04:00
/** CO₂ Galvoonly version (fresh submit path) */
type Target = "settings_co2gal"; // ← limited on purpose
2025-09-22 10:37:53 -04:00
type Opt = { id: string; label: string };
type Me = {
id: string;
2025-09-28 07:04:42 -04:00
username?: string;
email?: string;
};
2025-09-22 10:37:53 -04:00
2025-09-28 14:54:52 -04:00
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
// UI bits
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
2025-09-22 10:37:53 -04:00
function FilterableSelect({
label,
name,
register,
options,
loading,
onQuery,
placeholder = "—",
required = false,
2025-09-22 10:37:53 -04:00
}: {
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]);
2025-09-22 10:37:53 -04:00
const filtered = useMemo(() => {
if (!filter) return options;
const f = filter.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(f));
}, [options, filter]);
return (
<div>
2025-09-22 16:11:08 -04:00
<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)}
/>
2025-09-28 11:15:37 -04:00
<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>
2025-09-22 10:37:53 -04:00
</div>
);
}
2025-09-28 11:15:37 -04:00
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>
);
}
2025-10-03 22:54:05 -04:00
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 })} />
2025-10-03 22:54:05 -04:00
</div>
);
}
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
// Helpers
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
function shortId(s?: string) {
if (!s) return "";
return s.length <= 12 ? s : `${s.slice(0, 8)}${s.slice(-4)}`;
}
2025-10-02 23:12:24 -04:00
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);
}
2025-10-02 23:12:24 -04:00
function useOptions(path: string, forceIncludeId?: string) {
const [opts, setOpts] = useState<Opt[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
2025-09-22 16:11:08 -04:00
2025-10-04 14:02:01 -04:00
useEffect(() => {
let alive = true;
setLoading(true);
2025-10-04 14:02:01 -04:00
(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_coating") url = `${API}/items/material_coating?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_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") url = `${API}/items/laser_source?fields=submission_id,make,model&limit=2000&sort=make,model`, normalize = (rows) => rows.map((r) => ({ 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`;
// the three fixed lists you showed (labels per your request)
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; }
// special label for scan lenses
if (path === "laser_scan_lens") {
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) };
});
};
}
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);
// include currently selected ID if not in page/filter
if (forceIncludeId && !mapped.some((o: any) => String(o.id) === String(forceIncludeId))) {
mapped = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped];
}
2025-09-28 07:23:31 -04:00
const needle = (q || "").trim().toLowerCase();
const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped;
if (alive) setOpts(filtered);
})()
.catch(() => { if (alive) setOpts([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [path, q, forceIncludeId]);
return { opts, loading, setQ };
}
function normalizeForReset(iv: any) {
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),
};
}
type EditInitialValues = {
submission_id: string | number;
setting_title?: string;
setting_notes?: string;
photo?: string | { id?: string } | null;
screen?: string | { id?: string } | null;
mat?: any; mat_coat?: any; mat_color?: any; mat_opacity?: any; mat_thickness?: number | null;
source?: any; lens?: any; focus?: number | null;
laser_soft?: any; repeat_all?: number | null;
lens_conf?: any; lens_apt?: any; lens_exp?: any;
fill_settings?: any[] | null;
line_settings?: any[] | null;
raster_settings?: any[] | null;
};
// ─────────────────────────────────────────────────────────────
// Component (CO₂ Galvo only)
// ─────────────────────────────────────────────────────────────
export default function SettingsSubmit({
mode,
submissionId,
initialValues,
}: {
mode?: "edit";
submissionId?: string | number;
initialValues?: EditInitialValues;
}) {
const router = useRouter();
const sp = useSearchParams();
const target: Target = "settings_co2gal"; // locked
// files
2025-09-22 16:11:08 -04:00
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [screenFile, setScreenFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string>("");
const [screenPreview, setScreenPreview] = useState<string>("");
2025-09-22 10:37:53 -04:00
// errors / me
const [submitErr, setSubmitErr] = useState<string | null>(null);
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) setMe(j || null); })
2025-10-05 08:50:01 -04:00
.catch(() => { if (alive) setMeErr("not-signed-in"); });
return () => { alive = false; };
}, []);
const isEdit = mode === "edit" && submissionId != null;
const current = useMemo(() => (isEdit && initialValues ? normalizeForReset(initialValues) : null), [isEdit, initialValues]);
2025-09-28 11:44:34 -04:00
// options (galvo)
const mats = useOptions("material", current?.mat);
const coats = useOptions("material_coating", current?.mat_coat);
const colors= useOptions("material_color", current?.mat_color);
const opacs = useOptions("material_opacity", current?.mat_opacity);
const soft = useOptions("laser_software", current?.laser_soft);
const srcs = useOptions("laser_source", current?.source);
const lens = useOptions("laser_scan_lens", current?.lens);
2025-10-04 14:02:01 -04:00
// three fixed lists (labels per your request)
const lensConf = useOptions("laser_scan_lens_config", current?.lens_conf); // "Lens Configuration"
const lensApt = useOptions("laser_scan_lens_apt", current?.lens_apt); // "Scan Head Aperture"
const lensExp = useOptions("laser_scan_lens_exp", current?.lens_exp); // "Beam Expander"
2025-09-28 14:54:52 -04:00
// form
2025-09-28 14:54:52 -04:00
const {
register,
handleSubmit,
control,
reset,
2025-10-04 20:40:14 -04:00
setValue,
2025-10-04 20:14:47 -04:00
getValues,
2025-09-28 14:54:52 -04:00
formState: { isSubmitting },
2025-10-02 23:19:17 -04:00
} = useForm<any>({
defaultValues: {
setting_title: "",
setting_notes: "",
// required relations
mat: "", mat_coat: "", mat_color: "", mat_opacity: "",
source: "", lens: "", laser_soft: "",
lens_conf: "", lens_apt: "", lens_exp: "",
// numerics
focus: "", repeat_all: "", mat_thickness: "",
// repeaters (kept, but not required)
fill_settings: [], line_settings: [], raster_settings: [],
2025-10-02 23:19:17 -04:00
},
2025-09-28 14:54:52 -04:00
});
2025-10-05 08:50:01 -04:00
const fills = useFieldArray({ control, name: "fill_settings" });
const lines = useFieldArray({ control, name: "line_settings" });
2025-10-02 23:19:17 -04:00
const rasters = useFieldArray({ control, name: "raster_settings" });
2025-09-28 14:54:52 -04:00
// prefill for edit
useEffect(() => {
if (!isEdit || !current) return;
reset({
setting_title: current.setting_title ?? "",
setting_notes: current.setting_notes ?? "",
photo: current.photo ?? null,
screen: current.screen ?? null,
mat: current.mat ?? "",
mat_coat: current.mat_coat ?? "",
mat_color: current.mat_color ?? "",
mat_opacity: current.mat_opacity ?? "",
mat_thickness: current.mat_thickness ?? "",
source: current.source ?? "",
lens: current.lens ?? "",
focus: current.focus ?? "",
laser_soft: current.laser_soft ?? "",
repeat_all: current.repeat_all ?? "",
lens_conf: current.lens_conf ?? "",
lens_apt: current.lens_apt ?? "",
lens_exp: current.lens_exp ?? "",
fill_settings: current.fill_settings ?? [],
line_settings: current.line_settings ?? [],
raster_settings: current.raster_settings ?? [],
});
}, [isEdit, current, reset]);
// keep selects stable when options hydrate
useEffect(() => {
if (!isEdit || !current) return;
const names = ["mat","mat_coat","mat_color","mat_opacity","source","lens","laser_soft","lens_conf","lens_apt","lens_exp"] as const;
const values = getValues();
names.forEach((n) => {
const cur = (current as any)[n];
const now = (values as any)[n];
if (cur && (now == null || now === "")) setValue(n as any, cur, { shouldDirty: false, shouldValidate: false });
});
}, [isEdit, current, getValues, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, srcs.opts, lens.opts, soft.opts, lensConf.opts, lensApt.opts, lensExp.opts]);
2025-10-05 08:50:01 -04:00
// numeric / bool coercers
const num = (v: any) => (v === "" || v == null ? null : Number(v));
2025-10-02 23:19:17 -04:00
const bool = (v: any) => !!v;
2025-09-28 14:54:52 -04:00
// submit
2025-10-02 23:19:17 -04:00
async function onSubmit(values: any) {
setSubmitErr(null);
2025-09-28 07:41:15 -04:00
// required-photo logic: need a file unless edit already has a photo id
const currentPhotoId = isEdit && typeof current?.photo === "string" ? current!.photo as string : null;
if (!currentPhotoId && !photoFile) {
2025-10-02 23:19:17 -04:00
(document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus();
return;
}
2025-09-28 07:41:15 -04:00
// build payload with only required + notes/images + repeaters
const payload: any = {
2025-10-05 08:50:01 -04:00
target,
2025-10-02 23:19:17 -04:00
setting_title: values.setting_title,
setting_notes: values.setting_notes || "",
// required fields per your list
source: values.source || null,
lens: values.lens || null,
lens_conf: values.lens_conf || null,
lens_apt: values.lens_apt || null,
lens_exp: values.lens_exp || null,
focus: num(values.focus),
2025-10-02 23:19:17 -04:00
mat: values.mat || null,
mat_coat: values.mat_coat || null,
mat_color: values.mat_color || null,
mat_opacity: values.mat_opacity || null,
2025-10-05 08:50:01 -04:00
laser_soft: values.laser_soft || null,
repeat_all: num(values.repeat_all),
// nice-to-have
mat_thickness: num(values.mat_thickness),
// server auto-sets uploader from owner, but include a mirror if available
2025-10-05 08:24:51 -04:00
...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}),
// repeaters (optional pass-through)
2025-10-02 23:19:17 -04:00
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),
2025-10-02 23:19:17 -04:00
})),
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),
2025-10-02 23:19:17 -04:00
})),
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),
2025-10-02 23:19:17 -04:00
})),
};
2025-10-02 23:12:24 -04:00
// edit meta
if (isEdit && submissionId != null) {
payload.mode = "edit";
payload.submission_id = submissionId;
2025-10-04 20:40:14 -04:00
}
try {
const form = new FormData();
form.set("payload", JSON.stringify(payload)); // server route reads "payload"
if (photoFile) form.set("photo", photoFile, photoFile.name || "photo");
if (screenFile) form.set("screen", screenFile, screenFile.name || "screen");
2025-09-28 07:41:15 -04:00
const res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" });
2025-10-02 23:19:17 -04:00
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.");
2025-10-03 22:54:05 -04:00
throw new Error((data as any)?.error || "Submission failed");
2025-10-02 23:12:24 -04:00
}
2025-10-02 23:19:17 -04:00
2025-10-03 21:45:57 -04:00
if (!isEdit) {
reset();
setPhotoFile(null); setScreenFile(null);
setPhotoPreview(""); setScreenPreview("");
2025-10-03 21:45:57 -04:00
}
2025-10-02 23:19:17 -04:00
const id = (data as any)?.id ? String((data as any).id) : String(submissionId ?? "");
2025-10-03 22:54:05 -04:00
if (isEdit) {
const q = new URLSearchParams(sp.toString()); q.delete("edit");
2025-10-03 22:54:05 -04:00
router.replace(`/portal/laser-settings?${q.toString()}`);
} else {
router.push(`/submit/settings/success?target=${encodeURIComponent(target)}&id=${encodeURIComponent(id)}`);
}
2025-10-02 23:19:17 -04:00
} catch (e: any) {
setSubmitErr(e?.message || "Submission failed");
2025-09-28 14:54:52 -04:00
}
2025-10-02 23:19:17 -04:00
}
2025-09-28 07:41:15 -04:00
2025-10-02 23:19:17 -04:00
function onPick(file: File | null, setFile: (f: File | null) => void, setPreview: (s: string) => void) {
setFile(file);
2025-10-05 08:50:01 -04:00
if (!file) { setPreview(""); return; }
2025-10-02 23:19:17 -04:00
const reader = new FileReader();
reader.onload = () => setPreview(String(reader.result || ""));
reader.readAsDataURL(file);
}
2025-09-28 07:41:15 -04:00
const currentPhotoId = isEdit && typeof current?.photo === "string" ? (current!.photo as string) : null;
const currentScreenId = isEdit && typeof current?.screen === "string" ? (current!.screen as string) : null;
2025-09-28 07:41:15 -04:00
2025-10-02 23:19:17 -04:00
return (
<div className="max-w-3xl mx-auto space-y-4">
2025-09-22 10:37:53 -04:00
{/* Banner */}
2025-10-02 23:19:17 -04:00
{me ? (
<div className="text-sm text-muted-foreground">
Submitting as <span className="font-medium">{me.username || me.email || `User ${shortId(me.id)}`}</span>.
2025-10-02 23:12:24 -04:00
</div>
2025-10-02 23:19:17 -04:00
) : 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.
2025-10-02 23:12:24 -04:00
</div>
2025-10-02 23:19:17 -04:00
) : null}
2025-09-22 10:37:53 -04:00
{submitErr ? <div className="border border-red-500 text-red-600 bg-red-50 rounded p-2 text-sm">{submitErr}</div> : null}
2025-10-02 23:19:17 -04:00
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
2025-10-02 23:19:17 -04:00
{/* Title */}
<div>
<label className="block text-sm mb-1">Title <span className="text-red-600">*</span></label>
2025-10-02 23:19:17 -04:00
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
</div>
{/* Images */}
<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">{shortId(currentPhotoId)}</span></p>}
<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>
2025-10-02 23:19:17 -04:00
{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">{shortId(currentScreenId)}</span></p>}
<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>
2025-10-02 23:19:17 -04:00
{screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null}
</div>
</div>
2025-10-02 23:12:24 -04:00
2025-10-02 23:19:17 -04:00
{/* Notes */}
<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>
{/* Required Selects (materials, source, lens) */}
2025-10-02 23:19:17 -04:00
<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 />
<FilterableSelect label="Laser Source" name="source" register={register} options={srcs.opts} loading={srcs.loading} onQuery={srcs.setQ} required />
<FilterableSelect label="Lens" name="lens" register={register} options={lens.opts} loading={lens.loading} onQuery={lens.setQ} required />
2025-10-02 23:19:17 -04:00
</div>
{/* Numeric requireds */}
2025-10-02 23:19:17 -04:00
<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 />
2025-10-03 22:54:05 -04:00
<LabeledInput label="Material Thickness (mm)" name="mat_thickness" type="number" step="0.01" register={register} />
<p className="text-xs text-muted-foreground md:col-span-3">0 = in focus. Negative = closer. Positive = further.</p>
2025-10-02 23:19:17 -04:00
</div>
{/* Lens Configuration block (all required) */}
2025-10-02 23:19:17 -04:00
<fieldset className="border rounded p-3 space-y-2">
<legend className="font-semibold">Lens Options</legend>
<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 />
2025-10-02 23:19:17 -04:00
</div>
</fieldset>
{/* Optional repeaters kept for parity (not required) */}
{/* Feel free to hide these until after MVP if you want */}
2025-10-02 23:19:17 -04:00
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50">
2025-10-02 23:19:17 -04:00
{isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings"}
</button>
</form>
</div>
);
2025-09-28 11:15:37 -04:00
}