diff --git a/app/api/submit/settings/route.ts b/app/api/submit/settings/route.ts index c270f09b..b8f05ddf 100644 --- a/app/api/submit/settings/route.ts +++ b/app/api/submit/settings/route.ts @@ -1,6 +1,6 @@ // app/api/submit/settings/route.ts import { NextResponse } from "next/server"; -import { uploadFile, createSettingsItem, bytesFromMB, dxGET, dxPATCH } from "@/lib/directus"; +import { uploadFile, bytesFromMB, dxGET, dxPATCH, dxPOST } from "@/lib/directus"; import { requireBearer } from "@/app/api/_lib/auth"; /** ───────────────────────────────────────────────────────────── @@ -18,7 +18,7 @@ import { requireBearer } from "@/app/api/_lib/auth"; * - settings_co2gal * - settings_uv * - * Also supports editing: + * Edit: * Body must include { mode: "edit", submission_id: string|number } * We PATCH via filter[submission_id][_eq] and owner = current user. * ──────────────────────────────────────────────────────────── */ @@ -53,7 +53,7 @@ function num(v: any, fallback: number | null = null) { } type ReadResult = { - mode: "json" | "multipart"; // transport mode, not create/edit + mode: "json" | "multipart"; body: any; photoFile: File | null; screenFile: File | null; @@ -174,12 +174,18 @@ export async function POST(req: Request) { const mat_thickness = num(body?.mat_thickness, null); const source = body?.source ?? null; const lens = body?.lens ?? null; + + // CO₂ galvo extras (relations) + const lens_conf = body?.lens_conf ?? null; + const lens_apt = body?.lens_apt ?? null; + const lens_exp = body?.lens_exp ?? null; + const focus = num(body?.focus, null); const setting_notes = String(body?.setting_notes || "").trim(); // Shared string fields - const laser_soft = body?.laser_soft ?? null; // exact key: 'laser_soft' - const repeat_all = num(body?.repeat_all, null); // universally applicable + const laser_soft = body?.laser_soft ?? null; + const repeat_all = num(body?.repeat_all, null); // Upload / accept existing file ids let photo_id: string | null = body?.photo ?? null; @@ -233,6 +239,7 @@ export async function POST(req: Request) { const basePayload: Record = { setting_title, setting_notes, + // Ownership & attribution owner: meId || null, // M2O to directus_users uploader, // string mirror of username @@ -241,6 +248,7 @@ export async function POST(req: Request) { laser_soft, repeat_all, + // material / optics mat, mat_coat, mat_color, @@ -248,8 +256,15 @@ export async function POST(req: Request) { mat_thickness, source, lens, + + // CO₂ galvo extras + lens_conf, + lens_apt, + lens_exp, + focus, + // repeaters fill_settings: fills, line_settings: lines, raster_settings: rasters, @@ -258,6 +273,37 @@ export async function POST(req: Request) { last_modified_date: nowIso, }; + // ── Per-target requireds (server-side enforcement) ───────── + if (target === "settings_co2gal") { + const missing: string[] = []; + const req = { + setting_title, + uploader, + photo: op === "create" ? photo_id : true, // on edit, can be omitted + source, + lens, + lens_conf, + lens_apt, + lens_exp, + focus: Number.isFinite(focus as number), + mat, + mat_coat, + mat_color, + mat_opacity, + laser_soft, + repeat_all: Number.isFinite(repeat_all as number), + }; + for (const [k, v] of Object.entries(req)) { + if (!v) missing.push(k); + } + if (missing.length) { + return NextResponse.json( + { error: `Missing required: ${missing.join(", ")}` }, + { status: 400 } + ); + } + } + if (op === "create") { // Create-only fields basePayload.photo = photo_id; @@ -266,8 +312,14 @@ export async function POST(req: Request) { basePayload.submitted_via = "makearmy-app"; basePayload.submitted_at = nowIso; - const { data } = await createSettingsItem(target, basePayload, bearer); - return NextResponse.json({ ok: true, id: data.id }); + // 🔑 Directus requires { data: {...} } + const res = await dxPOST<{ data: { id: string | number } }>( + `/items/${target}`, + bearer, + { data: basePayload } + ); + + return NextResponse.json({ ok: true, id: res?.data?.id }); } // EDIT mode diff --git a/components/forms/SettingsSubmit.tsx b/components/forms/SettingsSubmit.tsx index 728a9c2b..c10f6b6f 100644 --- a/components/forms/SettingsSubmit.tsx +++ b/components/forms/SettingsSubmit.tsx @@ -5,34 +5,26 @@ import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; import { useRouter, useSearchParams } from "next/navigation"; -type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; +/** CO₂ Galvo implementation only (per spec). Other targets intentionally omitted. */ +type Target = "settings_co2gal"; type Opt = { id: string; label: string }; -type Me = { - id: string; - username?: string; - display_name?: string; - first_name?: string; - last_name?: string; - email?: string; -}; +type Me = { id: string; username?: string; email?: string }; const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); -// ───────────────────────────────────────────────────────────── -// Local enums (no schema introspection) -// ───────────────────────────────────────────────────────────── +/* ───────────────────────────────────────────────────────────── + * 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" }, @@ -49,14 +41,54 @@ const RASTER_DITHER_OPTIONS = [ const toOpts = (arr: { label: string; value: string }[]): Opt[] => arr.map((x) => ({ id: x.value, label: x.label })); -function shortId(s?: string) { - if (!s) return ""; - return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`; +/* ───────────────────────────────────────────────────────────── + * 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?: Target }; +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"; } -// ───────────────────────────────────────────────────────────── -// Normalizers for edit-mode prefill (IDs + enums) -// ───────────────────────────────────────────────────────────── +/* ───────────────────────────────────────────────────────────── + * Helpers + * ───────────────────────────────────────────────────────────── */ function idToString(v: any): string { if (v == null || v === "") return ""; if (typeof v === "object") { @@ -71,44 +103,9 @@ function normalizeEnums(value: any, allowed: string[], fallback: string) { return allowed.includes(v) ? v : fallback; } -type BaseProps = { initialTarget?: Target }; - -type EditInitialValues = { - /** optional, carried separately via props.submissionId */ - submission_id?: string | number; - setting_title?: string; - setting_notes?: string; - - photo?: string | File | { id?: string } | null; - screen?: string | File | { id?: string } | null; - - mat?: any; - mat_coat?: any; - mat_color?: any; - mat_opacity?: any; - mat_thickness?: number | null; - - source?: any; - lens?: any; - focus?: number | null; - - laser_soft?: any; - repeat_all?: number | null; - - // CO2 extras (stored as relations -> ids) - lens_conf?: any; - lens_apt?: any; - lens_exp?: any; - - fill_settings?: any[] | null; - line_settings?: any[] | null; - raster_settings?: any[] | null; -}; - function normalizeForReset(iv: EditInitialValues) { return { ...iv, - // Single-selects → string IDs mat: idToString(iv.mat), mat_coat: idToString(iv.mat_coat), mat_color: idToString(iv.mat_color), @@ -119,29 +116,13 @@ function normalizeForReset(iv: EditInitialValues) { lens_conf: idToString(iv.lens_conf), lens_apt: idToString(iv.lens_apt), lens_exp: idToString(iv.lens_exp), - - // Arrays: coerce dropdown-ish fields to internal enum keys - fill_settings: (iv.fill_settings ?? []).map((r: any) => ({ - ...r, - type: normalizeEnums(r?.type, ["uni", "bi", "offset"], "uni"), - })), + 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", "ordered", "atkinson", "dither", "stucki", "jarvis", "newsprint", "halftone", "sketch", "grayscale"], "threshold" ), })), @@ -149,225 +130,72 @@ function normalizeForReset(iv: EditInitialValues) { }; } -// ───────────────────────────────────────────────────────────── -/** Directus field whitelists + mapper (prevents drift) */ -// ───────────────────────────────────────────────────────────── -const DIRECTUS_FIELDS: Record = { - settings_co2gal: [ - "setting_title", - "setting_notes", - "photo", - "screen", - "mat", - "mat_coat", - "mat_color", - "mat_opacity", - "mat_thickness", - "source", - "lens", - "focus", - "laser_soft", - "repeat_all", - "fill_settings", - "line_settings", - "raster_settings", - "uploader", - "lens_conf", - "lens_apt", - "lens_exp", - ], - settings_co2gan: [ - "setting_title", - "setting_notes", - "photo", - "screen", - "mat", - "mat_coat", - "mat_color", - "mat_opacity", - "mat_thickness", - "source", - "lens", - "focus", - "laser_soft", - "repeat_all", - "fill_settings", - "line_settings", - "raster_settings", - "uploader", - "lens_conf", - ], - settings_fiber: [ - "setting_title", - "setting_notes", - "photo", - "screen", - "mat", - "mat_coat", - "mat_color", - "mat_opacity", - "mat_thickness", - "source", - "lens", - "focus", - "laser_soft", - "repeat_all", - "fill_settings", - "line_settings", - "raster_settings", - "uploader", - ], - settings_uv: [ - "setting_title", - "setting_notes", - "photo", - "screen", - "mat", - "mat_coat", - "mat_color", - "mat_opacity", - "mat_thickness", - "source", - "lens", - "focus", - "laser_soft", - "repeat_all", - "fill_settings", - "line_settings", - "raster_settings", - "uploader", - ], -} as const; - -function toDirectusData(target: Target, full: any) { - const allow = new Set(DIRECTUS_FIELDS[target]); - const out: any = {}; - for (const k of Object.keys(full)) { - if (!allow.has(k)) continue; - const v = full[k]; - if (v === "") continue; // avoid empty strings confusing required validation - out[k] = v; - } - return out; -} - -// ───────────────────────────────────────────────────────────── -// Props (Create vs Edit) + type guard -// ───────────────────────────────────────────────────────────── -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"; -} - -// ───────────────────────────────────────────────────────────── -// Options loader (materials, lenses, etc.) -// ───────────────────────────────────────────────────────────── -function useOptions(path: string, forceIncludeId?: string, opts?: { disableNmFilter?: boolean }) { - const [optsState, setOptsState] = useState([]); +/* ───────────────────────────────────────────────────────────── + * Options loader (Materials, Software, Source, Lens + fixed lens lists) + * ───────────────────────────────────────────────────────────── */ +function useOptions(path: string, forceIncludeId?: string) { + const [opts, setOpts] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); - const disableNmFilter = opts?.disableNmFilter === true; - - // helpers for the "laser_source" nm filtering - const parseNum = (v: any): number | null => { - if (v == null) return null; - const m = String(v).match(/(\d+(\.\d+)?)/); - return m ? Number(m[1]) : null; - }; - - const nmRangeFor = (target?: string | null): [number, number] | null => { - if (!target) return null; - const t = target.toLowerCase(); - if (t === "fiber") return [1000, 9000]; - if (t === "uv") return [300, 400]; - if (t === "co2-gantry" || t === "co2-galvo") return [10000, 11000]; - return null; - }; - useEffect(() => { let alive = true; setLoading(true); (async () => { - const [rawPath, qs] = path.split("?", 2); - const params = new URLSearchParams(qs || ""); - const target = params.get("target") || ""; - 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), - })); + 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 (rawPath === "material") { - url = `${API}/items/material?fields=id,name&limit=1000&sort=name`; - } else if (rawPath === "material_color") { - url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`; - } else if (rawPath === "material_coating") { - url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`; - } else if (rawPath === "material_opacity") { + 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 (rawPath === "laser_software") { + } else if (path === "laser_software") { url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`; - } else if (rawPath === "laser_source") { + } 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`; - const range = disableNmFilter ? null : nmRangeFor(target); normalize = (rows) => { - const filtered = range - ? rows.filter((r: any) => { - const nm = parseNum(r.nm); - return nm != null && nm >= range[0] && nm <= range[1]; + 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; }) - : rows; - return filtered.map((r: any) => ({ + .map((r: any) => ({ id: String(r.submission_id), - label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id), + label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id), })); }; - } else if (rawPath === "lens") { - if (target === "co2-gantry") { - url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000`; - normalize = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.name ?? r.id) })); - } else { - 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") { + 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; }; - } - } - // NEW: fixed lists for config / aperture / expander - else if (rawPath === "laser_scan_lens_config") { + 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 (rawPath === "laser_scan_lens_apt") { + } else if (path === "laser_scan_lens_apt") { url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`; - } else if (rawPath === "laser_scan_lens_exp") { + } else if (path === "laser_scan_lens_exp") { url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`; } else { - setOptsState([]); + setOpts([]); setLoading(false); return; } @@ -376,33 +204,30 @@ function useOptions(path: string, forceIncludeId?: string, opts?: { disableNmFil if (!res.ok) throw new Error(`Directus ${res.status} fetching ${url}`); const json = await res.json(); const rows = json?.data ?? []; - const mapped = normalize(rows); + let mapped = normalize(rows); - // Ensure the currently selected value is present even if filters/pagination miss it - let ensured = mapped; - if (forceIncludeId && !mapped.some((o: any) => String(o.id) === String(forceIncludeId))) { - ensured = [{ id: String(forceIncludeId), label: "(current selection)" }, ...mapped]; + 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 ? ensured.filter((o) => o.label.toLowerCase().includes(needle)) : ensured; - - if (alive) setOptsState(filtered); + const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped; + if (alive) setOpts(filtered); })() - .catch(() => alive && setOptsState([])) + .catch(() => setOpts([])) .finally(() => alive && setLoading(false)); return () => { alive = false; }; - }, [path, q, forceIncludeId, disableNmFilter]); + }, [path, q, forceIncludeId]); - return { opts: optsState, loading, setQ }; + return { opts, loading, setQ }; } -// ───────────────────────────────────────────────────────────── -// Small UI bits -// ───────────────────────────────────────────────────────────── +/* ───────────────────────────────────────────────────────────── + * Small UI bits + * ───────────────────────────────────────────────────────────── */ function FilterableSelect({ label, name, @@ -423,9 +248,7 @@ function FilterableSelect({ required?: boolean; }) { const [filter, setFilter] = useState(""); - useEffect(() => { - onQuery?.(filter); - }, [filter, onQuery]); + useEffect(() => { onQuery?.(filter); }, [filter, onQuery]); const filtered = useMemo(() => { if (!filter) return options; @@ -447,9 +270,7 @@ function FilterableSelect({ @@ -486,8 +307,7 @@ function LabeledInput({ return (
(initialFromQuery); - - useEffect(() => { - if (isEdit && props.initialTarget && props.initialTarget !== target) { - setTarget(props.initialTarget); - } - }, [isEdit, props.initialTarget, target]); - - const typeForOptions = useMemo(() => { - switch (target) { - case "settings_fiber": return "fiber"; - case "settings_uv": return "uv"; - case "settings_co2gan": return "co2-gantry"; - case "settings_co2gal": return "co2-galvo"; - default: return "fiber"; - } - }, [target]); + // CO₂ Galvo is the only target in this implementation. + const target: Target = "settings_co2gal"; // Image inputs const [photoFile, setPhotoFile] = useState(null); @@ -555,7 +356,7 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { return () => { alive = false; }; }, []); - const meLabel = me?.username ?? ""; + const meLabel = me?.username ?? me?.email ?? ""; // For edit-mode, compute normalized current values once const current = useMemo( @@ -563,24 +364,24 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { [isEdit, edit?.initialValues] ); - // Options + // 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 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); // required for ALL targets - const srcs = useOptions(`laser_source?target=${typeForOptions}`, current?.source || undefined, { disableNmFilter: isEdit }); - const lens = useOptions(`lens?target=${typeForOptions}`, current?.lens || 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); - // NEW: fixed-value dropdowns for galvo configs - const lensConf = useOptions("laser_scan_lens_config", current?.lens_conf || undefined); - const lensApt = useOptions("laser_scan_lens_apt", current?.lens_apt || undefined); - const lensExp = useOptions("laser_scan_lens_exp", current?.lens_exp || 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 now, no network) - 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) => {} }; + // 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, @@ -594,6 +395,8 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { defaultValues: { setting_title: "", setting_notes: "", + + // material / optics mat: "", mat_coat: "", mat_color: "", @@ -604,10 +407,13 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { focus: "", laser_soft: "", repeat_all: "", - // dropdown ids + + // CO₂ Galvo lens dropdowns lens_conf: "", lens_apt: "", lens_exp: "", + + // repeaters fill_settings: [], line_settings: [], raster_settings: [], @@ -627,19 +433,24 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { 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 ?? [], @@ -647,77 +458,66 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { } }, [isEdit, edit?.initialValues, reset]); - // After reset, force RHF values once (covers early case) + // After reset, force selects once to show current ids useEffect(() => { if (!isEdit || !current) return; - const fieldNames = [ - "laser_soft", "mat", "mat_coat", "mat_color", "mat_opacity", "source", "lens", - "lens_conf", "lens_apt", "lens_exp", - ] as const; + const names = ["laser_soft","mat","mat_coat","mat_color","mat_opacity","source","lens","lens_conf","lens_apt","lens_exp"] as const; const values = getValues(); - fieldNames.forEach((name) => { + 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]); - - // When options hydrate/change, re-apply the current ids so the select shows them - useEffect(() => { - if (!isEdit || !current) return; - const apply = (name: keyof typeof current) => { - const cur = (current as any)[name]; - if (cur) setValue(name as any, cur, { shouldDirty: false, shouldValidate: false }); - }; - ["mat","mat_coat","mat_color","mat_opacity","laser_soft","source","lens","lens_conf","lens_apt","lens_exp"] - .forEach((n) => apply(n as any)); - }, [isEdit, current, setValue, mats.opts, coats.opts, colors.opts, opacs.opts, soft.opts, srcs.opts, lens.opts, lensConf.opts, lensApt.opts, lensExp.opts]); + }, [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: Match prod envelope → ALWAYS multipart with "payload" - // ───────────────────────────────────────────────────────────── + /* ───────────────────────────────────────────────────────────── + * SUBMIT (multipart + payload JSON). CO₂ Galvo requireds enforced server-side, + * but we mark client-required for UX. + * ───────────────────────────────────────────────────────────── */ async function onSubmit(values: any) { setSubmitErr(null); - // Create vs Edit: photo is required unless an existing photo id is present or a new file is picked - const currentPhotoId = - isEdit && typeof edit!.initialValues?.photo === "string" && edit!.initialValues.photo - ? (edit!.initialValues.photo as string) - : null; - const requirePhoto = !currentPhotoId && !photoFile; - if (requirePhoto) { + // 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 fullPayload: any = { - target, + 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), - // uploader: include if we have it; server will also set from owner - ...(me?.username || me?.email ? { uploader: me?.username ?? me?.email! } : {}), - - // CO2 dropdown ids (relations) - lens_conf: values.lens_conf || null, - lens_apt: values.lens_apt || null, - lens_exp: values.lens_exp || null, - + /* repeaters */ fill_settings: (values.fill_settings || []).map((r: any) => ({ name: r.name || "", power: num(r.power), @@ -768,38 +568,17 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { })), }; - const directusData = toDirectusData(target, fullPayload); - if (!directusData.setting_title) { - setSubmitErr("Title is required."); - return; - } - - const base = isEdit && edit?.submissionId - ? { target, mode: "edit" as const, submission_id: edit.submissionId } - : { target }; - - const flatPayload = { - ...base, - ...directusData, - setting_title: directusData.setting_title, - }; - try { const form = new FormData(); - form.set("payload", JSON.stringify(flatPayload)); + 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 res = await fetch("/api/submit/settings", { method: "POST", body: form, credentials: "include" }); const data = await res.json().catch(() => ({})); if (!res.ok) { if (res.status === 401 || res.status === 403) throw new Error("You must be signed in to submit settings."); - throw new Error((data as any)?.error || "Submission failed"); + throw new Error(data?.error || "Submission failed"); } if (!isEdit) { @@ -831,43 +610,17 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { 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; + 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 ( -
- {/* Target + Software */} -
-
- - -
- -
- -
-
- - {/* Submitting-as banner */} +
+ {/* Header */} +
+

{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}

{me ? (
Submitting as {meLabel}. @@ -877,35 +630,36 @@ export default function SettingsSubmit(props: CreateProps | EditProps) { You’re not signed in. Submissions will fail until you sign in.
) : null} + {submitErr ?
{submitErr}
: null} +
- {submitErr ? ( -
{submitErr}
- ) : null} - -
- {/* Title */} -
-
- + + {/* Section: Overview */} +
+

Overview

+
+
+
+
+ +