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

539 lines
21 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.

// app/my/rigs/RigBuilderClient.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
let _redirectingDueToAuth = false;
function handleAuthError(err: any): boolean {
const status = (err as any)?.status;
const code = (err as any)?.code;
if (status === 401 || code === "TOKEN_EXPIRED") {
if (_redirectingDueToAuth) return true;
_redirectingDueToAuth = true;
const here = window.location.pathname + window.location.search;
const onSignIn = window.location.pathname.startsWith("/auth");
const next = encodeURIComponent(here);
window.location.replace(onSignIn ? `/auth/sign-in` : `/auth/sign-in?next=${next}`);
return true;
}
return false;
}
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; name: 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;
};
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
const SETTINGS_TARGET_MAP: Record<string, string> = {
fiber: "settings_fiber",
co2_gantry: "settings_co2gan",
co2_galvo: "settings_co2gal",
uv: "settings_uv",
};
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",
credentials: "include",
});
const txt = await res.text().catch(() => "");
if (res.ok) {
try { return JSON.parse(txt) as T; } catch { return undefined as T; }
}
let body: any = undefined;
try { body = JSON.parse(txt); } catch {}
if (body && typeof body.error === "string") {
try { body = JSON.parse(body.error); } catch {}
}
const err: any = new Error(`HTTP ${res.status} for ${url}`);
err.status = res.status;
err.code = body?.errors?.[0]?.extensions?.code || body?.code;
err.body = body ?? txt;
throw err;
}
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(),
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[]>([]);
// Load laser software list once (independent of rig type)
useEffect(() => {
(async () => {
try {
const swJson = await apiJson<{ data: Option[] }>(`/api/options/laser_software`);
const sw = Array.isArray(swJson?.data) ? swJson.data : [];
setSoftwareOpts(sw);
} catch (e: any) {
if (!handleAuthError(e)) {
console.error("[laser_software] load failed:", e);
setSoftwareOpts([]);
}
}
})();
}, []);
// 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 settingsTarget = SETTINGS_TARGET_MAP[rigTypeVal ?? ""] ?? "";
const isGantry = rigTypeVal === "co2_gantry";
const isScan = rigTypeVal === "fiber" || rigTypeVal === "uv" || rigTypeVal === "co2_galvo";
// Initial loads (rig types + existing rigs)
useEffect(() => {
(async () => {
try {
const [typesRes, rigsRes] = await Promise.all([
apiJson<{ data: { id: number | string; name: string }[] }>(`/api/options/user_rig_type`),
apiJson<{ data: RigRow[] }>(`/api/my/rigs`),
]);
const mappedTypes: RigType[] = (typesRes?.data ?? []).map((t) => ({
id: t.id,
name: t.name as any,
}));
setRigTypes(mappedTypes);
setRigs(rigsRes.data ?? []);
} catch (e: any) {
if (!handleAuthError(e)) {
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 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 (!settingsTarget) {
setSourceOpts([]);
setScanLensOpts([]);
setFocusLensOpts([]);
return;
}
(async () => {
// LASER sources by target (matches settings_* targets), fallback to no target
try {
const withTarget = await apiJson<{ data: Option[] }>(
`/api/options/laser_source?target=${encodeURIComponent(settingsTarget)}`
);
let list = withTarget?.data ?? [];
if (!list.length) {
const fallback = await apiJson<{ data: Option[] }>(`/api/options/laser_source`);
list = fallback?.data ?? [];
}
setSourceOpts(list);
} catch (e: any) {
if (!handleAuthError(e)) console.error("[laser_source] load failed:", e);
setSourceOpts([]);
}
if (isScan) {
try {
const lensesJson = await apiJson<{ data: Option[] }>(
`/api/options/lens?target=${encodeURIComponent(settingsTarget)}`
);
setScanLensOpts(lensesJson.data ?? []);
} catch (e: any) {
if (!handleAuthError(e)) console.error("[scan_lens] load failed:", e);
setScanLensOpts([]);
}
} else {
setScanLensOpts([]);
}
if (isGantry) {
try {
const focusJson = await apiJson<{ data: Option[] }>(`/api/options/laser_focus_lens`);
setFocusLensOpts(focusJson.data ?? []);
} catch (e: any) {
if (!handleAuthError(e)) console.error("[focus_lens] load failed:", e);
setFocusLensOpts([]);
}
} else {
setFocusLensOpts([]);
}
})();
}, [settingsTarget, isScan, isGantry, setValue]);
async function onSubmit(values: FormValues) {
try {
const payload = {
name: values.name,
// Your select uses the slug (name) as value; map it back to id for save:
rig_type:
rigTypes.find((t) => String(t.name) === String(values.rig_type))?.id ??
values.rig_type,
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." });
const rigsRes = await apiJson<{ data: RigRow[] }>(`/api/my/rigs`);
setRigs(rigsRes.data ?? []);
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) {
if (handleAuthError(e)) return;
const message = (() => {
try {
const j = typeof e?.body === "object" ? e.body : JSON.parse(e?.message || "{}");
if (j?.errors) {
const first = j.errors[0];
return `${first?.extensions?.code || "API"}: ${first?.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) {
if (handleAuthError(e)) return;
toast({
title: "Delete failed",
description: e?.message || "Could not delete rig.",
variant: "destructive",
});
}
}
const rigTypeItems = useMemo(
() =>
rigTypes.map((t) => ({
value: String(t.name), // using slug as value per your current pattern
label: String(t.name).replaceAll("_", " "),
})),
[rigTypes]
);
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>
<SelectContent position="popper" className="max-h-64 overflow-y-auto z-50 bg-background text-foreground border">
{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 position="popper" className="max-h-64 overflow-y-auto z-50 bg-background text-foreground border">
<SelectItem value="none"></SelectItem>
{sourceOpts.map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.name}
</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 position="popper" className="max-h-64 overflow-y-auto z-50 bg-background text-foreground border">
<SelectItem value="none"></SelectItem>
{softwareOpts.map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Notes */}
<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 position="popper" className="max-h-64 overflow-y-auto z-50 bg-background text-foreground border">
<SelectItem value="none"></SelectItem>
{focusLensOpts.map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.name}
</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 position="popper" className="max-h-64 overflow-y-auto z-50 bg-background text-foreground border">
<SelectItem value="none"></SelectItem>
{scanLensOpts.map((o) => (
<SelectItem key={o.id} value={String(o.id)}>
{o.name}
</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 position="popper" className="max-h-64 overflow-y-auto z-50 bg-background text-foreground border">
<SelectItem value="none"></SelectItem>
{/* Static until API endpoint for these is exposed */}
<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 position="popper" className="max-h-64 overflow-y-auto z-50 bg-background text-foreground border">
<SelectItem value="none"></SelectItem>
{/* Static until API endpoint for these is exposed */}
<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>
);
}