makearmy-app/components/forms/SettingsSubmit.tsx

857 lines
39 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
2025-10-05 12:30:19 -04:00
/** CO₂ Galvo implementation only (per spec). Other targets intentionally omitted. */
type Target = "settings_co2gal";
2025-10-05 12:35:24 -04:00
// 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";
2025-09-22 10:37:53 -04:00
type Opt = { id: string; label: string };
2025-10-05 12:30:19 -04:00
type Me = { id: string; 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-05 12:30:19 -04:00
/*
* Local enums (simple, stable lists that UI controls use)
* */
2025-10-05 11:54:02 -04:00
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 }));
2025-10-05 12:30:19 -04:00
/*
* Edit-mode initial values
* */
2025-10-05 11:54:02 -04:00
type EditInitialValues = {
2025-10-05 12:30:19 -04:00
submission_id: string | number;
2025-10-05 11:54:02 -04:00
setting_title?: string;
setting_notes?: string;
2025-10-05 12:30:19 -04:00
photo?: string | { id?: string } | null;
screen?: string | { id?: string } | null;
2025-10-05 11:54:02 -04:00
2025-10-05 12:30:19 -04:00
/* material / optics */
2025-10-05 11:54:02 -04:00
mat?: any;
mat_coat?: any;
mat_color?: any;
mat_opacity?: any;
mat_thickness?: number | null;
source?: any;
lens?: any;
focus?: number | null;
2025-10-05 12:30:19 -04:00
/* 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 */
2025-10-05 11:54:02 -04:00
laser_soft?: any;
repeat_all?: number | null;
2025-10-05 12:30:19 -04:00
/* repeaters */
2025-10-05 11:54:02 -04:00
fill_settings?: any[] | null;
line_settings?: any[] | null;
raster_settings?: any[] | null;
};
2025-10-05 12:35:24 -04:00
type BaseProps = { initialTarget?: ExternalTarget };
2025-10-05 12:30:19 -04:00
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;
}
2025-10-05 11:54:02 -04:00
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),
2025-10-05 12:30:19 -04:00
fill_settings: (iv.fill_settings ?? []).map((r: any) => ({ ...r, type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni") })),
2025-10-05 11:54:02 -04:00
raster_settings: (iv.raster_settings ?? []).map((r: any) => ({
...r,
type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"),
dither: normalizeEnums(
r?.dither,
2025-10-05 12:30:19 -04:00
["threshold", "ordered", "atkinson", "dither", "stucki", "jarvis", "newsprint", "halftone", "sketch", "grayscale"],
2025-10-05 11:54:02 -04:00
"threshold"
),
})),
line_settings: (iv.line_settings ?? []).map((r: any) => ({ ...r })),
};
}
2025-10-05 12:30:19 -04:00
/*
* Options loader (Materials, Software, Source, Lens + fixed lens lists)
* */
function useOptions(path: string, forceIncludeId?: string) {
const [opts, setOpts] = useState<Opt[]>([]);
2025-10-05 11:54:02 -04:00
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
useEffect(() => {
let alive = true;
setLoading(true);
(async () => {
let url = "";
let normalize: (rows: any[]) => Opt[] = (rows) =>
2025-10-05 12:30:19 -04:00
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") {
2025-10-05 11:54:02 -04:00
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) }));
2025-10-05 12:30:19 -04:00
} else if (path === "laser_software") {
2025-10-05 11:54:02 -04:00
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
2025-10-05 12:30:19 -04:00
} else if (path === "laser_source_co2_galvo") {
// Only show CO₂-range sources (10,00011,000 nm) to match galvo
2025-10-05 11:54:02 -04:00
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
normalize = (rows) => {
2025-10-05 12:30:19 -04:00
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;
2025-10-05 11:54:02 -04:00
})
2025-10-05 12:30:19 -04:00
.map((r: any) => ({
2025-10-05 11:54:02 -04:00
id: String(r.submission_id),
2025-10-05 12:30:19 -04:00
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id),
2025-10-05 11:54:02 -04:00
}));
};
2025-10-05 12:30:19 -04:00
} 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;
2025-10-05 11:54:02 -04:00
};
2025-10-05 12:30:19 -04:00
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") {
2025-10-05 11:54:02 -04:00
url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`;
2025-10-05 12:30:19 -04:00
} else if (path === "laser_scan_lens_apt") {
2025-10-05 11:54:02 -04:00
url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`;
2025-10-05 12:30:19 -04:00
} else if (path === "laser_scan_lens_exp") {
2025-10-05 11:54:02 -04:00
url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`;
} else {
2025-10-05 12:30:19 -04:00
setOpts([]);
2025-10-05 11:54:02 -04:00
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 ?? [];
2025-10-05 12:30:19 -04:00
let mapped = normalize(rows);
2025-10-05 11:54:02 -04:00
2025-10-05 12:30:19 -04:00
if (forceIncludeId && !mapped.some((o) => String(o.id) === String(forceIncludeId))) {
mapped = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped];
2025-10-05 11:54:02 -04:00
}
const needle = (q || "").trim().toLowerCase();
2025-10-05 12:30:19 -04:00
const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped;
if (alive) setOpts(filtered);
2025-10-05 11:54:02 -04:00
})()
2025-10-05 12:30:19 -04:00
.catch(() => setOpts([]))
2025-10-05 11:54:02 -04:00
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
2025-10-05 12:30:19 -04:00
}, [path, q, forceIncludeId]);
2025-10-05 11:54:02 -04:00
2025-10-05 12:30:19 -04:00
return { opts, loading, setQ };
2025-10-05 11:54:02 -04:00
}
2025-10-05 12:30:19 -04:00
/*
* Small UI bits
* */
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("");
2025-10-05 12:30:19 -04:00
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) => (
2025-10-05 12:30:19 -04:00
<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>
2025-10-05 11:54:02 -04:00
<label className="block text-xs mb-1">
2025-10-05 12:30:19 -04:00
{label}{required ? " *" : ""}
2025-10-05 11:54:02 -04:00
</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-05 12:30:19 -04:00
/*
* Component (CO Galvo only)
* */
2025-10-05 11:54:02 -04:00
export default function SettingsSubmit(props: CreateProps | EditProps) {
const router = useRouter();
const sp = useSearchParams();
2025-10-02 23:12:24 -04:00
2025-10-05 11:54:02 -04:00
const isEdit = isEditProps(props);
const edit = isEdit ? props : null;
2025-10-02 23:12:24 -04:00
2025-10-05 12:30:19 -04:00
// CO₂ Galvo is the only target in this implementation.
const target: Target = "settings_co2gal";
2025-10-05 11:54:02 -04:00
// Image inputs
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
2025-10-05 11:54:02 -04:00
// UX error for auth/submit
const [submitErr, setSubmitErr] = useState<string | null>(null);
2025-10-05 11:54:02 -04:00
// 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)))
2025-10-05 11:54:02 -04:00
.then((j) => { if (!alive) return; setMe(j || null); })
2025-10-05 08:50:01 -04:00
.catch(() => { if (alive) setMeErr("not-signed-in"); });
return () => { alive = false; };
}, []);
2025-10-05 12:30:19 -04:00
const meLabel = me?.username ?? me?.email ?? "";
2025-09-28 11:44:34 -04:00
2025-10-05 11:54:02 -04:00
// For edit-mode, compute normalized current values once
const current = useMemo(
() => (isEdit && edit?.initialValues ? normalizeForReset(edit.initialValues) : null),
[isEdit, edit?.initialValues]
);
2025-10-04 14:02:01 -04:00
2025-10-05 12:30:19 -04:00
// Options (CO₂ Galvo)
2025-10-05 11:54:02 -04:00
const mats = useOptions("material", current?.mat || undefined);
const coats = useOptions("material_coating", current?.mat_coat || undefined);
2025-10-05 12:30:19 -04:00
const colors= useOptions("material_color", current?.mat_color || undefined);
2025-10-05 11:54:02 -04:00
const opacs = useOptions("material_opacity", current?.mat_opacity || undefined);
2025-10-05 12:30:19 -04:00
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);
2025-10-05 11:54:02 -04:00
2025-10-05 12:30:19 -04:00
// 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
2025-10-05 11:54:02 -04:00
2025-10-05 12:30:19 -04:00
// 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) => {} };
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: "",
2025-10-05 12:30:19 -04:00
// material / optics
2025-10-05 11:54:02 -04:00
mat: "",
mat_coat: "",
mat_color: "",
mat_opacity: "",
mat_thickness: "",
source: "",
lens: "",
focus: "",
laser_soft: "",
repeat_all: "",
2025-10-05 12:30:19 -04:00
// CO₂ Galvo lens dropdowns
2025-10-05 11:54:02 -04:00
lens_conf: "",
lens_apt: "",
lens_exp: "",
2025-10-05 12:30:19 -04:00
// repeaters
2025-10-05 11:54:02 -04:00
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
2025-10-05 11:54:02 -04:00
// Prefill the form in edit mode
useEffect(() => {
2025-10-05 11:54:02 -04:00
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,
2025-10-05 12:30:19 -04:00
2025-10-05 11:54:02 -04:00
mat: iv.mat ?? "",
mat_coat: iv.mat_coat ?? "",
mat_color: iv.mat_color ?? "",
mat_opacity: iv.mat_opacity ?? "",
mat_thickness: iv.mat_thickness ?? "",
2025-10-05 12:30:19 -04:00
2025-10-05 11:54:02 -04:00
source: iv.source ?? "",
lens: iv.lens ?? "",
focus: iv.focus ?? "",
2025-10-05 12:30:19 -04:00
2025-10-05 11:54:02 -04:00
laser_soft: iv.laser_soft ?? "",
repeat_all: iv.repeat_all ?? "",
2025-10-05 12:30:19 -04:00
2025-10-05 11:54:02 -04:00
lens_conf: iv.lens_conf ?? "",
lens_apt: iv.lens_apt ?? "",
lens_exp: iv.lens_exp ?? "",
2025-10-05 12:30:19 -04:00
2025-10-05 11:54:02 -04:00
fill_settings: iv.fill_settings ?? [],
line_settings: iv.line_settings ?? [],
raster_settings: iv.raster_settings ?? [],
});
}
}, [isEdit, edit?.initialValues, reset]);
2025-10-05 12:30:19 -04:00
// After reset, force selects once to show current ids
useEffect(() => {
if (!isEdit || !current) return;
2025-10-05 12:30:19 -04:00
const names = ["laser_soft","mat","mat_coat","mat_color","mat_opacity","source","lens","lens_conf","lens_apt","lens_exp"] as const;
const values = getValues();
2025-10-05 12:30:19 -04:00
names.forEach((name) => {
2025-10-05 11:54:02 -04:00
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 });
}
});
2025-10-05 12:30:19 -04:00
}, [isEdit, current, getValues, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, soft.opts, srcs.opts, lens.opts, lensConf.opts, lensApt.opts, lensExp.opts]);
2025-10-05 11:54:02 -04:00
function num(v: any) { return 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
2025-10-05 12:30:19 -04:00
/*
* SUBMIT (multipart + payload JSON). CO Galvo requireds enforced server-side,
* but we mark client-required for UX.
* */
2025-10-02 23:19:17 -04:00
async function onSubmit(values: any) {
setSubmitErr(null);
2025-09-28 07:41:15 -04:00
2025-10-05 12:30:19 -04:00
// 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) {
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
2025-10-05 12:30:19 -04:00
const payload: any = {
target, // "settings_co2gal"
...(isEdit ? { mode: "edit" as const, submission_id: edit!.submissionId } : {}),
/* required basics */
2025-10-02 23:19:17 -04:00
setting_title: values.setting_title,
setting_notes: values.setting_notes || "",
2025-10-05 12:30:19 -04:00
/* material / optics */
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 11:54:02 -04:00
mat_thickness: num(values.mat_thickness),
2025-10-05 12:30:19 -04:00
2025-10-05 11:54:02 -04:00
source: values.source || null,
lens: values.lens || null,
focus: num(values.focus),
2025-10-05 12:30:19 -04:00
/* 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
2025-10-05 08:24:51 -04:00
2025-10-05 12:30:19 -04:00
/* shared */
laser_soft: values.laser_soft || null,
repeat_all: num(values.repeat_all),
2025-10-05 11:54:02 -04:00
2025-10-05 12:30:19 -04:00
/* repeaters */
2025-10-02 23:19:17 -04:00
fill_settings: (values.fill_settings || []).map((r: any) => ({
name: r.name || "",
2025-10-05 11:54:02 -04:00
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 || "",
2025-10-05 11:54:02 -04:00
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 || "",
2025-10-05 11:54:02 -04:00
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
try {
const form = new FormData();
2025-10-05 12:30:19 -04:00
form.set("payload", JSON.stringify(payload));
2025-10-05 11:54:02 -04:00
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
2025-10-05 12:30:19 -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-05 12:30:19 -04:00
throw new Error(data?.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();
2025-10-05 11:54:02 -04:00
setPhotoFile(null);
setScreenFile(null);
setPhotoPreview("");
setScreenPreview("");
2025-10-03 21:45:57 -04:00
}
2025-10-02 23:19:17 -04:00
2025-10-05 11:54:02 -04:00
const id = (data as any)?.id ? String((data as any).id) : String(edit?.submissionId ?? "");
2025-10-03 22:54:05 -04:00
if (isEdit) {
2025-10-05 11:54:02 -04:00
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
2025-10-05 12:30:19 -04:00
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;
2025-09-28 07:41:15 -04:00
2025-10-05 12:30:19 -04:00
/*
* RENDER: Sectioned form (CO Galvo)
* */
2025-10-02 23:19:17 -04:00
return (
2025-10-05 12:30:19 -04:00
<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>
2025-10-02 23:19:17 -04:00
{me ? (
<div className="text-sm text-muted-foreground">
2025-10-05 11:54:02 -04:00
Submitting as <span className="font-medium">{meLabel}</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-10-05 12:30:19 -04:00
{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>
2025-10-02 23:19:17 -04:00
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
</div>
2025-10-05 12:30:19 -04:00
<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>
2025-10-05 11:54:02 -04:00
</div>
2025-10-05 12:30:19 -04:00
</section>
2025-10-02 23:19:17 -04:00
2025-10-05 12:30:19 -04:00
{/* Section: Images */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Images</h2>
2025-10-02 23:19:17 -04:00
<div className="grid md:grid-cols-2 gap-4">
<div>
2025-10-05 11:54:02 -04:00
<label className="block text-sm mb-1">
Result Photo {!currentPhotoId ? <span className="text-red-600">*</span> : null}
</label>
2025-10-05 12:30:19 -04:00
{currentPhotoId ? (
<p className="text-xs text-muted-foreground mb-1">Current: <span className="font-mono">{currentPhotoId}</span></p>
) : null}
2025-10-05 11:54:02 -04:00
<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>
2025-10-05 12:30:19 -04:00
{currentScreenId ? (
<p className="text-xs text-muted-foreground mb-1">Current: <span className="font-mono">{currentScreenId}</span></p>
) : null}
2025-10-05 11:54:02 -04:00
<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-05 12:30:19 -04:00
</section>
2025-10-02 23:12:24 -04:00
2025-10-05 12:30:19 -04:00
{/* 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} />
2025-10-02 23:19:17 -04:00
</div>
2025-10-05 12:30:19 -04:00
</section>
2025-10-02 23:19:17 -04:00
2025-10-05 12:30:19 -04:00
{/* Section: Optics */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Optics</h2>
2025-10-02 23:19:17 -04:00
<div className="grid md:grid-cols-2 gap-3">
2025-10-05 12:30:19 -04:00
<FilterableSelect label="Software" name="laser_soft" register={register} options={soft.opts} loading={soft.loading} onQuery={soft.setQ} required />
2025-10-05 11:54:02 -04:00
<FilterableSelect label="Laser Source" name="source" register={register} options={srcs.opts} loading={srcs.loading} onQuery={srcs.setQ} required />
2025-10-05 12:30:19 -04:00
<FilterableSelect label="Scan Lens" name="lens" register={register} options={lens.opts} loading={lens.loading} onQuery={lens.setQ} required />
2025-10-02 23:19:17 -04:00
</div>
<div className="grid md:grid-cols-3 gap-3">
2025-10-05 11:54:02 -04:00
<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-02 23:19:17 -04:00
</div>
2025-10-05 12:30:19 -04:00
<p className="text-xs text-muted-foreground">0 = in focus. Negative = closer. Positive = further.</p>
</section>
2025-10-02 23:19:17 -04:00
2025-10-05 12:30:19 -04:00
{/* 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>
2025-10-05 11:54:02 -04:00
{/* FILL */}
2025-10-02 23:19:17 -04:00
<fieldset className="border rounded p-3 space-y-2">
2025-10-05 11:54:02 -04:00
<div className="flex items-center justify-between">
2025-10-05 12:30:19 -04:00
<legend className="font-semibold">Fill</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => fills.append({ type: "uni" })}>+ Add</button>
2025-10-02 23:19:17 -04:00
</div>
2025-10-05 11:54:02 -04:00
{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} />
2025-10-05 12:30:19 -04:00
<FilterableSelect label="Type" name={`fill_settings.${i}.type`} register={register} options={fillType.opts} loading={false} onQuery={() => {}} placeholder="Select type" />
2025-10-05 11:54:02 -04:00
<LabeledInput label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} type="number" step="0.1" register={register} />
2025-10-05 12:30:19 -04:00
<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} />
2025-10-05 11:54:02 -04:00
<div className="flex items-center gap-3">
2025-10-05 12:30:19 -04:00
<BoolBox label="Auto" name={`fill_settings.${i}.auto`} register={register} />
2025-10-05 11:54:02 -04:00
<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} />
2025-10-05 12:30:19 -04:00
<BoolBox label="Air" name={`fill_settings.${i}.air`} register={register} />
2025-10-05 11:54:02 -04:00
</div>
2025-10-05 12:30:19 -04:00
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => fills.remove(i)}>Remove</button>
2025-10-05 11:54:02 -04:00
</div>
))}
</fieldset>
{/* LINE */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
2025-10-05 12:30:19 -04:00
<legend className="font-semibold">Line</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={() => lines.append({})}>+ Add</button>
2025-10-05 11:54:02 -04:00
</div>
{lines.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
2025-10-05 12:30:19 -04:00
<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>
2025-10-05 11:54:02 -04:00
</div>
))}
2025-10-02 23:19:17 -04:00
</fieldset>
2025-10-05 11:54:02 -04:00
{/* RASTER */}
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
2025-10-05 12:30:19 -04:00
<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>
2025-10-05 11:54:02 -04:00
</div>
{rasters.fields.map((f, i) => (
<div key={f.id} className="grid md:grid-cols-4 gap-2">
2025-10-05 12:30:19 -04:00
<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} />
2025-10-05 11:54:02 -04:00
<div className="flex items-center gap-3">
<BoolBox label="Inversion" name={`raster_settings.${i}.inversion`} register={register} />
2025-10-05 12:30:19 -04:00
<BoolBox label="Air" name={`raster_settings.${i}.air`} register={register} />
2025-10-05 11:54:02 -04:00
</div>
2025-10-05 12:30:19 -04:00
<button type="button" className="px-2 py-1 border rounded md:col-span-4" onClick={() => rasters.remove(i)}>Remove</button>
2025-10-05 11:54:02 -04:00
</div>
))}
</fieldset>
2025-10-05 12:30:19 -04:00
</section>
2025-10-02 23:19:17 -04:00
2025-10-05 12:30:19 -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
}