makearmy-app/components/forms/SettingsSubmit.tsx

539 lines
22 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 } from "next/navigation";
/**
* From-scratch CO₂ Galvo form that follows the data sheet.
* - Prefill works via reset() with raw IDs.
* - Submits via /app/api/settings (this file does not assume any old helpers).
* - Lens options belong to Rig & Optics (not a separate "Lens Options" section).
*/
type Target = "settings_co2gal";
type Opt = { id: string; label: string };
type Me = { id: string; username?: string; email?: string };
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
type EditInitialValues = {
submission_id: string | number;
setting_title?: string;
setting_notes?: string;
photo?: string | null;
screen?: string | null;
// Material
mat?: string | null;
mat_coat?: string | null;
mat_color?: string | null;
mat_opacity?: string | null;
mat_thickness?: number | null;
// Rig & Optics
laser_soft?: string | null;
source?: string | null; // submission_id
lens?: string | null;
focus?: number | null;
// CO2 Galvo triplet (Rig & Optics)
lens_conf?: string | null;
lens_apt?: string | null;
lens_exp?: string | null;
repeat_all?: number | null;
// Repeaters
fill_settings?: any[] | null;
line_settings?: any[] | null;
raster_settings?: any[] | null;
};
type BaseProps = { mode?: "create" | "edit"; submissionId?: string | number; initialValues?: EditInitialValues | null };
export default function SettingsSubmit({ mode = "create", submissionId, initialValues }: BaseProps) {
const router = useRouter();
const isEdit = mode === "edit";
const [me, setMe] = useState<Me | null>(null);
const [submitErr, setSubmitErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
fetch(`/api/me`, { cache: "no-store", credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((j) => alive && setMe(j || null))
.catch(() => alive && setMe(null));
return () => {
alive = false;
};
}, []);
// Options loaders (raw Directus reads)
function useOptions(path: string, includeId?: string | null) {
const [opts, setOpts] = useState<Opt[]>([]);
useEffect(() => {
let live = true;
(async () => {
let url = "";
let map = (rows: any[]) => rows.map((r) => ({ id: String(r.id ?? r.submission_id), label: String(r.name ?? r.model ?? r.opacity ?? 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`;
map = (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") {
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
// Explicitly reference the global Number to avoid the local <Number/> component shadowing it.
type Row = { submission_id?: string | number; make?: string; model?: string; nm?: string | number | null };
const toNum = (v: unknown): number | null => {
if (typeof v === "number") return globalThis.Number.isFinite(v) ? v : null;
if (typeof v === "string") {
const m = v.match(/-?\d+(\.\d+)?/);
const n = m ? globalThis.Number(m[0]) : NaN;
return globalThis.Number.isFinite(n) ? n : null;
}
return null;
};
map = (rows: Row[]) =>
rows
.filter((r) => {
const nmVal = toNum(r.nm);
return nmVal !== null && nmVal >= 10000 && nmVal <= 11000;
})
.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`;
map = (rows) =>
rows
.slice()
.sort((a, b) => (parseFloat(a.focal_length ?? "99999") || 99999) - (parseFloat(b.focal_length ?? "99999") || 99999))
.map((r) => {
const fs = r.field_size ? `${r.field_size} mm` : "";
const fl = r.focal_length ? `${r.focal_length} mm` : "";
const label = [fs, fl].filter(Boolean).join(" — ") || String(r.id);
return { id: String(r.id), label };
});
} 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([]);
return;
}
const res = await fetch(url, { cache: "no-store", credentials: "include" });
const j = await res.json().catch(() => ({}));
const rows = Array.isArray(j?.data) ? j.data : [];
let list = map(rows);
if (includeId && !list.some((o) => String(o.id) === String(includeId))) {
list = [{ id: String(includeId), label: "(current selection)" }, ...list];
}
if (live) setOpts(list);
})().catch(() => live && setOpts([]));
return () => {
live = false;
};
}, [path, includeId]);
return { opts };
}
// Enumerations
const FILL_TYPES: Opt[] = [
{ id: "uni", label: "UniDirectional" },
{ id: "bi", label: "BiDirectional" },
{ id: "offset", label: "Offset Fill" },
];
const RASTER_TYPES = FILL_TYPES;
const RASTER_DITHER: Opt[] = [
"threshold",
"ordered",
"atkinson",
"dither",
"stucki",
"jarvis",
"newsprint",
"halftone",
"sketch",
"grayscale",
].map((x) => ({ id: x, label: x[0].toUpperCase() + x.slice(1) }));
// react-hook-form
const {
register,
handleSubmit,
control,
reset,
formState: { isSubmitting },
} = useForm<any>({
defaultValues: {
setting_title: "",
setting_notes: "",
// Material
mat: "",
mat_coat: "",
mat_color: "",
mat_opacity: "",
mat_thickness: "",
// Rig & Optics
laser_soft: "",
source: "",
lens: "",
focus: "",
lens_conf: "",
lens_apt: "",
lens_exp: "",
repeat_all: "",
// Repeaters
fill_settings: [],
line_settings: [],
raster_settings: [],
},
});
// Repeaters
const fills = useFieldArray({ control, name: "fill_settings" });
const lines = useFieldArray({ control, name: "line_settings" });
const rasters = useFieldArray({ control, name: "raster_settings" });
// Prefill (edit)
useEffect(() => {
if (!isEdit || !initialValues) return;
reset({
setting_title: initialValues.setting_title ?? "",
setting_notes: initialValues.setting_notes ?? "",
// Material
mat: initialValues.mat ?? "",
mat_coat: initialValues.mat_coat ?? "",
mat_color: initialValues.mat_color ?? "",
mat_opacity: initialValues.mat_opacity ?? "",
mat_thickness: initialValues.mat_thickness ?? "",
// Rig & Optics
laser_soft: initialValues.laser_soft ?? "",
source: initialValues.source ?? "",
lens: initialValues.lens ?? "",
focus: initialValues.focus ?? "",
lens_conf: initialValues.lens_conf ?? "",
lens_apt: initialValues.lens_apt ?? "",
lens_exp: initialValues.lens_exp ?? "",
repeat_all: initialValues.repeat_all ?? "",
// Repeaters
fill_settings: initialValues.fill_settings ?? [],
line_settings: initialValues.line_settings ?? [],
raster_settings: initialValues.raster_settings ?? [],
});
}, [isEdit, initialValues, reset]);
// Option lists (include current IDs to guarantee a visible option)
const mats = useOptions("material", initialValues?.mat ?? null);
const coats = useOptions("material_coating", initialValues?.mat_coat ?? null);
const colors = useOptions("material_color", initialValues?.mat_color ?? null);
const opacs = useOptions("material_opacity", initialValues?.mat_opacity ?? null);
const soft = useOptions("laser_software", initialValues?.laser_soft ?? null);
const srcs = useOptions("laser_source_co2_galvo", initialValues?.source ?? null);
const lens = useOptions("laser_scan_lens", initialValues?.lens ?? null);
const conf = useOptions("laser_scan_lens_config", initialValues?.lens_conf ?? null);
const apt = useOptions("laser_scan_lens_apt", initialValues?.lens_apt ?? null);
const exp = useOptions("laser_scan_lens_exp", initialValues?.lens_exp ?? null);
// Image files
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [screenFile, setScreenFile] = useState<File | null>(null);
const onPick = (setter: (f: File | null) => void) => (e: React.ChangeEvent<HTMLInputElement>) => setter(e.target.files?.[0] ?? null);
const onSubmit = async (values: any) => {
setSubmitErr(null);
const payload: any = {
target: "settings_co2gal" as Target,
...(isEdit ? { mode: "edit" as const, submission_id: submissionId } : {}),
setting_title: values.setting_title,
setting_notes: values.setting_notes || "",
// Material
mat: values.mat || null,
mat_coat: values.mat_coat || null,
mat_color: values.mat_color || null,
mat_opacity: values.mat_opacity || null,
mat_thickness: values.mat_thickness === "" ? null : Number(values.mat_thickness),
// Rig & Optics
laser_soft: values.laser_soft || null,
source: values.source || null,
lens: values.lens || null,
focus: values.focus === "" ? null : Number(values.focus),
lens_conf: values.lens_conf || null,
lens_apt: values.lens_apt || null,
lens_exp: values.lens_exp || null,
repeat_all: values.repeat_all === "" ? null : Number(values.repeat_all),
// Repeaters (raw pass-through; api will normalize nums/bools)
fill_settings: values.fill_settings || [],
line_settings: values.line_settings || [],
raster_settings: values.raster_settings || [],
// If editing with existing asset IDs, the API will accept them
...(initialValues?.photo ? { photo: initialValues.photo } : {}),
...(initialValues?.screen ? { screen: initialValues.screen } : {}),
};
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/settings", { method: "POST", body: form, credentials: "include" });
const j = await res.json().catch(() => ({}));
if (!res.ok) {
setSubmitErr(j?.error || `Submit failed (${res.status})`);
return;
}
if (isEdit) {
router.back();
} else {
reset();
setPhotoFile(null);
setScreenFile(null);
router.push(`/submit/settings/success?id=${encodeURIComponent(String(j?.id ?? ""))}`);
}
};
return (
<div className="space-y-5">
<header className="space-y-1">
<h1 className="text-xl font-semibold">{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}</h1>
{me ? <p className="text-sm text-muted-foreground">Submitting as {me.username || me.email}</p> : null}
{submitErr ? <div className="border border-red-500 bg-red-50 text-red-700 rounded px-3 py-2 text-sm">{submitErr}</div> : null}
</header>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Info */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Info</h2>
<div className="grid gap-3">
<div>
<label className="block text-sm mb-1">Title *</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>
{/* Images */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Images</h2>
<div className="grid md:grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1">Result Photo {isEdit || initialValues?.photo ? null : <span className="text-red-600">*</span>}</label>
<input type="file" accept="image/*" onChange={onPick(setPhotoFile)} />
</div>
<div>
<label className="block text-sm mb-1">Settings Screenshot (optional)</label>
<input type="file" accept="image/*" onChange={onPick(setScreenFile)} />
</div>
</div>
</section>
{/* Material */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Material</h2>
<div className="grid md:grid-cols-2 gap-3">
<Select label="Material *" {...{ name: "mat", register, options: mats.opts, required: true }} />
<Select label="Coating *" {...{ name: "mat_coat", register, options: coats.opts, required: true }} />
<Select label="Color *" {...{ name: "mat_color", register, options: colors.opts, required: true }} />
<Select label="Opacity *" {...{ name: "mat_opacity", register, options: opacs.opts, required: true }} />
<Number label="Material Thickness (mm)" name="mat_thickness" register={register} step="0.01" />
</div>
</section>
{/* Rig & Optics (includes lens_conf/apt/exp) */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Rig & Optics</h2>
<div className="grid md:grid-cols-2 gap-3">
<Select label="Software *" {...{ name: "laser_soft", register, options: soft.opts, required: true }} />
<Select label="Laser Source *" {...{ name: "source", register, options: srcs.opts, required: true }} />
<Select label="Scan Lens *" {...{ name: "lens", register, options: lens.opts, required: true }} />
<Number label="Focus (mm) *" name="focus" register={register} step="1" />
<Number label="Repeat All" name="repeat_all" register={register} step="1" />
</div>
<div className="grid md:grid-cols-3 gap-3">
<Select label="Lens Configuration *" {...{ name: "lens_conf", register, options: conf.opts, required: true }} />
<Select label="Scan Head Aperture *" {...{ name: "lens_apt", register, options: apt.opts, required: true }} />
<Select label="Beam Expander *" {...{ name: "lens_exp", register, options: exp.opts, required: true }} />
</div>
</section>
{/* Process Settings */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Process Settings</h2>
{/* Fill */}
<Repeater
title="Fill"
fields={fills.fields}
onAdd={() => fills.append({ type: "uni" })}
onRemove={(i) => fills.remove(i)}
render={(i) => (
<div className="grid md:grid-cols-4 gap-2">
<Text label="Name" name={`fill_settings.${i}.name`} register={register} />
<Select label="Type" name={`fill_settings.${i}.type`} register={register} options={FILL_TYPES} />
<Number label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} register={register} step="0.1" />
<Number label="Pulse (ns)" name={`fill_settings.${i}.pulse`} register={register} step="0.1" />
<Number label="Power (%)" name={`fill_settings.${i}.power`} register={register} step="0.1" />
<Number label="Speed (mm/s)" name={`fill_settings.${i}.speed`} register={register} step="0.1" />
<Number label="Interval (mm)" name={`fill_settings.${i}.interval`} register={register} step="0.001" />
<Number label="Pass" name={`fill_settings.${i}.pass`} register={register} step="1" />
<Number label="Angle (°)" name={`fill_settings.${i}.angle`} register={register} step="1" />
<Number label="Increment" name={`fill_settings.${i}.increment`} register={register} step="0.001" />
<Check label="Auto" name={`fill_settings.${i}.auto`} register={register} />
<Check label="Cross" name={`fill_settings.${i}.cross`} register={register} />
<Check label="Flood" name={`fill_settings.${i}.flood`} register={register} />
<Check label="Air" name={`fill_settings.${i}.air`} register={register} />
</div>
)}
/>
{/* Line */}
<Repeater
title="Line"
fields={lines.fields}
onAdd={() => lines.append({})}
onRemove={(i) => lines.remove(i)}
render={(i) => (
<div className="grid md:grid-cols-4 gap-2">
<Text label="Name" name={`line_settings.${i}.name`} register={register} />
<Number label="Frequency (kHz)" name={`line_settings.${i}.frequency`} register={register} step="0.1" />
<Number label="Pulse (ns)" name={`line_settings.${i}.pulse`} register={register} step="0.1" />
<Number label="Power (%)" name={`line_settings.${i}.power`} register={register} step="0.1" />
<Number label="Speed (mm/s)" name={`line_settings.${i}.speed`} register={register} step="0.1" />
<Check label="Perf" name={`line_settings.${i}.perf`} register={register} />
<Check label="Cut" name={`line_settings.${i}.cut`} register={register} />
<Check label="Skip" name={`line_settings.${i}.skip`} register={register} />
<Number label="Pass" name={`line_settings.${i}.pass`} register={register} step="1" />
<Number label="Step" name={`line_settings.${i}.step`} register={register} step="0.001" />
<Number label="Size" name={`line_settings.${i}.size`} register={register} step="0.001" />
<Check label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
<Check label="Air" name={`line_settings.${i}.air`} register={register} />
</div>
)}
/>
{/* Raster */}
<Repeater
title="Raster"
fields={rasters.fields}
onAdd={() => rasters.append({ type: "uni", dither: "threshold" })}
onRemove={(i) => rasters.remove(i)}
render={(i) => (
<div className="grid md:grid-cols-4 gap-2">
<Text label="Name" name={`raster_settings.${i}.name`} register={register} />
<Number label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} register={register} step="0.1" />
<Number label="Pulse (ns)" name={`raster_settings.${i}.pulse`} register={register} step="0.1" />
<Select label="Type" name={`raster_settings.${i}.type`} register={register} options={RASTER_TYPES} />
<Select label="Dither" name={`raster_settings.${i}.dither`} register={register} options={RASTER_DITHER} />
<Number label="Power (%)" name={`raster_settings.${i}.power`} register={register} step="0.1" />
<Number label="Speed (mm/s)" name={`raster_settings.${i}.speed`} register={register} step="0.1" />
<Number label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} register={register} step="1" />
<Number label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} register={register} step="1" />
<Number label="Interval (mm)" name={`raster_settings.${i}.interval`} register={register} step="0.001" />
<Number label="Dot" name={`raster_settings.${i}.dot`} register={register} step="0.1" />
<Number label="Pass" name={`raster_settings.${i}.pass`} register={register} step="1" />
<Check label="Cross" name={`raster_settings.${i}.cross`} register={register} />
<Check label="Inversion" name={`raster_settings.${i}.inversion`} register={register} />
<Check label="Air" name={`raster_settings.${i}.air`} register={register} />
</div>
)}
/>
</section>
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background disabled:opacity-50">
{isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings"}
</button>
</form>
</div>
);
}
/* Small UI */
function Select({
label,
name,
register,
options,
required,
}: { label: string; name: string; register: UseFormRegister<any>; options: Opt[]; required?: boolean }) {
return (
<div>
<label className="block text-sm mb-1">
{label} {required ? <span className="text-red-600">*</span> : null}
</label>
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
<option value=""></option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
);
}
function Number({ label, name, register, step }: any) {
return (
<div>
<label className="block text-sm mb-1">{label}</label>
<input type="number" step={step ?? "1"} className="w-full border rounded px-2 py-1" {...register(name)} />
</div>
);
}
function Text({ label, name, register }: any) {
return (
<div>
<label className="block text-sm mb-1">{label}</label>
<input className="w-full border rounded px-2 py-1" {...register(name)} />
</div>
);
}
function Check({ label, name, register }: any) {
return (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...register(name)} /> {label}
</label>
);
}
function Repeater({ title, fields, onAdd, onRemove, render }: any) {
return (
<fieldset className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<legend className="font-semibold">{title}</legend>
<button type="button" className="px-2 py-1 border rounded" onClick={onAdd}>
+ Add
</button>
</div>
{fields.map((_: any, i: number) => (
<div key={i} className="space-y-2">
{render(i)}
<button type="button" className="px-2 py-1 border rounded" onClick={() => onRemove(i)}>
Remove
</button>
</div>
))}
</fieldset>
);
}