From 974b7e76017634078fbd3582308fc1dd1cf6d7d2 Mon Sep 17 00:00:00 2001 From: makearmy Date: Fri, 26 Sep 2025 19:33:27 -0400 Subject: [PATCH] rigbuilder UI fixes --- app/my/rigs/RigBuilderClient.tsx | 560 +++++++++++++++++++------------ app/my/rigs/page.tsx | 45 +-- components/ui/select.tsx | 220 ++++++------ node_modules/.package-lock.json | 6 - package-lock.json | 6 - 5 files changed, 466 insertions(+), 371 deletions(-) diff --git a/app/my/rigs/RigBuilderClient.tsx b/app/my/rigs/RigBuilderClient.tsx index df01e4af..dc4f0d2a 100644 --- a/app/my/rigs/RigBuilderClient.tsx +++ b/app/my/rigs/RigBuilderClient.tsx @@ -1,200 +1,333 @@ -// app/my/rigs/RigBuilderClient.tsx "use client"; import { useEffect, useMemo, useState } from "react"; -import { z } from "zod"; import { useForm } from "react-hook-form"; +import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useToast } from "@/hooks/use-toast"; + import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Select, SelectTrigger, + SelectValue, SelectContent, SelectItem, - SelectValue, } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -type RigType = { id: number | string; name: string }; -export default function RigBuilderClient({ rigTypes }: { rigTypes: RigType[] }) { +// ───────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────── + +type Option = { id: string | number; label: string }; +type RigType = { id: number | string; name: "fiber" | "uv" | "co2_galvo" | "co2_gantry" | string }; + +type RigRow = { + id: number; + name: string; + rig_type: number | string | null; + rig_type_name?: string; // convenience when our API includes name +}; + +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── + +const RIG_TARGET_MAP: Record = { + fiber: "settings_fiber", + uv: "settings_uv", + co2_galvo: "settings_co2gal", + co2_gantry: "settings_co2gan", +}; + +async function apiJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { + ...init, + headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, + cache: "no-store", + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(txt || res.statusText); + } + return res.json() as Promise; +} + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + rig_type: z.string().min(1, "Pick a rig type"), + laser_source: z.string().optional().nullable(), + laser_software: z.string().optional().nullable(), + // exactly one of focus OR scan (by rig type). We let the form send nulls. + laser_focus_lens: z.string().optional().nullable(), + laser_scan_lens: z.string().optional().nullable(), + laser_scan_lens_apt: z.string().optional().nullable(), + laser_scan_lens_exp: z.string().optional().nullable(), + notes: z.string().optional().nullable(), +}); + +type FormValues = z.infer; + +// ───────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────── + +export default function RigBuilderClient() { const { toast } = useToast(); - const FormSchema = z.object({ - name: z.string().min(2, "Please enter a name"), - rig_type: z.string().min(1, "Choose a rig type"), - laser_source: z.string().optional().nullable(), - laser_focus_lens: z.string().optional().nullable(), - laser_scan_lens: z.string().optional().nullable(), - laser_scan_lens_apt: z.string().optional().nullable(), - laser_scan_lens_exp: z.string().optional().nullable(), - laser_software: z.string().optional().nullable(), - notes: z.string().optional().nullable(), - }); - type FormValues = z.infer; + // Lists + const [rigTypes, setRigTypes] = useState([]); + const [rigs, setRigs] = useState([]); - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - name: "", - rig_type: "", - notes: "", - }, + // Options that depend on rig type + const [sourceOpts, setSourceOpts] = useState([]); + const [softwareOpts, setSoftwareOpts] = useState([]); + const [scanLensOpts, setScanLensOpts] = useState([]); + const [focusLensOpts, setFocusLensOpts] = useState([]); + + // Form + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "My Fiber #1", + rig_type: "", + laser_source: null, + laser_software: null, + laser_focus_lens: null, + laser_scan_lens: null, + laser_scan_lens_apt: null, + laser_scan_lens_exp: null, + notes: "", + }, }); - const selectedTypeName = useMemo(() => { - const v = form.watch("rig_type"); - return rigTypes.find((r) => String(r.id) === String(v))?.name ?? ""; - }, [form, rigTypes]); + const rigTypeVal = watch("rig_type"); + const rigTarget = RIG_TARGET_MAP[rigTypeVal ?? ""] || ""; - // Option lists (pulled from your existing endpoints) - const [laserSources, setLaserSources] = useState<{ id: string; label: string }[]>([]); - const [scanLenses, setScanLenses] = useState<{ id: string; label: string }[]>([]); - const [focusLenses, setFocusLenses] = useState<{ id: string; label: string }[]>([]); - const [softwares, setSoftwares] = useState<{ id: string; label: string }[]>([]); + const isGantry = rigTypeVal === "co2_gantry"; + const isScan = rigTypeVal === "fiber" || rigTypeVal === "uv" || rigTypeVal === "co2_galvo"; + // Initial loads useEffect(() => { (async () => { try { - const ls = await fetch("/api/options/laser_source").then((r) => r.json()); - setLaserSources(ls?.data ?? []); - } catch {} - try { - const sl = await fetch("/api/options/lens?target=settings_fiber").then((r) => r.json()); - setScanLenses(sl?.data ?? []); - } catch {} - try { - const fl = await fetch("/api/options/repeater-choices?key=laser_focus_lens").then((r) => - r.json() - ); - setFocusLenses(fl?.data ?? []); - } catch {} - try { - const sw = await fetch("/api/options/repeater-choices?key=laser_software").then((r) => - r.json() - ); - setSoftwares(sw?.data ?? []); - } catch {} + const [typesRes, rigsRes] = await Promise.all([ + apiJson<{ data: { id: number; name: string }[] }>("/api/options/user_rig_type"), + apiJson<{ data: RigRow[] }>("/api/my/rigs"), + ]); + setRigTypes(typesRes.data); + setRigs(rigsRes.data); + } catch (e: any) { + console.warn("[rigs] initial load failed:", e?.message || e); + toast({ + title: "Failed to load", + description: "Could not load your rigs or rig types.", + variant: "destructive", + }); + } })(); - }, []); + }, [toast]); - const coerceId = (v: unknown) => { - if (typeof v === "number") return v; - if (typeof v === "string" && v !== "") { - const n = Number(v); - return Number.isFinite(n) ? n : v; + // Load static-ish options that depend on rig type + useEffect(() => { + // when rig type changes, clear type-specific fields + setValue("laser_focus_lens", null); + setValue("laser_scan_lens", null); + setValue("laser_scan_lens_apt", null); + setValue("laser_scan_lens_exp", null); + + if (!rigTarget) { + setSourceOpts([]); + setScanLensOpts([]); + return; } - return v ?? null; - }; + + (async () => { + try { + // laser sources (by target) + const src = await apiJson<{ data: Option[] }>(`/api/options/laser_source?target=${encodeURIComponent(rigTarget)}`); + setSourceOpts(src.data ?? []); + } catch { + setSourceOpts([]); + } + + try { + // software (generic list; if you have target-aware, swap the endpoint) + const soft = await apiJson<{ data: Option[] }>(`/api/options/laser_soft`); + setSoftwareOpts(soft.data ?? []); + } catch { + setSoftwareOpts([]); + } + + if (isScan) { + try { + const lenses = await apiJson<{ data: Option[] }>(`/api/options/lens?target=${encodeURIComponent(rigTarget)}`); + // server already formats "110x110mm (F160)"; keep but ensure scroll + setScanLensOpts(lenses.data ?? []); + } catch { + setScanLensOpts([]); + } + } else { + setScanLensOpts([]); + } + + if (isGantry) { + try { + // focus lenses are just name strings + const focus = await apiJson<{ data: Option[] }>(`/api/options/laser_focus_lens`); + setFocusLensOpts(focus.data ?? []); + } catch { + setFocusLensOpts([]); + } + } else { + setFocusLensOpts([]); + } + })(); + }, [rigTarget, isScan, isGantry, setValue]); async function onSubmit(values: FormValues) { try { + // shape for API const payload = { name: values.name, - rig_type: coerceId(values.rig_type), // IMPORTANT: send ID - laser_source: values.laser_source ? coerceId(values.laser_source) : null, - laser_focus_lens: - selectedTypeName === "co2_gantry" && values.laser_focus_lens - ? coerceId(values.laser_focus_lens) - : null, - laser_scan_lens: - selectedTypeName !== "co2_gantry" && values.laser_scan_lens - ? coerceId(values.laser_scan_lens) - : null, - laser_scan_lens_apt: - selectedTypeName !== "co2_gantry" && values.laser_scan_lens_apt - ? values.laser_scan_lens_apt - : null, - laser_scan_lens_exp: - selectedTypeName !== "co2_gantry" && values.laser_scan_lens_exp - ? values.laser_scan_lens_exp - : null, - laser_software: values.laser_software ? coerceId(values.laser_software) : null, - notes: values.notes ?? null, + rig_type: rigTypes.find((t) => String(t.name) === String(values.rig_type))?.id ?? values.rig_type, // allow id or name + laser_source: values.laser_source || null, + laser_software: values.laser_software || null, + laser_focus_lens: isGantry ? values.laser_focus_lens || null : null, + laser_scan_lens: isScan ? values.laser_scan_lens || null : null, + laser_scan_lens_apt: isScan ? values.laser_scan_lens_apt || null : null, + laser_scan_lens_exp: isScan ? values.laser_scan_lens_exp || null : null, + notes: values.notes || null, }; - const res = await fetch("/api/my/rigs", { + await apiJson("/api/my/rigs", { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - const json = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(json?.error || json?.errors?.[0]?.message || res.statusText); - } + toast({ title: "Rig saved", description: "Your rig was added." }); + + // refresh list + const rigsRes = await apiJson<{ data: RigRow[] }>("/api/my/rigs"); + setRigs(rigsRes.data); + + // keep rig type but clear the rest so it's quick to add another + reset({ + name: "", + rig_type: values.rig_type, + laser_source: null, + laser_software: null, + laser_focus_lens: null, + laser_scan_lens: null, + laser_scan_lens_apt: null, + laser_scan_lens_exp: null, + notes: "", + }); + } catch (e: any) { + const message = (() => { + try { + const j = JSON.parse(e?.message || "{}"); + if (j?.errors) return `Directus error ${j.errors?.[0]?.extensions?.code || ""}: ${j.errors?.[0]?.message || "Failed"}`; + } catch {} + return e?.message || "Failed to save rig"; + })(); - toast({ title: "Saved!", description: `Rig created (id: ${json?.data?.id ?? "?"}).` }); - form.reset({ name: "", rig_type: "", notes: "" }); - document.dispatchEvent(new CustomEvent("rigs:refresh")); - } catch (err: any) { toast({ title: "Failed to save rig", - description: String(err?.message || err), - variant: "destructive", + description: message, + variant: "destructive", }); } } - return ( - <> -
-
-

New Rig

- {selectedTypeName ? {selectedTypeName} : null} -
+ async function deleteRig(id: number) { + if (!confirm("Delete this rig?")) return; + try { + await apiJson(`/api/my/rigs/${id}`, { method: "DELETE" }); + setRigs((prev) => prev.filter((r) => r.id !== id)); + } catch (e: any) { + toast({ + title: "Delete failed", + description: e?.message || "Could not delete rig.", + variant: "destructive", + }); + } + } -
+ const rigTypeItems = useMemo( + () => rigTypes.map((t) => ({ value: String(t.name), label: String(t.name).replaceAll("_", " ") })), + [rigTypes] + ); + + // ───────────────────────────────────────────────────────────── + // UI + // ───────────────────────────────────────────────────────────── + + return ( +
+ + + New Rig + + + {/* Name */}
- - {form.formState.errors.name ? ( -

{form.formState.errors.name.message}

- ) : null} +
- {/* Rig Type (ID values) */} + {/* Rig type */}
- {form.formState.errors.rig_type ? ( -

{form.formState.errors.rig_type.message}

- ) : null}
- {/* Source */} + {/* Laser Source (optional) */}
- {/* Software */} + {/* Laser Software (optional) */}
- {/* Focus lens – only for co2_gantry */} - {selectedTypeName === "co2_gantry" && ( + {/* Notes spans 2 cols */} +
+ +