539 lines
22 KiB
TypeScript
539 lines
22 KiB
TypeScript
// components/forms/SettingsSubmit.tsx
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
/**
|
|
* From-scratch CO₂ Galvo form that follows the data sheet.
|
|
* - Prefill works via reset() with raw IDs.
|
|
* - Submits via /app/api/settings (this file does not assume any old helpers).
|
|
* - Lens options belong to Rig & Optics (not a separate "Lens Options" section).
|
|
*/
|
|
|
|
type Target = "settings_co2gal";
|
|
|
|
type Opt = { id: string; label: string };
|
|
type Me = { id: string; username?: string; email?: string };
|
|
|
|
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
|
|
|
type EditInitialValues = {
|
|
submission_id: string | number;
|
|
|
|
setting_title?: string;
|
|
setting_notes?: string;
|
|
|
|
photo?: string | null;
|
|
screen?: string | null;
|
|
|
|
// Material
|
|
mat?: string | null;
|
|
mat_coat?: string | null;
|
|
mat_color?: string | null;
|
|
mat_opacity?: string | null;
|
|
mat_thickness?: number | null;
|
|
|
|
// Rig & Optics
|
|
laser_soft?: string | null;
|
|
source?: string | null; // submission_id
|
|
lens?: string | null;
|
|
focus?: number | null;
|
|
// CO2 Galvo triplet (Rig & Optics)
|
|
lens_conf?: string | null;
|
|
lens_apt?: string | null;
|
|
lens_exp?: string | null;
|
|
|
|
repeat_all?: number | null;
|
|
|
|
// Repeaters
|
|
fill_settings?: any[] | null;
|
|
line_settings?: any[] | null;
|
|
raster_settings?: any[] | null;
|
|
};
|
|
|
|
type BaseProps = { mode?: "create" | "edit"; submissionId?: string | number; initialValues?: EditInitialValues | null };
|
|
export default function SettingsSubmit({ mode = "create", submissionId, initialValues }: BaseProps) {
|
|
const router = useRouter();
|
|
const isEdit = mode === "edit";
|
|
|
|
const [me, setMe] = useState<Me | null>(null);
|
|
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
fetch(`/api/me`, { cache: "no-store", credentials: "include" })
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((j) => alive && setMe(j || null))
|
|
.catch(() => alive && setMe(null));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
|
|
// Options loaders (raw Directus reads)
|
|
function useOptions(path: string, includeId?: string | null) {
|
|
const [opts, setOpts] = useState<Opt[]>([]);
|
|
useEffect(() => {
|
|
let live = true;
|
|
|
|
(async () => {
|
|
let url = "";
|
|
let map = (rows: any[]) => rows.map((r) => ({ id: String(r.id ?? r.submission_id), label: String(r.name ?? r.model ?? r.opacity ?? r.id) }));
|
|
|
|
if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`;
|
|
else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`;
|
|
else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`;
|
|
else if (path === "material_opacity") {
|
|
url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`;
|
|
map = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) }));
|
|
} else if (path === "laser_software") {
|
|
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
|
|
} else if (path === "laser_source_co2_galvo") {
|
|
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
|
|
// Explicitly reference the global Number to avoid the local <Number/> component shadowing it.
|
|
type Row = { submission_id?: string | number; make?: string; model?: string; nm?: string | number | null };
|
|
const toNum = (v: unknown): number | null => {
|
|
if (typeof v === "number") return globalThis.Number.isFinite(v) ? v : null;
|
|
if (typeof v === "string") {
|
|
const m = v.match(/-?\d+(\.\d+)?/);
|
|
const n = m ? globalThis.Number(m[0]) : NaN;
|
|
return globalThis.Number.isFinite(n) ? n : null;
|
|
}
|
|
return null;
|
|
};
|
|
map = (rows: Row[]) =>
|
|
rows
|
|
.filter((r) => {
|
|
const nmVal = toNum(r.nm);
|
|
return nmVal !== null && nmVal >= 10000 && nmVal <= 11000;
|
|
})
|
|
.map((r) => ({
|
|
id: String(r.submission_id ?? ""),
|
|
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id ?? ""),
|
|
}));
|
|
} else if (path === "laser_scan_lens") {
|
|
url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
|
|
map = (rows) =>
|
|
rows
|
|
.slice()
|
|
.sort((a, b) => (parseFloat(a.focal_length ?? "99999") || 99999) - (parseFloat(b.focal_length ?? "99999") || 99999))
|
|
.map((r) => {
|
|
const fs = r.field_size ? `${r.field_size} mm` : "";
|
|
const fl = r.focal_length ? `${r.focal_length} mm` : "";
|
|
const label = [fs, fl].filter(Boolean).join(" — ") || String(r.id);
|
|
return { id: String(r.id), label };
|
|
});
|
|
} else if (path === "laser_scan_lens_config") {
|
|
url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`;
|
|
} else if (path === "laser_scan_lens_apt") {
|
|
url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`;
|
|
} else if (path === "laser_scan_lens_exp") {
|
|
url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`;
|
|
} else {
|
|
setOpts([]);
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(url, { cache: "no-store", credentials: "include" });
|
|
const j = await res.json().catch(() => ({}));
|
|
const rows = Array.isArray(j?.data) ? j.data : [];
|
|
let list = map(rows);
|
|
|
|
if (includeId && !list.some((o) => String(o.id) === String(includeId))) {
|
|
list = [{ id: String(includeId), label: "(current selection)" }, ...list];
|
|
}
|
|
if (live) setOpts(list);
|
|
})().catch(() => live && setOpts([]));
|
|
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [path, includeId]);
|
|
|
|
return { opts };
|
|
}
|
|
|
|
// Enumerations
|
|
const FILL_TYPES: Opt[] = [
|
|
{ id: "uni", label: "UniDirectional" },
|
|
{ id: "bi", label: "BiDirectional" },
|
|
{ id: "offset", label: "Offset Fill" },
|
|
];
|
|
const RASTER_TYPES = FILL_TYPES;
|
|
const RASTER_DITHER: Opt[] = [
|
|
"threshold",
|
|
"ordered",
|
|
"atkinson",
|
|
"dither",
|
|
"stucki",
|
|
"jarvis",
|
|
"newsprint",
|
|
"halftone",
|
|
"sketch",
|
|
"grayscale",
|
|
].map((x) => ({ id: x, label: x[0].toUpperCase() + x.slice(1) }));
|
|
|
|
// react-hook-form
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
control,
|
|
reset,
|
|
formState: { isSubmitting },
|
|
} = useForm<any>({
|
|
defaultValues: {
|
|
setting_title: "",
|
|
setting_notes: "",
|
|
// Material
|
|
mat: "",
|
|
mat_coat: "",
|
|
mat_color: "",
|
|
mat_opacity: "",
|
|
mat_thickness: "",
|
|
// Rig & Optics
|
|
laser_soft: "",
|
|
source: "",
|
|
lens: "",
|
|
focus: "",
|
|
lens_conf: "",
|
|
lens_apt: "",
|
|
lens_exp: "",
|
|
repeat_all: "",
|
|
// Repeaters
|
|
fill_settings: [],
|
|
line_settings: [],
|
|
raster_settings: [],
|
|
},
|
|
});
|
|
|
|
// Repeaters
|
|
const fills = useFieldArray({ control, name: "fill_settings" });
|
|
const lines = useFieldArray({ control, name: "line_settings" });
|
|
const rasters = useFieldArray({ control, name: "raster_settings" });
|
|
|
|
// Prefill (edit)
|
|
useEffect(() => {
|
|
if (!isEdit || !initialValues) return;
|
|
reset({
|
|
setting_title: initialValues.setting_title ?? "",
|
|
setting_notes: initialValues.setting_notes ?? "",
|
|
// Material
|
|
mat: initialValues.mat ?? "",
|
|
mat_coat: initialValues.mat_coat ?? "",
|
|
mat_color: initialValues.mat_color ?? "",
|
|
mat_opacity: initialValues.mat_opacity ?? "",
|
|
mat_thickness: initialValues.mat_thickness ?? "",
|
|
// Rig & Optics
|
|
laser_soft: initialValues.laser_soft ?? "",
|
|
source: initialValues.source ?? "",
|
|
lens: initialValues.lens ?? "",
|
|
focus: initialValues.focus ?? "",
|
|
lens_conf: initialValues.lens_conf ?? "",
|
|
lens_apt: initialValues.lens_apt ?? "",
|
|
lens_exp: initialValues.lens_exp ?? "",
|
|
repeat_all: initialValues.repeat_all ?? "",
|
|
// Repeaters
|
|
fill_settings: initialValues.fill_settings ?? [],
|
|
line_settings: initialValues.line_settings ?? [],
|
|
raster_settings: initialValues.raster_settings ?? [],
|
|
});
|
|
}, [isEdit, initialValues, reset]);
|
|
|
|
// Option lists (include current IDs to guarantee a visible option)
|
|
const mats = useOptions("material", initialValues?.mat ?? null);
|
|
const coats = useOptions("material_coating", initialValues?.mat_coat ?? null);
|
|
const colors = useOptions("material_color", initialValues?.mat_color ?? null);
|
|
const opacs = useOptions("material_opacity", initialValues?.mat_opacity ?? null);
|
|
const soft = useOptions("laser_software", initialValues?.laser_soft ?? null);
|
|
const srcs = useOptions("laser_source_co2_galvo", initialValues?.source ?? null);
|
|
const lens = useOptions("laser_scan_lens", initialValues?.lens ?? null);
|
|
const conf = useOptions("laser_scan_lens_config", initialValues?.lens_conf ?? null);
|
|
const apt = useOptions("laser_scan_lens_apt", initialValues?.lens_apt ?? null);
|
|
const exp = useOptions("laser_scan_lens_exp", initialValues?.lens_exp ?? null);
|
|
|
|
// Image files
|
|
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
|
const [screenFile, setScreenFile] = useState<File | null>(null);
|
|
const onPick = (setter: (f: File | null) => void) => (e: React.ChangeEvent<HTMLInputElement>) => setter(e.target.files?.[0] ?? null);
|
|
|
|
const onSubmit = async (values: any) => {
|
|
setSubmitErr(null);
|
|
const payload: any = {
|
|
target: "settings_co2gal" as Target,
|
|
...(isEdit ? { mode: "edit" as const, submission_id: submissionId } : {}),
|
|
setting_title: values.setting_title,
|
|
setting_notes: values.setting_notes || "",
|
|
// Material
|
|
mat: values.mat || null,
|
|
mat_coat: values.mat_coat || null,
|
|
mat_color: values.mat_color || null,
|
|
mat_opacity: values.mat_opacity || null,
|
|
mat_thickness: values.mat_thickness === "" ? null : Number(values.mat_thickness),
|
|
// Rig & Optics
|
|
laser_soft: values.laser_soft || null,
|
|
source: values.source || null,
|
|
lens: values.lens || null,
|
|
focus: values.focus === "" ? null : Number(values.focus),
|
|
lens_conf: values.lens_conf || null,
|
|
lens_apt: values.lens_apt || null,
|
|
lens_exp: values.lens_exp || null,
|
|
repeat_all: values.repeat_all === "" ? null : Number(values.repeat_all),
|
|
// Repeaters (raw pass-through; api will normalize nums/bools)
|
|
fill_settings: values.fill_settings || [],
|
|
line_settings: values.line_settings || [],
|
|
raster_settings: values.raster_settings || [],
|
|
// If editing with existing asset IDs, the API will accept them
|
|
...(initialValues?.photo ? { photo: initialValues.photo } : {}),
|
|
...(initialValues?.screen ? { screen: initialValues.screen } : {}),
|
|
};
|
|
|
|
const form = new FormData();
|
|
form.set("payload", JSON.stringify(payload));
|
|
if (photoFile) form.set("photo", photoFile, photoFile.name || "photo");
|
|
if (screenFile) form.set("screen", screenFile, screenFile.name || "screen");
|
|
|
|
const res = await fetch("/api/settings", { method: "POST", body: form, credentials: "include" });
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setSubmitErr(j?.error || `Submit failed (${res.status})`);
|
|
return;
|
|
}
|
|
|
|
if (isEdit) {
|
|
router.back();
|
|
} else {
|
|
reset();
|
|
setPhotoFile(null);
|
|
setScreenFile(null);
|
|
router.push(`/submit/settings/success?id=${encodeURIComponent(String(j?.id ?? ""))}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<header className="space-y-1">
|
|
<h1 className="text-xl font-semibold">{isEdit ? "Edit CO₂ Galvo Setting" : "Submit CO₂ Galvo Setting"}</h1>
|
|
{me ? <p className="text-sm text-muted-foreground">Submitting as {me.username || me.email}</p> : null}
|
|
{submitErr ? <div className="border border-red-500 bg-red-50 text-red-700 rounded px-3 py-2 text-sm">{submitErr}</div> : null}
|
|
</header>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
{/* Info */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Info</h2>
|
|
<div className="grid gap-3">
|
|
<div>
|
|
<label className="block text-sm mb-1">Title *</label>
|
|
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm mb-1">Notes</label>
|
|
<textarea rows={4} className="w-full border rounded px-2 py-1" {...register("setting_notes")} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Images */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Images</h2>
|
|
<div className="grid md:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm mb-1">Result Photo {isEdit || initialValues?.photo ? null : <span className="text-red-600">*</span>}</label>
|
|
<input type="file" accept="image/*" onChange={onPick(setPhotoFile)} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm mb-1">Settings Screenshot (optional)</label>
|
|
<input type="file" accept="image/*" onChange={onPick(setScreenFile)} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Material */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Material</h2>
|
|
<div className="grid md:grid-cols-2 gap-3">
|
|
<Select label="Material *" {...{ name: "mat", register, options: mats.opts, required: true }} />
|
|
<Select label="Coating *" {...{ name: "mat_coat", register, options: coats.opts, required: true }} />
|
|
<Select label="Color *" {...{ name: "mat_color", register, options: colors.opts, required: true }} />
|
|
<Select label="Opacity *" {...{ name: "mat_opacity", register, options: opacs.opts, required: true }} />
|
|
<Number label="Material Thickness (mm)" name="mat_thickness" register={register} step="0.01" />
|
|
</div>
|
|
</section>
|
|
|
|
{/* Rig & Optics (includes lens_conf/apt/exp) */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-semibold">Rig & Optics</h2>
|
|
<div className="grid md:grid-cols-2 gap-3">
|
|
<Select label="Software *" {...{ name: "laser_soft", register, options: soft.opts, required: true }} />
|
|
<Select label="Laser Source *" {...{ name: "source", register, options: srcs.opts, required: true }} />
|
|
<Select label="Scan Lens *" {...{ name: "lens", register, options: lens.opts, required: true }} />
|
|
<Number label="Focus (mm) *" name="focus" register={register} step="1" />
|
|
<Number label="Repeat All" name="repeat_all" register={register} step="1" />
|
|
</div>
|
|
<div className="grid md:grid-cols-3 gap-3">
|
|
<Select label="Lens Configuration *" {...{ name: "lens_conf", register, options: conf.opts, required: true }} />
|
|
<Select label="Scan Head Aperture *" {...{ name: "lens_apt", register, options: apt.opts, required: true }} />
|
|
<Select label="Beam Expander *" {...{ name: "lens_exp", register, options: exp.opts, required: true }} />
|
|
</div>
|
|
</section>
|
|
|
|
{/* Process Settings */}
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-semibold">Process Settings</h2>
|
|
|
|
{/* Fill */}
|
|
<Repeater
|
|
title="Fill"
|
|
fields={fills.fields}
|
|
onAdd={() => fills.append({ type: "uni" })}
|
|
onRemove={(i) => fills.remove(i)}
|
|
render={(i) => (
|
|
<div className="grid md:grid-cols-4 gap-2">
|
|
<Text label="Name" name={`fill_settings.${i}.name`} register={register} />
|
|
<Select label="Type" name={`fill_settings.${i}.type`} register={register} options={FILL_TYPES} />
|
|
<Number label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} register={register} step="0.1" />
|
|
<Number label="Pulse (ns)" name={`fill_settings.${i}.pulse`} register={register} step="0.1" />
|
|
<Number label="Power (%)" name={`fill_settings.${i}.power`} register={register} step="0.1" />
|
|
<Number label="Speed (mm/s)" name={`fill_settings.${i}.speed`} register={register} step="0.1" />
|
|
<Number label="Interval (mm)" name={`fill_settings.${i}.interval`} register={register} step="0.001" />
|
|
<Number label="Pass" name={`fill_settings.${i}.pass`} register={register} step="1" />
|
|
<Number label="Angle (°)" name={`fill_settings.${i}.angle`} register={register} step="1" />
|
|
<Number label="Increment" name={`fill_settings.${i}.increment`} register={register} step="0.001" />
|
|
<Check label="Auto" name={`fill_settings.${i}.auto`} register={register} />
|
|
<Check label="Cross" name={`fill_settings.${i}.cross`} register={register} />
|
|
<Check label="Flood" name={`fill_settings.${i}.flood`} register={register} />
|
|
<Check label="Air" name={`fill_settings.${i}.air`} register={register} />
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
{/* Line */}
|
|
<Repeater
|
|
title="Line"
|
|
fields={lines.fields}
|
|
onAdd={() => lines.append({})}
|
|
onRemove={(i) => lines.remove(i)}
|
|
render={(i) => (
|
|
<div className="grid md:grid-cols-4 gap-2">
|
|
<Text label="Name" name={`line_settings.${i}.name`} register={register} />
|
|
<Number label="Frequency (kHz)" name={`line_settings.${i}.frequency`} register={register} step="0.1" />
|
|
<Number label="Pulse (ns)" name={`line_settings.${i}.pulse`} register={register} step="0.1" />
|
|
<Number label="Power (%)" name={`line_settings.${i}.power`} register={register} step="0.1" />
|
|
<Number label="Speed (mm/s)" name={`line_settings.${i}.speed`} register={register} step="0.1" />
|
|
<Check label="Perf" name={`line_settings.${i}.perf`} register={register} />
|
|
<Check label="Cut" name={`line_settings.${i}.cut`} register={register} />
|
|
<Check label="Skip" name={`line_settings.${i}.skip`} register={register} />
|
|
<Number label="Pass" name={`line_settings.${i}.pass`} register={register} step="1" />
|
|
<Number label="Step" name={`line_settings.${i}.step`} register={register} step="0.001" />
|
|
<Number label="Size" name={`line_settings.${i}.size`} register={register} step="0.001" />
|
|
<Check label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
|
|
<Check label="Air" name={`line_settings.${i}.air`} register={register} />
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
{/* Raster */}
|
|
<Repeater
|
|
title="Raster"
|
|
fields={rasters.fields}
|
|
onAdd={() => rasters.append({ type: "uni", dither: "threshold" })}
|
|
onRemove={(i) => rasters.remove(i)}
|
|
render={(i) => (
|
|
<div className="grid md:grid-cols-4 gap-2">
|
|
<Text label="Name" name={`raster_settings.${i}.name`} register={register} />
|
|
<Number label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} register={register} step="0.1" />
|
|
<Number label="Pulse (ns)" name={`raster_settings.${i}.pulse`} register={register} step="0.1" />
|
|
<Select label="Type" name={`raster_settings.${i}.type`} register={register} options={RASTER_TYPES} />
|
|
<Select label="Dither" name={`raster_settings.${i}.dither`} register={register} options={RASTER_DITHER} />
|
|
<Number label="Power (%)" name={`raster_settings.${i}.power`} register={register} step="0.1" />
|
|
<Number label="Speed (mm/s)" name={`raster_settings.${i}.speed`} register={register} step="0.1" />
|
|
<Number label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} register={register} step="1" />
|
|
<Number label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} register={register} step="1" />
|
|
<Number label="Interval (mm)" name={`raster_settings.${i}.interval`} register={register} step="0.001" />
|
|
<Number label="Dot" name={`raster_settings.${i}.dot`} register={register} step="0.1" />
|
|
<Number label="Pass" name={`raster_settings.${i}.pass`} register={register} step="1" />
|
|
<Check label="Cross" name={`raster_settings.${i}.cross`} register={register} />
|
|
<Check label="Inversion" name={`raster_settings.${i}.inversion`} register={register} />
|
|
<Check label="Air" name={`raster_settings.${i}.air`} register={register} />
|
|
</div>
|
|
)}
|
|
/>
|
|
</section>
|
|
|
|
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background disabled:opacity-50">
|
|
{isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* Small UI */
|
|
function Select({
|
|
label,
|
|
name,
|
|
register,
|
|
options,
|
|
required,
|
|
}: { label: string; name: string; register: UseFormRegister<any>; options: Opt[]; required?: boolean }) {
|
|
return (
|
|
<div>
|
|
<label className="block text-sm mb-1">
|
|
{label} {required ? <span className="text-red-600">*</span> : null}
|
|
</label>
|
|
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
|
|
<option value="">—</option>
|
|
{options.map((o) => (
|
|
<option key={o.id} value={o.id}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
function Number({ label, name, register, step }: any) {
|
|
return (
|
|
<div>
|
|
<label className="block text-sm mb-1">{label}</label>
|
|
<input type="number" step={step ?? "1"} className="w-full border rounded px-2 py-1" {...register(name)} />
|
|
</div>
|
|
);
|
|
}
|
|
function Text({ label, name, register }: any) {
|
|
return (
|
|
<div>
|
|
<label className="block text-sm mb-1">{label}</label>
|
|
<input className="w-full border rounded px-2 py-1" {...register(name)} />
|
|
</div>
|
|
);
|
|
}
|
|
function Check({ label, name, register }: any) {
|
|
return (
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" {...register(name)} /> {label}
|
|
</label>
|
|
);
|
|
}
|
|
function Repeater({ title, fields, onAdd, onRemove, render }: any) {
|
|
return (
|
|
<fieldset className="border rounded p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<legend className="font-semibold">{title}</legend>
|
|
<button type="button" className="px-2 py-1 border rounded" onClick={onAdd}>
|
|
+ Add
|
|
</button>
|
|
</div>
|
|
{fields.map((_: any, i: number) => (
|
|
<div key={i} className="space-y-2">
|
|
{render(i)}
|
|
<button type="button" className="px-2 py-1 border rounded" onClick={() => onRemove(i)}>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
))}
|
|
</fieldset>
|
|
);
|
|
}
|