settings UI fixes
This commit is contained in:
parent
98ba42dfef
commit
15b9e42603
3 changed files with 317 additions and 397 deletions
|
|
@ -1,4 +1,3 @@
|
|||
// components/details/CO2GalvoDetail.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
|
@ -14,14 +13,15 @@ type Rec = {
|
|||
photo?: { id?: string } | string | null;
|
||||
screen?: { id?: string } | string | null;
|
||||
|
||||
mat?: { id?: string | number } | string | number | null;
|
||||
mat_coat?: { id?: string | number } | string | number | null;
|
||||
mat_color?: { id?: string | number } | string | number | null;
|
||||
mat_opacity?: { id?: string | number } | string | number | null;
|
||||
// ids & readable fields
|
||||
mat?: { id?: string | number; name?: string | null } | null;
|
||||
mat_coat?: { id?: string | number; name?: string | null } | null;
|
||||
mat_color?: { id?: string | number; name?: string | null } | null;
|
||||
mat_opacity?: { id?: string | number; opacity?: string | number | null } | null;
|
||||
mat_thickness?: number | null;
|
||||
|
||||
source?: { submission_id?: string | number } | string | number | null;
|
||||
lens?: { id?: string | number } | string | number | null;
|
||||
source?: { submission_id?: string | number; make?: string | null; model?: string | null; nm?: string | null } | null;
|
||||
lens?: { id?: string | number; field_size?: string | number | null; focal_length?: string | number | null } | null;
|
||||
focus?: number | null;
|
||||
|
||||
laser_soft?: any;
|
||||
|
|
@ -39,11 +39,7 @@ type Rec = {
|
|||
|
||||
async function readJson(res: Response) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`Unexpected response (HTTP ${res.status})`);
|
||||
}
|
||||
try { return text ? JSON.parse(text) : null; } catch { throw new Error(`Unexpected response (HTTP ${res.status})`); }
|
||||
}
|
||||
|
||||
function ownerLabel(o: Rec["owner"]) {
|
||||
|
|
@ -52,6 +48,10 @@ function ownerLabel(o: Rec["owner"]) {
|
|||
return o.username || String(o.id ?? "—");
|
||||
}
|
||||
|
||||
function fileUrl(id?: string) {
|
||||
return id ? `/api/dx/assets/${id}` : "";
|
||||
}
|
||||
|
||||
export default function CO2GalvoDetail({
|
||||
id,
|
||||
mode,
|
||||
|
|
@ -74,7 +74,7 @@ export default function CO2GalvoDetail({
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// load record (by submission_id)
|
||||
// load record (with human-readable fields)
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let dead = false;
|
||||
|
|
@ -90,19 +90,34 @@ export default function CO2GalvoDetail({
|
|||
"setting_notes",
|
||||
"photo.id",
|
||||
"screen.id",
|
||||
|
||||
"mat.id",
|
||||
"mat.name",
|
||||
"mat_coat.id",
|
||||
"mat_coat.name",
|
||||
"mat_color.id",
|
||||
"mat_color.name",
|
||||
"mat_opacity.id",
|
||||
"mat_opacity.opacity",
|
||||
"mat_thickness",
|
||||
|
||||
"source.submission_id",
|
||||
"source.make",
|
||||
"source.model",
|
||||
"source.nm",
|
||||
|
||||
"lens.id",
|
||||
"lens.field_size",
|
||||
"lens.focal_length",
|
||||
|
||||
"focus",
|
||||
"laser_soft",
|
||||
"repeat_all",
|
||||
|
||||
"fill_settings",
|
||||
"line_settings",
|
||||
"raster_settings",
|
||||
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
"uploader",
|
||||
|
|
@ -129,29 +144,22 @@ export default function CO2GalvoDetail({
|
|||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
dead = true;
|
||||
};
|
||||
return () => { dead = true; };
|
||||
}, [id]);
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
if (!rec) return null;
|
||||
const toId = (v: any) => (v == null ? null : typeof v === "object" ? v.id ?? v.submission_id ?? null : v);
|
||||
|
||||
const toId = (v: any) =>
|
||||
v == null ? null : typeof v === "object" ? v.id ?? v.submission_id ?? null : v;
|
||||
const photoId = typeof rec.photo === "string" || typeof rec.photo === "number" ? String(rec.photo) : rec.photo?.id ?? null;
|
||||
const screenId = typeof rec.screen === "string" || typeof rec.screen === "number" ? String(rec.screen) : rec.screen?.id ?? null;
|
||||
|
||||
const photoId =
|
||||
typeof rec.photo === "string" || typeof rec.photo === "number" ? String(rec.photo) : rec.photo?.id ?? null;
|
||||
const screenId =
|
||||
typeof rec.screen === "string" || typeof rec.screen === "number" ? String(rec.screen) : rec.screen?.id ?? null;
|
||||
|
||||
const matId = toId(rec.mat);
|
||||
const coatId = toId(rec.mat_coat);
|
||||
const colorId = toId(rec.mat_color);
|
||||
const matId = toId(rec.mat);
|
||||
const coatId = toId(rec.mat_coat);
|
||||
const colorId = toId(rec.mat_color);
|
||||
const opacityId = toId(rec.mat_opacity);
|
||||
const lensId = toId(rec.lens);
|
||||
const sourceId =
|
||||
rec.source && typeof rec.source === "object" ? rec.source.submission_id ?? null : (rec.source as any) ?? null;
|
||||
const lensId = toId(rec.lens);
|
||||
const sourceId = rec.source && typeof rec.source === "object" ? rec.source.submission_id ?? null : (rec.source as any) ?? null;
|
||||
|
||||
return {
|
||||
setting_title: rec.setting_title ?? "",
|
||||
|
|
@ -164,7 +172,7 @@ export default function CO2GalvoDetail({
|
|||
mat_opacity: opacityId ? String(opacityId) : null,
|
||||
mat_thickness: rec.mat_thickness ?? null,
|
||||
source: sourceId != null ? String(sourceId) : null,
|
||||
lens: lensId != null ? String(lensId) : null,
|
||||
lens: lensId != null ? String(lensId) : null,
|
||||
focus: rec.focus ?? null,
|
||||
laser_soft: rec.laser_soft ?? null,
|
||||
repeat_all: rec.repeat_all ?? null,
|
||||
|
|
@ -181,76 +189,187 @@ export default function CO2GalvoDetail({
|
|||
}
|
||||
|
||||
if (loading) return <p className="p-6">Loading setting…</p>;
|
||||
if (err)
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">{err}</div>
|
||||
</div>
|
||||
);
|
||||
if (err) return <div className="p-6"><div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">{err}</div></div>;
|
||||
if (!rec) return <p className="p-6">Setting not found.</p>;
|
||||
|
||||
// EDIT MODE
|
||||
// EDIT
|
||||
if (editMode && initialValues) {
|
||||
return (
|
||||
<main className="p-4 lg:p-6 max-w-5xl mx-auto space-y-4">
|
||||
<main className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl lg:text-2xl font-semibold">Edit CO₂ Galvo Setting</h1>
|
||||
<button
|
||||
className="px-2 py-1 border rounded"
|
||||
onClick={() => {
|
||||
if (onBack) onBack();
|
||||
else clearEditParam();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SettingsSubmit
|
||||
mode="edit"
|
||||
initialTarget="settings_co2gal"
|
||||
submissionId={rec.submission_id}
|
||||
initialValues={initialValues}
|
||||
/>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link className="underline" href={`/settings/co2-galvo/${rec.submission_id}`}>
|
||||
← Back to view
|
||||
</Link>
|
||||
<button className="px-2 py-1 border rounded" onClick={() => (onBack ? onBack() : clearEditParam())}>Cancel</button>
|
||||
</div>
|
||||
<SettingsSubmit mode="edit" initialTarget="settings_co2gal" submissionId={rec.submission_id} initialValues={initialValues} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// VIEW MODE
|
||||
// VIEW (readable)
|
||||
const ownerDisplay = ownerLabel(rec.owner);
|
||||
const photoId = typeof rec.photo === "object" ? rec.photo?.id : (rec.photo as any);
|
||||
const screenId = typeof rec.screen === "object" ? rec.screen?.id : (rec.screen as any);
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="card bg-card p-4 mb-4 rounded border">
|
||||
<h1 className="text-2xl font-bold mb-2 break-words">{rec.setting_title || "Untitled"}</h1>
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-bold break-words">{rec.setting_title || "Untitled"}</h1>
|
||||
<div className="text-sm text-muted-foreground">Last modified: {rec.last_modified_date || "—"}</div>
|
||||
</header>
|
||||
|
||||
{/* debug block left intact for now */}
|
||||
<pre className="text-xs bg-muted/30 rounded p-2 mb-2 overflow-auto">
|
||||
{JSON.stringify({ owner: rec?.owner }, null, 2)}
|
||||
</pre>
|
||||
<section className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">
|
||||
<div><span className="font-medium">Owner:</span> {ownerDisplay}</div>
|
||||
<div><span className="font-medium">Uploader:</span> {rec.uploader || "—"}</div>
|
||||
<div><span className="font-medium">Material:</span> {rec.mat?.name || "—"}</div>
|
||||
<div><span className="font-medium">Coating:</span> {rec.mat_coat?.name || "—"}</div>
|
||||
<div><span className="font-medium">Color:</span> {rec.mat_color?.name || "—"}</div>
|
||||
<div><span className="font-medium">Opacity:</span> {rec.mat_opacity?.opacity ?? "—"}</div>
|
||||
<div><span className="font-medium">Thickness (mm):</span> {rec.mat_thickness ?? "—"}</div>
|
||||
<div><span className="font-medium">Laser Source:</span> {[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") || "—"}{rec.source?.nm ? ` (${rec.source.nm})` : ""}</div>
|
||||
<div><span className="font-medium">Scan Lens:</span> {rec.lens?.field_size || "—"}{rec.lens?.focal_length ? ` / ${rec.lens.focal_length} mm` : ""}</div>
|
||||
<div><span className="font-medium">Focus (mm):</span> {rec.focus ?? "—"}</div>
|
||||
<div><span className="font-medium">Software:</span> {rec.laser_soft ?? "—"}</div>
|
||||
<div><span className="font-medium">Repeat All:</span> {rec.repeat_all ?? "—"}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Owner:</strong> {ownerDisplay}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Uploader:</strong> {rec.uploader || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{rec.setting_notes ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Notes</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{rec.setting_notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showOwnerEdit && (
|
||||
<div className="pt-2">
|
||||
<Link className="underline" href={`/settings/co2-galvo/${rec.submission_id}?edit=1`}>
|
||||
Edit this setting
|
||||
</Link>
|
||||
<Link href={`/settings/co2-galvo/${rec.submission_id}?edit=1`} className="underline">Edit this setting</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{photoId ? (
|
||||
<figure className="border rounded overflow-hidden">
|
||||
<img src={fileUrl(String(photoId))} alt="Result" className="w-full h-auto" />
|
||||
<figcaption className="text-xs p-1 text-muted-foreground">Result</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
{screenId ? (
|
||||
<figure className="border rounded overflow-hidden">
|
||||
<img src={fileUrl(String(screenId))} alt="Settings screenshot" className="w-full h-auto" />
|
||||
<figcaption className="text-xs p-1 text-muted-foreground">Settings Screenshot</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Repeaters */}
|
||||
{(rec.fill_settings?.length ?? 0) > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Fill Settings</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1 text-left">Type</th>
|
||||
<th className="px-2 py-1 text-left">Power (%)</th>
|
||||
<th className="px-2 py-1 text-left">Speed (mm/s)</th>
|
||||
<th className="px-2 py-1 text-left">Interval</th>
|
||||
<th className="px-2 py-1 text-left">Angle</th>
|
||||
<th className="px-2 py-1 text-left">Pass</th>
|
||||
<th className="px-2 py-1 text-left">Freq (kHz)</th>
|
||||
<th className="px-2 py-1 text-left">Pulse (ns)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rec.fill_settings!.map((r: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">{r.name || "—"}</td>
|
||||
<td className="px-2 py-1">{r.type || "—"}</td>
|
||||
<td className="px-2 py-1">{r.power ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.speed ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.interval ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.angle ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.pass ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.frequency ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.pulse ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(rec.line_settings?.length ?? 0) > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Line Settings</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1 text-left">Power</th>
|
||||
<th className="px-2 py-1 text-left">Speed</th>
|
||||
<th className="px-2 py-1 text-left">Freq</th>
|
||||
<th className="px-2 py-1 text-left">Pulse</th>
|
||||
<th className="px-2 py-1 text-left">Pass</th>
|
||||
<th className="px-2 py-1 text-left">Air</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rec.line_settings!.map((r: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">{r.name || "—"}</td>
|
||||
<td className="px-2 py-1">{r.power ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.speed ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.frequency ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.pulse ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.pass ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.air ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(rec.raster_settings?.length ?? 0) > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Raster Settings</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1 text-left">Type</th>
|
||||
<th className="px-2 py-1 text-left">Dither</th>
|
||||
<th className="px-2 py-1 text-left">Power</th>
|
||||
<th className="px-2 py-1 text-left">Speed</th>
|
||||
<th className="px-2 py-1 text-left">Interval</th>
|
||||
<th className="px-2 py-1 text-left">Pass</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rec.raster_settings!.map((r: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">{r.name || "—"}</td>
|
||||
<td className="px-2 py-1">{r.type || "—"}</td>
|
||||
<td className="px-2 py-1">{r.dither || "—"}</td>
|
||||
<td className="px-2 py-1">{r.power ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.speed ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.interval ?? "—"}</td>
|
||||
<td className="px-2 py-1">{r.pass ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,286 +1,64 @@
|
|||
// components/lists/CO2GalvoList.tsx
|
||||
"use client";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
value={localQuery}
|
||||
onChange={(e) => {
|
||||
setLocalQuery(e.currentTarget.value);
|
||||
onQueryChange?.(e.currentTarget.value);
|
||||
})}
|
||||
placeholder="Search by title, owner, material, model…"
|
||||
className="w-full border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type Owner =
|
||||
| string
|
||||
| number
|
||||
| {
|
||||
id?: string | number;
|
||||
username?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type SettingsRow = {
|
||||
submission_id: string | number;
|
||||
setting_title?: string | null;
|
||||
uploader?: string | null;
|
||||
owner?: Owner;
|
||||
mat?: { name?: string | null } | null;
|
||||
mat_coat?: { name?: string | null } | null;
|
||||
source?: { model?: string | null } | null;
|
||||
lens?: { field_size?: string | number | null } | null;
|
||||
};
|
||||
|
||||
export type CO2GalvoListProps = {
|
||||
/** Build the href for a record (portal vs standalone) */
|
||||
linkFor: (submission_id: string | number, opts?: { edit?: boolean }) => string;
|
||||
/** Optional controlled search text (else internal input) */
|
||||
queryText?: string;
|
||||
onQueryChange?: (q: string) => void;
|
||||
};
|
||||
|
||||
async function readJson(res: Response) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`Unexpected response (status ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function CO2GalvoList({ linkFor, queryText, onQueryChange }: CO2GalvoListProps) {
|
||||
const [settings, setSettings] = useState<SettingsRow[]>([]);
|
||||
const [ownerMap, setOwnerMap] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvingOwners, setResolvingOwners] = useState(false);
|
||||
const [meId, setMeId] = useState<string | null>(null);
|
||||
const [localQuery, setLocalQuery] = useState(queryText ?? "");
|
||||
|
||||
// keep local and external search text in sync
|
||||
useEffect(() => {
|
||||
if (queryText !== undefined) setLocalQuery(queryText);
|
||||
}, [queryText]);
|
||||
|
||||
const q = localQuery;
|
||||
|
||||
// load current user id (for edit visibility)
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/dx/users/me?fields=id`, {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!r.ok) return;
|
||||
const j = await readJson(r);
|
||||
const id = j?.data?.id ?? j?.id ?? null;
|
||||
if (!dead) setMeId(id ? String(id) : null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
dead = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// load list
|
||||
useEffect(() => {
|
||||
const fields = [
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"uploader",
|
||||
"owner",
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
"photo.id",
|
||||
"photo.title",
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"source.model",
|
||||
"lens.field_size",
|
||||
].join(",");
|
||||
|
||||
const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&limit=-1`;
|
||||
|
||||
fetch(url, { cache: "no-store", credentials: "include" })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const j = await readJson(res).catch(() => null);
|
||||
const msg = (j as any)?.errors?.[0]?.message || `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return readJson(res);
|
||||
})
|
||||
.then((json: any) => setSettings(Array.isArray(json?.data) ? json.data : []))
|
||||
.catch((e) => {
|
||||
console.error("CO2 Galvo list fetch failed:", e);
|
||||
setSettings([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// resolve owner usernames if needed
|
||||
useEffect(() => {
|
||||
if (!settings.length) return;
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const s of settings) {
|
||||
const o = s.owner;
|
||||
if (!o) continue;
|
||||
|
||||
if (typeof o === "string" || typeof o === "number") {
|
||||
const k = String(o);
|
||||
if (!ownerMap[k]) ids.add(k);
|
||||
} else {
|
||||
const id = o.id != null ? String(o.id) : "";
|
||||
const hasUsername = !!o.username;
|
||||
if (id && !hasUsername && !ownerMap[id]) ids.add(id);
|
||||
}
|
||||
}
|
||||
if (!ids.size) return;
|
||||
|
||||
const all = Array.from(ids);
|
||||
const chunk = 100;
|
||||
setResolvingOwners(true);
|
||||
|
||||
(async () => {
|
||||
const updates: Record<string, string> = {};
|
||||
for (let i = 0; i < all.length; i += chunk) {
|
||||
const slice = all.slice(i, i + chunk);
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("fields", "id,username");
|
||||
qs.set("limit", String(slice.length));
|
||||
qs.set("filter[id][_in]", slice.join(","));
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/dx/users?${qs.toString()}`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await readJson(r).catch(() => null);
|
||||
const msg = (j as any)?.errors?.[0]?.message || `HTTP ${r.status}`;
|
||||
console.warn("Owner lookup failed:", msg);
|
||||
if (r.status === 401 || r.status === 403) break;
|
||||
continue;
|
||||
}
|
||||
const j = await readJson(r);
|
||||
const rows: Array<{ id: string; username?: string | null }> = j?.data || [];
|
||||
for (const row of rows) {
|
||||
updates[String(row.id)] = row.username || String(row.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Owner lookup error:", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
setOwnerMap((prev) => ({ ...prev, ...updates }));
|
||||
}
|
||||
setResolvingOwners(false);
|
||||
})();
|
||||
}, [settings, ownerMap]);
|
||||
|
||||
const isMine = (o: Owner) => {
|
||||
if (!meId || !o) return false;
|
||||
if (typeof o === "string" || typeof o === "number") return String(o) === meId;
|
||||
if (o.id != null) return String(o.id) === meId;
|
||||
return false;
|
||||
};
|
||||
|
||||
const ownerLabel = (o: Owner) => {
|
||||
if (!o) return "—";
|
||||
if (typeof o === "string" || typeof o === "number") {
|
||||
const id = String(o);
|
||||
return ownerMap[id] || id;
|
||||
}
|
||||
{loading ? (
|
||||
<p>Loading…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<thead className="sticky top-0">
|
||||
<tr className="border-b">
|
||||
<th className="px-2 py-2 text-left w-[30%]">Title</th>
|
||||
<th className="px-2 py-2 text-left w-[14%]">Owner</th>
|
||||
<th className="px-2 py-2 text-left w-[14%]">Material</th>
|
||||
<th className="px-2 py-2 text-left w-[14%]">Coating</th>
|
||||
<th className="px-2 py-2 text-left w-[14%]">Model</th>
|
||||
<th className="px-2 py-2 text-left w-[10%]">Field</th>
|
||||
<th className="px-2 py-2 text-left w-[4%]">Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filtered.map((s) => {
|
||||
const mine = isMine(s.owner);
|
||||
const ownerText = ownerLabel(s.owner) + (mine ? " (you)" : "");
|
||||
return (
|
||||
o.username ||
|
||||
[o.first_name, o.last_name].filter(Boolean).join(" ").trim() ||
|
||||
o.email ||
|
||||
(o.id != null ? String(o.id) : "—")
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const nq = (q || "").toLowerCase();
|
||||
if (!nq) return settings;
|
||||
return settings.filter((entry) => {
|
||||
const fields = [
|
||||
entry.setting_title,
|
||||
ownerLabel(entry.owner),
|
||||
entry.uploader,
|
||||
entry.mat?.name,
|
||||
entry.mat_coat?.name,
|
||||
entry.source?.model,
|
||||
entry.lens?.field_size as any,
|
||||
].filter(Boolean);
|
||||
return fields.some((v: any) => String(v).toLowerCase().includes(nq));
|
||||
});
|
||||
}, [settings, q, ownerMap]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
value={localQuery}
|
||||
onChange={(e) => {
|
||||
setLocalQuery(e.currentTarget.value);
|
||||
onQueryChange?.(e.currentTarget.value);
|
||||
}}
|
||||
placeholder="Search by title, owner, material, model…"
|
||||
className="w-full max-w-lg border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p>Loading…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
<th className="px-2 py-2 text-left">Title</th>
|
||||
<th className="px-2 py-2 text-left">Owner {resolvingOwners ? "…resolving" : ""}</th>
|
||||
<th className="px-2 py-2 text-left">Material</th>
|
||||
<th className="px-2 py-2 text-left">Coating</th>
|
||||
<th className="px-2 py-2 text-left">Model</th>
|
||||
<th className="px-2 py-2 text-left">Field</th>
|
||||
<th className="px-2 py-2 text-left">Actions</th>
|
||||
<tr key={s.submission_id} className="hover:bg-muted/40">
|
||||
<td className="px-2 py-2 truncate">
|
||||
<a href={linkFor(s.submission_id)} className="underline">
|
||||
{s.setting_title || "Untitled"}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-2 py-2 truncate">{ownerText}</td>
|
||||
<td className="px-2 py-2 truncate">{s.mat?.name || "—"}</td>
|
||||
<td className="px-2 py-2 truncate">{s.mat_coat?.name || "—"}</td>
|
||||
<td className="px-2 py-2 truncate">{s.source?.model || "—"}</td>
|
||||
<td className="px-2 py-2 truncate">{s.lens?.field_size || "—"}</td>
|
||||
<td className="px-2 py-2">
|
||||
{mine ? (
|
||||
<a href={linkFor(s.submission_id, { edit: true })} className="underline">
|
||||
Edit
|
||||
</a>
|
||||
) : (
|
||||
<span className="opacity-50">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => {
|
||||
const mine = isMine(s.owner);
|
||||
const ownerText = ownerLabel(s.owner) + (mine ? " (you)" : "");
|
||||
return (
|
||||
<tr key={s.submission_id} className="border-b hover:bg-muted/30">
|
||||
<td className="px-2 py-2 whitespace-nowrap">
|
||||
<Link href={linkFor(s.submission_id)} className="underline">
|
||||
{s.setting_title || "Untitled"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{ownerText}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{s.mat?.name || "—"}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{s.mat_coat?.name || "—"}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{s.source?.model || "—"}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{s.lens?.field_size || "—"}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">
|
||||
{mine ? (
|
||||
<Link href={linkFor(s.submission_id, { edit: true })} className="underline">
|
||||
Edit
|
||||
</Link>
|
||||
) : (
|
||||
<span className="opacity-50">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// components/portal/panels/CO2GalvoPanel.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
|
@ -10,47 +9,71 @@ export default function CO2GalvoPanel() {
|
|||
const router = useRouter();
|
||||
|
||||
const id = sp.get("id");
|
||||
const edit = sp.get("edit") === "1";
|
||||
const view = sp.get("view") === "detail" && id ? "detail" : "list";
|
||||
|
||||
function linkFor(submission_id: string | number, opts?: { edit?: boolean }) {
|
||||
function setView(next: "list" | "detail", nextId?: string | number) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", "co2-galvo");
|
||||
q.set("id", String(submission_id));
|
||||
if (opts?.edit) q.set("edit", "1");
|
||||
else q.delete("edit");
|
||||
return `/portal/laser-settings?${q.toString()}`;
|
||||
}
|
||||
|
||||
function clearIdAndEdit() {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.delete("id");
|
||||
q.delete("edit");
|
||||
if (next === "detail" && nextId != null) {
|
||||
q.set("view", "detail");
|
||||
q.set("id", String(nextId));
|
||||
} else {
|
||||
q.set("view", "list");
|
||||
q.delete("id");
|
||||
q.delete("edit");
|
||||
}
|
||||
router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="rounded-md border p-4">
|
||||
<CO2GalvoList linkFor={linkFor} />
|
||||
</div>
|
||||
const linkFor = (sid: string | number, opts?: { edit?: boolean }) => {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", "co2-galvo");
|
||||
q.set("view", "detail");
|
||||
q.set("id", String(sid));
|
||||
if (opts?.edit) q.set("edit", "1"); else q.delete("edit");
|
||||
return `/portal/laser-settings?${q.toString()}`;
|
||||
};
|
||||
|
||||
<div className="rounded-md border p-4 min-h-[240px]">
|
||||
{id ? (
|
||||
<CO2GalvoDetail
|
||||
id={id}
|
||||
mode={edit ? "edit" : "view"}
|
||||
onBack={clearIdAndEdit}
|
||||
onSaved={() => {
|
||||
// Post-save: return to view mode and refetch by dropping edit=1 in place.
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.delete("edit");
|
||||
router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Select a setting to view details</div>
|
||||
)}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* App-style header (no big boxes) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`px-3 py-1.5 rounded border ${view === "list" ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => id && setView("detail", id)}
|
||||
disabled={!id}
|
||||
className={`px-3 py-1.5 rounded border ${view === "detail" ? "bg-primary text-primary-foreground" : "hover:bg-muted"} disabled:opacity-50`}
|
||||
>
|
||||
Detail
|
||||
</button>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
CO₂ Galvo Settings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{view === "list" ? (
|
||||
<div className="w-full">
|
||||
<CO2GalvoList linkFor={linkFor} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{id ? (
|
||||
<CO2GalvoDetail
|
||||
id={id}
|
||||
onBack={() => setView("list")}
|
||||
showOwnerEdit={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No record selected.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue