540 lines
24 KiB
TypeScript
540 lines
24 KiB
TypeScript
// 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–only version (fresh submit path) */
|
||
|
||
type Target = "settings_co2gal"; // ← limited on purpose
|
||
type Opt = { id: string; label: string };
|
||
type Me = {
|
||
id: string;
|
||
username?: string;
|
||
email?: string;
|
||
};
|
||
|
||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// 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>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────
|
||
function shortId(s?: string) {
|
||
if (!s) return "";
|
||
return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`;
|
||
}
|
||
|
||
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 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_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];
|
||
}
|
||
|
||
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
|
||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||
const [screenFile, setScreenFile] = useState<File | null>(null);
|
||
const [photoPreview, setPhotoPreview] = useState<string>("");
|
||
const [screenPreview, setScreenPreview] = useState<string>("");
|
||
|
||
// 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); })
|
||
.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]);
|
||
|
||
// 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);
|
||
|
||
// 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"
|
||
|
||
// form
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
control,
|
||
reset,
|
||
setValue,
|
||
getValues,
|
||
formState: { isSubmitting },
|
||
} = 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: [],
|
||
},
|
||
});
|
||
|
||
const fills = useFieldArray({ control, name: "fill_settings" });
|
||
const lines = useFieldArray({ control, name: "line_settings" });
|
||
const rasters = useFieldArray({ control, name: "raster_settings" });
|
||
|
||
// 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]);
|
||
|
||
// numeric / bool coercers
|
||
const num = (v: any) => (v === "" || v == null ? null : Number(v));
|
||
const bool = (v: any) => !!v;
|
||
|
||
// submit
|
||
async function onSubmit(values: any) {
|
||
setSubmitErr(null);
|
||
|
||
// 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) {
|
||
(document.querySelector('input[type="file"][data-role="photo"]') as HTMLInputElement | null)?.focus();
|
||
return;
|
||
}
|
||
|
||
// build payload with only required + notes/images + repeaters
|
||
const payload: any = {
|
||
target,
|
||
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),
|
||
mat: values.mat || null,
|
||
mat_coat: values.mat_coat || null,
|
||
mat_color: values.mat_color || null,
|
||
mat_opacity: values.mat_opacity || null,
|
||
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
|
||
...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}),
|
||
|
||
// repeaters (optional – pass-through)
|
||
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),
|
||
})),
|
||
};
|
||
|
||
// edit meta
|
||
if (isEdit && submissionId != null) {
|
||
payload.mode = "edit";
|
||
payload.submission_id = submissionId;
|
||
}
|
||
|
||
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");
|
||
|
||
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 as any)?.error || "Submission failed");
|
||
}
|
||
|
||
if (!isEdit) {
|
||
reset();
|
||
setPhotoFile(null); setScreenFile(null);
|
||
setPhotoPreview(""); setScreenPreview("");
|
||
}
|
||
|
||
const id = (data as any)?.id ? String((data as any).id) : String(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 current?.photo === "string" ? (current!.photo as string) : null;
|
||
const currentScreenId = isEdit && typeof current?.screen === "string" ? (current!.screen as string) : null;
|
||
|
||
return (
|
||
<div className="max-w-3xl mx-auto space-y-4">
|
||
|
||
{/* Banner */}
|
||
{me ? (
|
||
<div className="text-sm text-muted-foreground">
|
||
Submitting as <span className="font-medium">{me.username || me.email || `User ${shortId(me.id)}`}</span>.
|
||
</div>
|
||
) : meErr ? (
|
||
<div className="border border-yellow-600 bg-yellow-50 text-yellow-800 rounded p-2 text-sm">
|
||
You’re 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}
|
||
|
||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||
|
||
{/* Title */}
|
||
<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>
|
||
|
||
{/* 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>
|
||
{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>
|
||
{screenPreview ? <img src={screenPreview} alt="Settings preview" className="mt-2 rounded border" /> : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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) */}
|
||
<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 />
|
||
</div>
|
||
|
||
{/* Numeric requireds */}
|
||
<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 />
|
||
<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>
|
||
</div>
|
||
|
||
{/* Lens Configuration block (all required) */}
|
||
<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 />
|
||
</div>
|
||
</fieldset>
|
||
|
||
{/* Optional repeaters kept for parity (not required) */}
|
||
{/* Feel free to hide these until after MVP if you want */}
|
||
|
||
<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>
|
||
);
|
||
}
|