fixes to rig type definitions causing 500 errors
This commit is contained in:
parent
226fcc8013
commit
384f0a4958
2 changed files with 391 additions and 297 deletions
|
|
@ -1,79 +1,95 @@
|
||||||
|
// app/api/my/rigs/route.ts
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { directusFetch } from "@/lib/directus";
|
|
||||||
|
|
||||||
// Change these if your collection/owner field differ
|
const BASE = process.env.DIRECTUS_URL!;
|
||||||
const BASE_COLLECTION = process.env.RIGS_COLLECTION || "rigs";
|
|
||||||
const OWNER_FIELD = process.env.RIGS_OWNER_FIELD || "owner";
|
|
||||||
|
|
||||||
// Pull the user's Directus access token from cookies (await to satisfy Next 15 typings)
|
function bearerFromCookies() {
|
||||||
async function bearerFromCookies() {
|
const store = cookies();
|
||||||
const jar = await cookies();
|
const at = store.get("ma_at")?.value;
|
||||||
const at = jar.get("ma_at")?.value;
|
|
||||||
if (!at) throw new Error("Not authenticated");
|
if (!at) throw new Error("Not authenticated");
|
||||||
return `Bearer ${at}`;
|
return `Bearer ${at}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve current Directus user id using their access token
|
async function fetchJSON(path: string, init: RequestInit = {}) {
|
||||||
async function getMeId(auth: string): Promise<string> {
|
const res = await fetch(`${BASE}${path}`, init);
|
||||||
const res = await directusFetch<{ data: { id: string } }>(
|
const text = await res.text();
|
||||||
`/users/me?fields=id`,
|
let json: any = null;
|
||||||
{ headers: { Authorization: auth } }
|
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||||
);
|
if (!res.ok) {
|
||||||
const id = res?.data?.id;
|
throw new Error(
|
||||||
if (!id) throw new Error("Unable to resolve current user id");
|
`Directus error ${res.status}: ${text || res.statusText}`
|
||||||
return id;
|
);
|
||||||
|
}
|
||||||
|
return json ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// List rigs that belong to the current user
|
export async function GET() {
|
||||||
export async function GET(_req: NextRequest) {
|
|
||||||
try {
|
try {
|
||||||
const auth = await bearerFromCookies();
|
const auth = bearerFromCookies();
|
||||||
const meId = await getMeId(auth);
|
// Your Users role already restricts READ to owner == $CURRENT_USER
|
||||||
|
const out = await fetchJSON(`/items/user_rigs?fields=*,owner.username`, {
|
||||||
const { data } = await directusFetch<{ data: any[] }>(
|
headers: { Authorization: auth, Accept: "application/json" },
|
||||||
`/items/${BASE_COLLECTION}?filter[${OWNER_FIELD}][_eq]=${encodeURIComponent(
|
});
|
||||||
meId
|
return NextResponse.json(out);
|
||||||
)}&limit=200&sort=-date_created`,
|
} catch (e: any) {
|
||||||
{ headers: { Authorization: auth } }
|
return NextResponse.json({ error: e.message || String(e) }, { status: 401 });
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, data });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || "List failed" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new rig for the current user
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const auth = await bearerFromCookies();
|
const auth = bearerFromCookies();
|
||||||
const meId = await getMeId(auth);
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
// minimal validation – keep it light, let Directus enforce the rest
|
||||||
|
const name = String(body?.name ?? "").trim();
|
||||||
|
const rig_type = String(body?.rig_type ?? "").trim();
|
||||||
|
|
||||||
// Ensure ownership is set to the current user
|
if (!name) return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||||
const payload = { ...body, [OWNER_FIELD]: meId };
|
if (!rig_type) return NextResponse.json({ error: "rig_type is required" }, { status: 400 });
|
||||||
|
|
||||||
const { data } = await directusFetch<{ data: any }>(
|
// Get the current user's id so we can set owner explicitly
|
||||||
`/items/${BASE_COLLECTION}`,
|
const me = await fetchJSON(`/users/me`, {
|
||||||
{
|
headers: { Authorization: auth, Accept: "application/json" },
|
||||||
method: "POST",
|
});
|
||||||
headers: {
|
const ownerId = me?.data?.id;
|
||||||
"Content-Type": "application/json",
|
if (!ownerId) throw new Error("Could not resolve current user id");
|
||||||
Authorization: auth,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, data });
|
const payload = {
|
||||||
} catch (err: any) {
|
name,
|
||||||
|
rig_type,
|
||||||
|
owner: ownerId,
|
||||||
|
// pass through optional relational fields only if present (prevents FK violations)
|
||||||
|
laser_source: body?.laser_source ?? null,
|
||||||
|
laser_focus_lens: body?.laser_focus_lens ?? null,
|
||||||
|
laser_scan_lens: body?.laser_scan_lens ?? null,
|
||||||
|
laser_scan_lens_apt: body?.laser_scan_lens_apt ?? null,
|
||||||
|
laser_scan_lens_exp: body?.laser_scan_lens_exp ?? null,
|
||||||
|
laser_software: body?.laser_software ?? null,
|
||||||
|
notes: body?.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await fetchJSON(`/items/user_rigs`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: auth,
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Prefer: "return=representation",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(created);
|
||||||
|
} catch (e: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: err?.message || "Create failed" },
|
{ error: e?.message || "Failed to create rig" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
const ms = Date.now() - started;
|
||||||
|
if (ms) console.log(`[my/rigs POST] in ~${ms}ms`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
"use client";
|
// app/my/rigs/page.tsx
|
||||||
|
import { cookies } from "next/headers";
|
||||||
import * as React from "react";
|
import SignOutButton from "@/components/SignOutButton";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
|
|
@ -17,311 +11,395 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import SignOutButton from "@/components/SignOutButton";
|
import { z } from "zod";
|
||||||
|
|
||||||
type Option = { id: string; label: string };
|
// Server-only: load rig types with the user's bearer
|
||||||
|
const API_BASE = process.env.DIRECTUS_URL!;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
type RigType = { id: number | string; name: "fiber" | "uv" | "co2_galvo" | "co2_gantry" | string };
|
||||||
// Rig Type → options mapping
|
|
||||||
// These mirror existing “settings_*” targets used by the options endpoints.
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
const RIG_TYPES = [
|
|
||||||
{ value: "settings_fiber", label: "Fiber (Galvo)" },
|
|
||||||
{ value: "settings_uv", label: "UV (Galvo)" },
|
|
||||||
{ value: "settings_co2gal", label: "CO₂ Galvo" },
|
|
||||||
{ value: "settings_co2gan", label: "CO₂ Gantry" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type RigType = (typeof RIG_TYPES)[number]["value"];
|
async function loadRigTypes(): Promise<RigType[]> {
|
||||||
const isGantry = (t: RigType) => t === "settings_co2gan";
|
const ck = await cookies();
|
||||||
|
const at = ck.get("ma_at")?.value;
|
||||||
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
|
if (at) headers.Authorization = `Bearer ${at}`;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
const res = await fetch(
|
||||||
// Validation – conditionally require the right lens field
|
`${API_BASE}/items/user_rig_type?fields=id,name&sort=sort`,
|
||||||
// ─────────────────────────────────────────────────────────────
|
{ cache: "no-store", headers }
|
||||||
const RigSchema = z
|
);
|
||||||
.object({
|
if (!res.ok) {
|
||||||
name: z.string().min(1, "Name is required"),
|
console.warn("[my/rigs] failed to load rig types:", await res.text());
|
||||||
rig_type: z.enum(RIG_TYPES.map((r) => r.value) as [RigType, ...RigType[]]),
|
return [];
|
||||||
laser_source: z.string().min(1, "Source is required"),
|
|
||||||
laser_software: z.string().min(1, "Software is required"),
|
|
||||||
// lens fields (one of these is required depending on rig_type)
|
|
||||||
laser_scan_lens: z.string().optional(),
|
|
||||||
laser_focus_lens: z.string().optional(),
|
|
||||||
// optional galvo bits
|
|
||||||
laser_scan_lens_apt: z.string().optional(),
|
|
||||||
laser_scan_lens_exp: z.string().optional(),
|
|
||||||
notes: z.string().optional(),
|
|
||||||
})
|
|
||||||
.superRefine((val, ctx) => {
|
|
||||||
if (isGantry(val.rig_type)) {
|
|
||||||
if (!val.laser_focus_lens) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["laser_focus_lens"], message: "Focus lens is required for CO₂ Gantry" });
|
|
||||||
}
|
|
||||||
// clear any galvo-only extras client-side
|
|
||||||
val.laser_scan_lens = undefined;
|
|
||||||
val.laser_scan_lens_apt = undefined;
|
|
||||||
val.laser_scan_lens_exp = undefined;
|
|
||||||
} else {
|
|
||||||
if (!val.laser_scan_lens) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["laser_scan_lens"], message: "Scan lens is required" });
|
|
||||||
}
|
|
||||||
// clear the gantry-only field
|
|
||||||
val.laser_focus_lens = undefined;
|
|
||||||
}
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
return (json?.data ?? []) as RigType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// (optional) helper while converting string -> number if numeric
|
||||||
|
function coerceId(v: unknown) {
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
if (typeof v === "string") {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : v;
|
||||||
|
}
|
||||||
|
return v ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MyRigsPage() {
|
||||||
|
const rigTypes = await loadRigTypes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-8 space-y-8">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold">My Rigs</h1>
|
||||||
|
<SignOutButton redirectTo="/auth/sign-in" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RigBuilder rigTypes={rigTypes} />
|
||||||
|
<RigList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────
|
||||||
|
* Client: Form + interactions
|
||||||
|
* ────────────────────────────────────────────────────────── */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const FormSchema = z.object({
|
||||||
|
name: z.string().min(2, "Please enter a name"),
|
||||||
|
rig_type: z.string().min(1, "Choose a rig type"), // we’ll coerce to number/uuid on submit
|
||||||
|
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 RigForm = z.infer<typeof RigSchema>;
|
type FormValues = z.infer<typeof FormSchema>;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
function RigBuilder({ rigTypes }: { rigTypes: RigType[] }) {
|
||||||
// Helpers
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
async function fetchOptions(url: string): Promise<Option[]> {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const json = await res.json().catch(() => null);
|
|
||||||
return json?.data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map rig_type → /api/options target
|
|
||||||
function targetFor(type: RigType): string {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MyRigsPage() {
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<RigForm>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(RigSchema),
|
resolver: zodResolver(FormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
rig_type: "settings_fiber",
|
rig_type: "",
|
||||||
laser_source: "",
|
notes: "",
|
||||||
laser_software: "",
|
},
|
||||||
laser_scan_lens: "",
|
|
||||||
laser_focus_lens: "",
|
|
||||||
laser_scan_lens_apt: "",
|
|
||||||
laser_scan_lens_exp: "",
|
|
||||||
notes: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rigType = form.watch("rig_type");
|
const selectedType = useMemo(() => {
|
||||||
|
const v = form.watch("rig_type");
|
||||||
|
return rigTypes.find((r) => String(r.id) === String(v))?.name ?? "";
|
||||||
|
}, [form, rigTypes]);
|
||||||
|
|
||||||
const [sources, setSources] = useState<Option[]>([]);
|
// Options (client-side) – you already have these /api/options endpoints
|
||||||
const [software, setSoftware] = useState<Option[]>([]);
|
const [laserSources, setLaserSources] = useState<{ id: string; label: string }[]>([]);
|
||||||
const [scanLens, setScanLens] = useState<Option[]>([]);
|
const [scanLenses, setScanLenses] = useState<{ id: string; label: string }[]>([]);
|
||||||
const [focusLens, setFocusLens] = useState<Option[]>([]);
|
const [focusLenses, setFocusLenses] = useState<{ id: string; label: string }[]>([]);
|
||||||
const [apertures, setApertures] = useState<Option[]>([]);
|
const [softwares, setSoftwares] = useState<{ id: string; label: string }[]>([]);
|
||||||
const [expanders, setExpanders] = useState<Option[]>([]);
|
|
||||||
|
|
||||||
// Load static options that don’t depend on rig_type
|
// Fetch option lists (simple, debounced-less; tweak as desired)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchOptions("/api/options/laser_software").then(setSoftware);
|
(async () => {
|
||||||
|
try {
|
||||||
|
// laser sources – all
|
||||||
|
const ls = await fetch("/api/options/laser_source").then((r) => r.json());
|
||||||
|
setLaserSources(ls?.data ?? []);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
// scan lenses – list for all scan types
|
||||||
|
const sl = await fetch("/api/options/lens?target=settings_fiber").then((r) => r.json());
|
||||||
|
setScanLenses(sl?.data ?? []);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
// focus lenses – gantry focuses
|
||||||
|
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 {}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load options that do depend on rig_type
|
async function onSubmit(values: FormValues) {
|
||||||
useEffect(() => {
|
try {
|
||||||
const target = targetFor(rigType);
|
const payload = {
|
||||||
// laser sources filtered by wavelength
|
name: values.name,
|
||||||
fetchOptions(`/api/options/laser_source?target=${encodeURIComponent(target)}`).then(setSources);
|
rig_type: coerceId(values.rig_type),
|
||||||
|
laser_source: values.laser_source ? coerceId(values.laser_source) : null,
|
||||||
|
laser_focus_lens:
|
||||||
|
selectedType === "co2_gantry" && values.laser_focus_lens
|
||||||
|
? coerceId(values.laser_focus_lens)
|
||||||
|
: null,
|
||||||
|
laser_scan_lens:
|
||||||
|
selectedType !== "co2_gantry" && values.laser_scan_lens
|
||||||
|
? coerceId(values.laser_scan_lens)
|
||||||
|
: null,
|
||||||
|
laser_scan_lens_apt:
|
||||||
|
selectedType !== "co2_gantry" && values.laser_scan_lens_apt
|
||||||
|
? coerceId(values.laser_scan_lens_apt)
|
||||||
|
: null,
|
||||||
|
laser_scan_lens_exp:
|
||||||
|
selectedType !== "co2_gantry" && values.laser_scan_lens_exp
|
||||||
|
? coerceId(values.laser_scan_lens_exp)
|
||||||
|
: null,
|
||||||
|
laser_software: values.laser_software ? coerceId(values.laser_software) : null,
|
||||||
|
notes: values.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
// lens: our /api/options/lens endpoint already returns:
|
const res = await fetch("/api/my/rigs", {
|
||||||
// - SCAN lenses for galvo targets (with mm + F-number label)
|
method: "POST",
|
||||||
// - FOCUS lenses for CO₂ gantry target (label=name)
|
headers: { "Content-Type": "application/json" },
|
||||||
fetchOptions(`/api/options/lens?target=${encodeURIComponent(target)}`).then((data) => {
|
body: JSON.stringify(payload),
|
||||||
if (isGantry(rigType)) {
|
});
|
||||||
setFocusLens(data);
|
|
||||||
setScanLens([]);
|
const json = await res.json().catch(() => ({}));
|
||||||
} else {
|
if (!res.ok) {
|
||||||
setScanLens(data);
|
throw new Error(json?.error || json?.errors?.[0]?.message || res.statusText);
|
||||||
setFocusLens([]);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Optional galvo-only option lists (safe to fetch; we’ll hide in UI for gantry)
|
toast({ title: "Saved!", description: `Rig created (id: ${json?.data?.id ?? "?"}).` });
|
||||||
fetchOptions("/api/options/laser_scan_lens_apt").then(setApertures);
|
form.reset({ name: "", rig_type: "", notes: "" });
|
||||||
fetchOptions("/api/options/laser_scan_lens_exp").then(setExpanders);
|
// You might also trigger a refetch on the RigList (simple hacky way below)
|
||||||
}, [rigType]);
|
document.dispatchEvent(new CustomEvent("rigs:refresh"));
|
||||||
|
} catch (err: any) {
|
||||||
// When the rig type flips, wipe fields that no longer apply
|
toast({
|
||||||
useEffect(() => {
|
title: "Failed to save rig",
|
||||||
if (isGantry(rigType)) {
|
description: String(err?.message || err),
|
||||||
form.setValue("laser_scan_lens", "");
|
variant: "destructive",
|
||||||
form.setValue("laser_scan_lens_apt", "");
|
});
|
||||||
form.setValue("laser_scan_lens_exp", "");
|
|
||||||
} else {
|
|
||||||
form.setValue("laser_focus_lens", "");
|
|
||||||
}
|
}
|
||||||
}, [rigType, form]);
|
|
||||||
|
|
||||||
async function onSubmit(values: RigForm) {
|
|
||||||
const res = await fetch("/api/my/rigs", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(values),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = (await res.json().catch(() => ({} as any)))?.error || res.statusText;
|
|
||||||
toast({ title: "Failed to save rig", description: msg });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast({ title: "Rig saved", description: "Your rig has been created." });
|
|
||||||
form.reset({ ...form.getValues(), name: "" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
|
||||||
// UI
|
|
||||||
// ─────────────────────────────────────────────────────────
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
<div className="rounded-xl border bg-card p-4 md:p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-semibold">My Rigs</h1>
|
<h2 className="text-lg font-medium">New Rig</h2>
|
||||||
<SignOutButton />
|
{selectedType ? <Badge variant="secondary">{selectedType}</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Rig name</Label>
|
<label className="text-sm font-medium">Name</label>
|
||||||
<Input id="name" {...form.register("name")} placeholder="e.g. Shop Fiber #1" />
|
<Input placeholder="My Fiber #1" {...form.register("name")} />
|
||||||
<p className="text-sm text-muted-foreground">{form.formState.errors.name?.message}</p>
|
{form.formState.errors.name ? (
|
||||||
|
<p className="text-xs text-red-500">{form.formState.errors.name.message}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rig Type */}
|
{/* Rig Type (IDs) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Rig type</Label>
|
<label className="text-sm font-medium">Rig Type</label>
|
||||||
<Select
|
<Select
|
||||||
value={rigType}
|
value={form.watch("rig_type")}
|
||||||
onValueChange={(v) => form.setValue("rig_type", v as RigType)}
|
onValueChange={(v) => form.setValue("rig_type", v, { shouldValidate: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger><SelectValue placeholder="Choose a rig type" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectContent>
|
<SelectValue placeholder="Choose a rig type" />
|
||||||
{RIG_TYPES.map((t) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
<SelectContent className="max-h-64 overflow-y-auto">
|
||||||
|
{rigTypes.map((rt) => (
|
||||||
|
<SelectItem key={rt.id} value={String(rt.id)}>
|
||||||
|
{rt.name}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{form.formState.errors.rig_type ? (
|
||||||
|
<p className="text-xs text-red-500">{form.formState.errors.rig_type.message}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source */}
|
{/* Laser Source */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Laser source</Label>
|
<label className="text-sm font-medium">LASER Source</label>
|
||||||
<Select
|
<Select
|
||||||
value={form.watch("laser_source") || ""}
|
value={form.watch("laser_source") ?? ""}
|
||||||
onValueChange={(v) => form.setValue("laser_source", v)}
|
onValueChange={(v) => form.setValue("laser_source", v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger><SelectValue placeholder="Select a source" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectContent>
|
<SelectValue placeholder="Optional" />
|
||||||
{sources.map((o) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
<SelectContent className="max-h-64 overflow-y-auto">
|
||||||
|
{laserSources.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={String(o.id)}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_source?.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Software */}
|
{/* Software */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Software</Label>
|
<label className="text-sm font-medium">LASER Software</label>
|
||||||
<Select
|
<Select
|
||||||
value={form.watch("laser_software") || ""}
|
value={form.watch("laser_software") ?? ""}
|
||||||
onValueChange={(v) => form.setValue("laser_software", v)}
|
onValueChange={(v) => form.setValue("laser_software", v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger><SelectValue placeholder="Select software" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectContent>
|
<SelectValue placeholder="Optional" />
|
||||||
{software.map((o) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
<SelectContent className="max-h-64 overflow-y-auto">
|
||||||
|
{softwares.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={String(o.id)}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_software?.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lens fields (conditional) */}
|
{/* Focus lens – ONLY for co2_gantry */}
|
||||||
{!isGantry(rigType) ? (
|
{selectedType === "co2_gantry" && (
|
||||||
<>
|
|
||||||
{/* SCAN lens (galvo) */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Scan lens</Label>
|
<label className="text-sm font-medium">LASER Focus Lens</label>
|
||||||
<Select
|
<Select
|
||||||
value={form.watch("laser_scan_lens") || ""}
|
value={form.watch("laser_focus_lens") ?? ""}
|
||||||
onValueChange={(v) => form.setValue("laser_scan_lens", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select a scan lens" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{scanLens.map((o) => (
|
|
||||||
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_scan_lens?.message}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Optional galvo bits */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Scan head aperture (optional)</Label>
|
|
||||||
<Select
|
|
||||||
value={form.watch("laser_scan_lens_apt") || ""}
|
|
||||||
onValueChange={(v) => form.setValue("laser_scan_lens_apt", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select aperture" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{apertures.map((o) => (
|
|
||||||
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Beam expander (optional)</Label>
|
|
||||||
<Select
|
|
||||||
value={form.watch("laser_scan_lens_exp") || ""}
|
|
||||||
onValueChange={(v) => form.setValue("laser_scan_lens_exp", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select expander" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{expanders.map((o) => (
|
|
||||||
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// FOCUS lens (gantry)
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Focus lens</Label>
|
|
||||||
<Select
|
|
||||||
value={form.watch("laser_focus_lens") || ""}
|
|
||||||
onValueChange={(v) => form.setValue("laser_focus_lens", v)}
|
onValueChange={(v) => form.setValue("laser_focus_lens", v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger><SelectValue placeholder="Select a focus lens" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectContent>
|
<SelectValue placeholder="Select a focus lens" />
|
||||||
{focusLens.map((o) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
<SelectContent className="max-h-64 overflow-y-auto">
|
||||||
|
{focusLenses.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={String(o.id)}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_focus_lens?.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Scan lens fields – for fiber/uv/co2_galvo */}
|
||||||
<div className="space-y-2">
|
{selectedType && selectedType !== "co2_gantry" && (
|
||||||
<Label htmlFor="notes">Notes (optional)</Label>
|
<>
|
||||||
<Textarea id="notes" rows={4} {...form.register("notes")} placeholder="Anything special about this rig…" />
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">LASER Scan Lens</label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("laser_scan_lens") ?? ""}
|
||||||
|
onValueChange={(v) => form.setValue("laser_scan_lens", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a scan lens" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64 overflow-y-auto">
|
||||||
|
{scanLenses.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={String(o.id)}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Scan Head Aperture (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 10 mm"
|
||||||
|
{...form.register("laser_scan_lens_apt")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Beam Expander (optional)</label>
|
||||||
|
<Input placeholder="e.g. 2×" {...form.register("laser_scan_lens_exp")} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-sm font-medium">Notes</label>
|
||||||
|
<Textarea rows={4} placeholder="Optional notes…" {...form.register("notes")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="md:col-span-2">
|
||||||
<Button type="submit">Save Rig</Button>
|
<Button type="submit" className="w-full md:w-auto">
|
||||||
<Button type="button" variant="secondary" onClick={() => form.reset()}>Clear</Button>
|
Save Rig
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Very small client list (refreshes when a rig is created)
|
||||||
|
function RigList() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
async function fetchRigs() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/my/rigs", { cache: "no-store" });
|
||||||
|
const json = await res.json();
|
||||||
|
setItems(json?.data ?? []);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({ title: "Failed to load rigs", description: String(e?.message || e), variant: "destructive" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRigs();
|
||||||
|
const onRefresh = () => fetchRigs();
|
||||||
|
document.addEventListener("rigs:refresh", onRefresh);
|
||||||
|
return () => document.removeEventListener("rigs:refresh", onRefresh);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No rigs yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-base font-medium">Your Rigs</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{r.name}</span>
|
||||||
|
{r?.rig_type_name ? <Badge variant="secondary">{r.rig_type_name}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/my/rigs/${r.id}`, { method: "DELETE" });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j?.error || res.statusText);
|
||||||
|
document.dispatchEvent(new CustomEvent("rigs:refresh"));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({
|
||||||
|
title: "Delete failed",
|
||||||
|
description: String(err?.message || err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue