856 lines
39 KiB
TypeScript
856 lines
39 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 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,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<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">
|
||
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}
|
||
</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>
|
||
);
|
||
}
|