327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
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 { Button } from "@/components/ui/button";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectTrigger,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import SignOutButton from "@/components/SignOutButton";
|
||
|
||
type Option = { id: string; label: 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"];
|
||
const isGantry = (t: RigType) => t === "settings_co2gan";
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Validation – conditionally require the right lens field
|
||
// ─────────────────────────────────────────────────────────────
|
||
const RigSchema = z
|
||
.object({
|
||
name: z.string().min(1, "Name is required"),
|
||
rig_type: z.enum(RIG_TYPES.map((r) => r.value) as [RigType, ...RigType[]]),
|
||
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;
|
||
}
|
||
});
|
||
|
||
type RigForm = z.infer<typeof RigSchema>;
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// 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 form = useForm<RigForm>({
|
||
resolver: zodResolver(RigSchema),
|
||
defaultValues: {
|
||
name: "",
|
||
rig_type: "settings_fiber",
|
||
laser_source: "",
|
||
laser_software: "",
|
||
laser_scan_lens: "",
|
||
laser_focus_lens: "",
|
||
laser_scan_lens_apt: "",
|
||
laser_scan_lens_exp: "",
|
||
notes: "",
|
||
},
|
||
});
|
||
|
||
const rigType = form.watch("rig_type");
|
||
|
||
const [sources, setSources] = useState<Option[]>([]);
|
||
const [software, setSoftware] = useState<Option[]>([]);
|
||
const [scanLens, setScanLens] = useState<Option[]>([]);
|
||
const [focusLens, setFocusLens] = useState<Option[]>([]);
|
||
const [apertures, setApertures] = useState<Option[]>([]);
|
||
const [expanders, setExpanders] = useState<Option[]>([]);
|
||
|
||
// Load static options that don’t depend on rig_type
|
||
useEffect(() => {
|
||
fetchOptions("/api/options/laser_software").then(setSoftware);
|
||
}, []);
|
||
|
||
// Load options that do depend on rig_type
|
||
useEffect(() => {
|
||
const target = targetFor(rigType);
|
||
// laser sources filtered by wavelength
|
||
fetchOptions(`/api/options/laser_source?target=${encodeURIComponent(target)}`).then(setSources);
|
||
|
||
// lens: our /api/options/lens endpoint already returns:
|
||
// - SCAN lenses for galvo targets (with mm + F-number label)
|
||
// - FOCUS lenses for CO₂ gantry target (label=name)
|
||
fetchOptions(`/api/options/lens?target=${encodeURIComponent(target)}`).then((data) => {
|
||
if (isGantry(rigType)) {
|
||
setFocusLens(data);
|
||
setScanLens([]);
|
||
} else {
|
||
setScanLens(data);
|
||
setFocusLens([]);
|
||
}
|
||
});
|
||
|
||
// Optional galvo-only option lists (safe to fetch; we’ll hide in UI for gantry)
|
||
fetchOptions("/api/options/laser_scan_lens_apt").then(setApertures);
|
||
fetchOptions("/api/options/laser_scan_lens_exp").then(setExpanders);
|
||
}, [rigType]);
|
||
|
||
// When the rig type flips, wipe fields that no longer apply
|
||
useEffect(() => {
|
||
if (isGantry(rigType)) {
|
||
form.setValue("laser_scan_lens", "");
|
||
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 (
|
||
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-semibold">My Rigs</h1>
|
||
<SignOutButton />
|
||
</div>
|
||
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||
{/* Name */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">Rig name</Label>
|
||
<Input id="name" {...form.register("name")} placeholder="e.g. Shop Fiber #1" />
|
||
<p className="text-sm text-muted-foreground">{form.formState.errors.name?.message}</p>
|
||
</div>
|
||
|
||
{/* Rig Type */}
|
||
<div className="space-y-2">
|
||
<Label>Rig type</Label>
|
||
<Select
|
||
value={rigType}
|
||
onValueChange={(v) => form.setValue("rig_type", v as RigType)}
|
||
>
|
||
<SelectTrigger><SelectValue placeholder="Choose a rig type" /></SelectTrigger>
|
||
<SelectContent>
|
||
{RIG_TYPES.map((t) => (
|
||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Source */}
|
||
<div className="space-y-2">
|
||
<Label>Laser source</Label>
|
||
<Select
|
||
value={form.watch("laser_source") || ""}
|
||
onValueChange={(v) => form.setValue("laser_source", v)}
|
||
>
|
||
<SelectTrigger><SelectValue placeholder="Select a source" /></SelectTrigger>
|
||
<SelectContent>
|
||
{sources.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_source?.message}</p>
|
||
</div>
|
||
|
||
{/* Software */}
|
||
<div className="space-y-2">
|
||
<Label>Software</Label>
|
||
<Select
|
||
value={form.watch("laser_software") || ""}
|
||
onValueChange={(v) => form.setValue("laser_software", v)}
|
||
>
|
||
<SelectTrigger><SelectValue placeholder="Select software" /></SelectTrigger>
|
||
<SelectContent>
|
||
{software.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_software?.message}</p>
|
||
</div>
|
||
|
||
{/* Lens fields (conditional) */}
|
||
{!isGantry(rigType) ? (
|
||
<>
|
||
{/* SCAN lens (galvo) */}
|
||
<div className="space-y-2">
|
||
<Label>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>
|
||
{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)}
|
||
>
|
||
<SelectTrigger><SelectValue placeholder="Select a focus lens" /></SelectTrigger>
|
||
<SelectContent>
|
||
{focusLens.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_focus_lens?.message}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Notes */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="notes">Notes (optional)</Label>
|
||
<Textarea id="notes" rows={4} {...form.register("notes")} placeholder="Anything special about this rig…" />
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<Button type="submit">Save Rig</Button>
|
||
<Button type="button" variant="secondary" onClick={() => form.reset()}>Clear</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|