makearmy-app/app/my/rigs/RigBuilderClient.tsx

489 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState } from "react";
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,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
// ─────────────────────────────────────────────────────────────
// 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<string, string> = {
fiber: "settings_fiber",
uv: "settings_uv",
co2_galvo: "settings_co2gal",
co2_gantry: "settings_co2gan",
};
async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
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<T>;
}
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<typeof schema>;
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
export default function RigBuilderClient() {
const { toast } = useToast();
// Lists
const [rigTypes, setRigTypes] = useState<RigType[]>([]);
const [rigs, setRigs] = useState<RigRow[]>([]);
// Options that depend on rig type
const [sourceOpts, setSourceOpts] = useState<Option[]>([]);
const [softwareOpts, setSoftwareOpts] = useState<Option[]>([]);
const [scanLensOpts, setScanLensOpts] = useState<Option[]>([]);
const [focusLensOpts, setFocusLensOpts] = useState<Option[]>([]);
// Form
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { isSubmitting },
} = useForm<FormValues>({
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 rigTypeVal = watch("rig_type");
const rigTarget = RIG_TARGET_MAP[rigTypeVal ?? ""] || "";
const isGantry = rigTypeVal === "co2_gantry";
const isScan = rigTypeVal === "fiber" || rigTypeVal === "uv" || rigTypeVal === "co2_galvo";
// Initial loads
useEffect(() => {
(async () => {
try {
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]);
// 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;
}
(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: 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,
};
await apiJson("/api/my/rigs", {
method: "POST",
body: JSON.stringify(payload),
});
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: "Failed to save rig",
description: message,
variant: "destructive",
});
}
}
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 (
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>New Rig</CardTitle>
</CardHeader>
<CardContent>
<form className="grid grid-cols-1 gap-6 md:grid-cols-2" onSubmit={handleSubmit(onSubmit)}>
{/* Name */}
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input placeholder="e.g. My Fiber #1" {...register("name")} />
</div>
{/* Rig type */}
<div className="space-y-2">
<label className="text-sm font-medium">Rig Type</label>
<Select
value={rigTypeVal}
onValueChange={(v) => setValue("rig_type", v)}
>
<SelectTrigger>
<SelectValue placeholder="Choose a rig type" />
</SelectTrigger>
{/* add scroll so big lists are usable */}
<SelectContent className="max-h-64 overflow-y-auto">
{rigTypeItems.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>
{rt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Laser Source (optional) */}
<div className="space-y-2">
<label className="text-sm font-medium">LASER Source</label>
<Select
value={watch("laser_source") ?? "none"}
onValueChange={(v) => setValue("laser_source", v === "none" ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent className="max-h-64 overflow-y-auto">
<SelectItem value="none"></SelectItem>
{sourceOpts.map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Laser Software (optional) */}
<div className="space-y-2">
<label className="text-sm font-medium">LASER Software</label>
<Select
value={watch("laser_software") ?? "none"}
onValueChange={(v) => setValue("laser_software", v === "none" ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent className="max-h-64 overflow-y-auto">
<SelectItem value="none"></SelectItem>
{softwareOpts.map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Notes spans 2 cols */}
<div className="col-span-1 md:col-span-2 space-y-2">
<label className="text-sm font-medium">Notes</label>
<Textarea placeholder="Optional notes…" rows={4} {...register("notes")} />
</div>
{/* CONDITIONAL: Focus lens for CO2 Gantry */}
{isGantry && (
<div className="col-span-1 md:col-span-2 grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">LASER Focus Lens</label>
<Select
value={watch("laser_focus_lens") ?? "none"}
onValueChange={(v) => setValue("laser_focus_lens", v === "none" ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent className="max-h-64 overflow-y-auto">
<SelectItem value="none"></SelectItem>
{focusLensOpts.map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* CONDITIONAL: Scan lens + accessories for fiber/uv/co2_galvo */}
{isScan && (
<div className="col-span-1 md:col-span-2 grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium">LASER Scan Lens</label>
<Select
value={watch("laser_scan_lens") ?? "none"}
onValueChange={(v) => setValue("laser_scan_lens", v === "none" ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent className="max-h-64 overflow-y-auto">
<SelectItem value="none"></SelectItem>
{scanLensOpts.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 Lens Aperture</label>
<Select
value={watch("laser_scan_lens_apt") ?? "none"}
onValueChange={(v) => setValue("laser_scan_lens_apt", v === "none" ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent className="max-h-64 overflow-y-auto">
<SelectItem value="none"></SelectItem>
{/* These can be swapped to real options when you expose them as /api/options/... */}
<SelectItem value="10mm">10 mm</SelectItem>
<SelectItem value="14mm">14 mm</SelectItem>
<SelectItem value="20mm">20 mm</SelectItem>
<SelectItem value="30mm">30 mm</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Beam Expander</label>
<Select
value={watch("laser_scan_lens_exp") ?? "none"}
onValueChange={(v) => setValue("laser_scan_lens_exp", v === "none" ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent className="max-h-64 overflow-y-auto">
<SelectItem value="none"></SelectItem>
<SelectItem value="1.5x">1.5×</SelectItem>
<SelectItem value="2x">2×</SelectItem>
<SelectItem value="3x">3×</SelectItem>
<SelectItem value="4x">4×</SelectItem>
<SelectItem value="5x">5×</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="col-span-1 md:col-span-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving…" : "Save Rig"}
</Button>
</div>
</form>
</CardContent>
</Card>
{/* Existing rigs */}
<div className="space-y-3">
<h2 className="text-xl font-semibold">Your Rigs</h2>
<div className="divide-y divide-border rounded-md border">
{rigs.length === 0 && (
<div className="p-4 text-sm opacity-70">No rigs yet.</div>
)}
{rigs.map((r) => (
<div key={r.id} className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div className="font-medium">{r.name}</div>
{r.rig_type_name && (
<Badge variant="secondary">{r.rig_type_name}</Badge>
)}
</div>
<Button variant="destructive" size="sm" onClick={() => deleteRig(r.id)}>
Delete
</Button>
</div>
))}
</div>
</div>
</div>
);
}