makearmy-app/components/forms/SettingsSubmit.tsx
2025-10-06 21:02:47 -04:00

615 lines
25 KiB
TypeScript

// components/forms/SettingsSubmit.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm, useFieldArray, useWatch, type UseFormRegister } from "react-hook-form";
import { useRouter } from "next/navigation";
type Target = "settings_co2gal";
type Opt = { id: string; label: string };
type Me = { id: string; username?: string; email?: string } | null;
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
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);
const [submitErr, setSubmitErr] = useState<string | null>(null);
// Robust current-user fetch
useEffect(() => {
let alive = true;
(async () => {
try {
const r1 = await fetch(`/api/me`, { cache: "no-store", credentials: "include" });
if (r1.ok) {
const j = await r1.json().catch(() => null);
if (alive && j) {
const id = j?.id ?? j?.data?.id ?? null;
const username = j?.username ?? j?.data?.username ?? j?.name ?? null;
const email = j?.email ?? j?.data?.email ?? null;
setMe(id ? { id, username: username ?? undefined, email: email ?? undefined } : null);
return;
}
}
const r2 = await fetch(`/api/dx/users/me?fields=id,username,email`, { cache: "no-store", credentials: "include" });
if (alive && r2.ok) {
const j2 = await r2.json().catch(() => null);
const d = j2?.data ?? j2 ?? null;
setMe(d?.id ? { id: d.id, username: d.username ?? undefined, email: d.email ?? undefined } : null);
}
} catch {
if (alive) setMe(null);
}
})();
return () => { alive = false; };
}, []);
// Options loaders (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`;
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,
getValues, // ⬅ added
setValue, // ⬅ added
formState: { isSubmitting },
} = useForm<any>({
defaultValues: {
setting_title: "",
setting_notes: "",
// Material
mat: "",
mat_coat: "",
mat_color: "",
mat_opacity: "",
mat_thickness: "",
// Rig & Optics
laser_soft: "",
source: "",
// keep these blank so Select shows "—"
lens_conf: "",
lens_apt: "",
lens_exp: "",
lens: "",
focus: "",
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_conf: initialValues.lens_conf ?? "",
lens_apt: initialValues.lens_apt ?? "",
lens_exp: initialValues.lens_exp ?? "",
lens: initialValues.lens ?? "",
focus: initialValues.focus ?? "",
repeat_all: initialValues.repeat_all ?? "",
// Repeaters
fill_settings: initialValues.fill_settings ?? [],
line_settings: initialValues.line_settings ?? [],
raster_settings: initialValues.raster_settings ?? [],
});
}, [isEdit, initialValues, reset]);
// Re-apply select values when options hydrate (fixes stubborn placeholder issue)
useEffect(() => {
if (!isEdit || !initialValues) return;
const ensure = (name: string, currentId: string | null | undefined, opts: Opt[]) => {
if (!currentId) return;
const cur = getValues(name as any);
const curStr = cur == null ? "" : String(cur);
if (!curStr || !opts.some((o) => String(o.id) === curStr)) {
setValue(name as any, String(currentId), { shouldDirty: false, shouldValidate: false });
}
};
ensure("mat", initialValues.mat, mats.opts);
ensure("mat_coat", initialValues.mat_coat, coats.opts);
ensure("mat_color", initialValues.mat_color, colors.opts);
ensure("mat_opacity", initialValues.mat_opacity, opacs.opts);
ensure("laser_soft", initialValues.laser_soft, soft.opts);
ensure("source", initialValues.source, srcs.opts);
ensure("lens_conf", initialValues.lens_conf, conf.opts);
ensure("lens_apt", initialValues.lens_apt, apt.opts);
ensure("lens_exp", initialValues.lens_exp, exp.opts);
ensure("lens", initialValues.lens, lens.opts);
}, [
isEdit,
initialValues,
mats.opts, coats.opts, colors.opts, opacs.opts,
soft.opts, srcs.opts,
conf.opts, apt.opts, exp.opts,
lens.opts,
getValues, setValue,
]);
// 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 : globalThis.Number(values.mat_thickness),
// Rig & Optics
laser_soft: values.laser_soft || null,
source: values.source || null,
lens_conf: values.lens_conf || null,
lens_apt: values.lens_apt || null,
lens_exp: values.lens_exp || null,
lens: values.lens || null,
focus: values.focus === "" ? null : globalThis.Number(values.focus),
repeat_all: values.repeat_all === "" ? null : globalThis.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 ?? ""))}`);
}
};
const meLabel = me?.username || me?.email || "";
return (
<div className="space-y-5">
<header className="space-y-1">
{/* Hide local H1 in edit mode to avoid duplicate page title */}
{!isEdit && <h1 className="text-xl font-semibold">Submit CO Galvo Setting</h1>}
{meLabel ? <p className="text-sm text-muted-foreground">Submitting as {meLabel}</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</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 (order per spec) */}
<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="Laser Software" {...{ name: "laser_soft", register, options: soft.opts, required: true }} />
<Select label="Laser Source" {...{ name: "source", register, options: srcs.opts, required: true }} />
</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>
<div className="grid md:grid-cols-3 gap-3">
<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>
</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: "" })}
onRemove={(i) => fills.remove(i)}
render={(i) => {
const autoRotate = !!useWatch({ control, name: `fill_settings.${i}.auto` });
return (
<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" />
{/* Move Auto first, then its increment in a half-width cell */}
<Check label="Auto Rotate" name={`fill_settings.${i}.auto`} register={register} />
{autoRotate && (
<div className="md:col-span-2">
<Number label="Auto Rotate Increment (°)" name={`fill_settings.${i}.increment`} register={register} step="0.001" />
</div>
)}
<Check label="Crosshatch" name={`fill_settings.${i}.cross`} register={register} />
<Check label="Flood Fill" name={`fill_settings.${i}.flood`} register={register} />
<Check label="Air Assist" 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) => {
const perf = !!useWatch({ control, name: `line_settings.${i}.perf` });
const wobble = !!useWatch({ control, name: `line_settings.${i}.wobble` });
return (
<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" />
{/* Pass before perf per your last request */}
<Number label="Pass" name={`line_settings.${i}.pass`} register={register} step="1" />
<Check label="Perforation Mode" name={`line_settings.${i}.perf`} register={register} />
{perf && (
<>
<Number label="Cut (mm)" name={`line_settings.${i}.cut`} register={register} step="0.001" />
<Number label="Skip (mm)" name={`line_settings.${i}.skip`} register={register} step="0.001" />
</>
)}
<Check label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
{wobble && (
<>
<Number label="Step (mm)" name={`line_settings.${i}.step`} register={register} step="0.001" />
<Number label="Size (mm)" name={`line_settings.${i}.size`} register={register} step="0.001" />
</>
)}
<Check label="Air Assist" name={`line_settings.${i}.air`} register={register} />
</div>
);
}}
/>
{/* Raster */}
<Repeater
title="Raster"
fields={rasters.fields}
onAdd={() => rasters.append({ type: "", dither: "" })}
onRemove={(i) => rasters.remove(i)}
render={(i) => {
const ditherVal = useWatch({ control, name: `raster_settings.${i}.dither` }) || "";
const isHalftone = ditherVal === "halftone";
return (
<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" />
{isHalftone && (
<>
<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 Width Adjustment (mm)" name={`raster_settings.${i}.dot`} register={register} step="0.1" />
<Number label="Pass" name={`raster_settings.${i}.pass`} register={register} step="1" />
<Check label="Crosshatch" name={`raster_settings.${i}.cross`} register={register} />
<Check label="Inverted" name={`raster_settings.${i}.inversion`} register={register} />
<Check label="Air Assist" 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>
);
}