- Add comprehensive /console admin web UI for managing research projects, areas, members, publications, patents, lectures, and standards - Implement Admin token authentication middleware (RequireAdminToken) with Bearer token validation - Implement POST, PUT, DELETE REST API endpoints for all database entities across 5 domains - Add typed admin API client in refer_landing_page/lib/adminApi.ts with localStorage session persistence - Enhance E2E test runner to manage backend server lifecycle during automated tests - Pass all 12 quality gates cleanly with unanimous reviewer PASS verdicts
577 lines
22 KiB
TypeScript
577 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
|
|
import {
|
|
adminGetStandardsBodies,
|
|
adminCreateStandardsBody,
|
|
adminUpdateStandardsBody,
|
|
adminDeleteStandardsBody,
|
|
adminCreateStandardDocument,
|
|
adminUpdateStandardDocument,
|
|
adminDeleteStandardDocument,
|
|
AdminStandardsBody,
|
|
AdminStandardDocument,
|
|
} from "../../../lib/adminApi";
|
|
|
|
const DOC_STATUSES = ["IS", "TS", "TR", "CDV", "WDTR", "InProgress"];
|
|
|
|
export default function AdminStandardizationPage() {
|
|
const [bodies, setBodies] = useState<AdminStandardsBody[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
|
|
|
// Body Modal State
|
|
const [isBodyModalOpen, setIsBodyModalOpen] = useState(false);
|
|
const [editingBody, setEditingBody] = useState<AdminStandardsBody | null>(null);
|
|
const [bodyForm, setBodyForm] = useState({
|
|
org: "",
|
|
full_name: "",
|
|
scope: "international" as "international" | "domestic",
|
|
period: "",
|
|
role: "",
|
|
display_order: 0,
|
|
});
|
|
|
|
// Document Modal State
|
|
const [isDocModalOpen, setIsDocModalOpen] = useState(false);
|
|
const [targetBodyId, setTargetBodyId] = useState<number | null>(null);
|
|
const [editingDoc, setEditingDoc] = useState<AdminStandardDocument | null>(null);
|
|
const [docForm, setDocForm] = useState({
|
|
wg: "",
|
|
project_name: "",
|
|
title: "",
|
|
doc_ref: "",
|
|
status: "IS" as "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress",
|
|
published_at: "",
|
|
});
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [deletingBodyId, setDeletingBodyId] = useState<number | null>(null);
|
|
const [deletingDocId, setDeletingDocId] = useState<number | null>(null);
|
|
|
|
const loadBodies = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const data = await adminGetStandardsBodies();
|
|
setBodies(data);
|
|
} catch (err: any) {
|
|
setError(err?.message || "Failed to load standards bodies.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadBodies();
|
|
}, []);
|
|
|
|
// Body Actions
|
|
const handleOpenBodyCreate = () => {
|
|
setEditingBody(null);
|
|
setBodyForm({
|
|
org: "",
|
|
full_name: "",
|
|
scope: "international",
|
|
period: "",
|
|
role: "",
|
|
display_order: bodies.length + 1,
|
|
});
|
|
setIsBodyModalOpen(true);
|
|
};
|
|
|
|
const handleOpenBodyEdit = (b: AdminStandardsBody) => {
|
|
setEditingBody(b);
|
|
setBodyForm({
|
|
org: b.org,
|
|
full_name: b.full_name,
|
|
scope: b.scope,
|
|
period: b.period,
|
|
role: b.role || "",
|
|
display_order: b.display_order,
|
|
});
|
|
setIsBodyModalOpen(true);
|
|
};
|
|
|
|
const handleBodySubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const token = getClientToken();
|
|
if (!token) return;
|
|
|
|
const payload = {
|
|
org: bodyForm.org.trim(),
|
|
full_name: bodyForm.full_name.trim(),
|
|
scope: bodyForm.scope,
|
|
period: bodyForm.period.trim(),
|
|
role: bodyForm.role.trim() || undefined,
|
|
display_order: Number(bodyForm.display_order) || 0,
|
|
};
|
|
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
if (editingBody) {
|
|
await adminUpdateStandardsBody(token, editingBody.id, payload);
|
|
setSuccessMsg(`Standards Body "${payload.org}" updated.`);
|
|
} else {
|
|
await adminCreateStandardsBody(token, payload);
|
|
setSuccessMsg(`Standards Body "${payload.org}" created.`);
|
|
}
|
|
setIsBodyModalOpen(false);
|
|
await loadBodies();
|
|
} catch (err: any) {
|
|
setError(err?.message || "Failed to save standards body.");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmDeleteBody = async () => {
|
|
if (!deletingBodyId) return;
|
|
const token = getClientToken();
|
|
if (!token) return;
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
await adminDeleteStandardsBody(token, deletingBodyId);
|
|
setSuccessMsg("Standards body and associated documents deleted successfully (Cascade).");
|
|
setDeletingBodyId(null);
|
|
await loadBodies();
|
|
} catch (err: any) {
|
|
setError(err?.message || "Failed to delete standards body.");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Doc Actions
|
|
const handleOpenDocCreate = (bodyId: number) => {
|
|
setTargetBodyId(bodyId);
|
|
setEditingDoc(null);
|
|
setDocForm({
|
|
wg: "",
|
|
project_name: "",
|
|
title: "",
|
|
doc_ref: "",
|
|
status: "IS",
|
|
published_at: "",
|
|
});
|
|
setIsDocModalOpen(true);
|
|
};
|
|
|
|
const handleOpenDocEdit = (d: AdminStandardDocument) => {
|
|
setTargetBodyId(d.body_id);
|
|
setEditingDoc(d);
|
|
setDocForm({
|
|
wg: d.wg,
|
|
project_name: d.project_name,
|
|
title: d.title,
|
|
doc_ref: d.doc_ref,
|
|
status: d.status,
|
|
published_at: d.published_at || "",
|
|
});
|
|
setIsDocModalOpen(true);
|
|
};
|
|
|
|
const handleDocSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const token = getClientToken();
|
|
if (!token || !targetBodyId) return;
|
|
|
|
const payload = {
|
|
wg: docForm.wg.trim(),
|
|
project_name: docForm.project_name.trim(),
|
|
title: docForm.title.trim(),
|
|
doc_ref: docForm.doc_ref.trim(),
|
|
status: docForm.status,
|
|
published_at: docForm.published_at.trim() || undefined,
|
|
};
|
|
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
if (editingDoc) {
|
|
await adminUpdateStandardDocument(token, editingDoc.id, payload);
|
|
setSuccessMsg(`Document "${payload.doc_ref}" updated.`);
|
|
} else {
|
|
await adminCreateStandardDocument(token, targetBodyId, payload);
|
|
setSuccessMsg(`Document "${payload.doc_ref}" added.`);
|
|
}
|
|
setIsDocModalOpen(false);
|
|
await loadBodies();
|
|
} catch (err: any) {
|
|
setError(err?.message || "Failed to save standard document.");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmDeleteDoc = async () => {
|
|
if (!deletingDocId) return;
|
|
const token = getClientToken();
|
|
if (!token) return;
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
await adminDeleteStandardDocument(token, deletingDocId);
|
|
setSuccessMsg("Standard document deleted successfully.");
|
|
setDeletingDocId(null);
|
|
await loadBodies();
|
|
} catch (err: any) {
|
|
setError(err?.message || "Failed to delete document.");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<AdminHeader
|
|
title="Standardization Management"
|
|
description="Manage international and domestic standardization organizations, working groups, and published specifications."
|
|
action={
|
|
<button
|
|
onClick={handleOpenBodyCreate}
|
|
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
|
|
>
|
|
+ Add Standards Body
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
|
|
{successMsg && <AlertBanner type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
|
|
|
{isLoading ? (
|
|
<div className="py-12 text-center text-sm text-ink/70">Loading standardization records...</div>
|
|
) : bodies.length === 0 ? (
|
|
<div className="bg-paper border border-line rounded-lg p-12 text-center text-ink/60">
|
|
No standards bodies recorded. Click "+ Add Standards Body" to get started.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{bodies.map((body) => {
|
|
const allDocs = body.projects?.flatMap((p) => p.documents) || [];
|
|
|
|
return (
|
|
<div key={body.id} className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
|
{/* Body Header Bar */}
|
|
<div className="px-6 py-4 bg-ivory border-b border-line flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-bold text-base text-ink">{body.org}</span>
|
|
<span className="text-xs font-mono text-ink/60">({body.full_name})</span>
|
|
<span
|
|
className={`text-[10px] px-2 py-0.5 rounded font-bold uppercase ${
|
|
body.scope === "international"
|
|
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
|
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
|
}`}
|
|
>
|
|
{body.scope}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-ink/60 mt-1">
|
|
Period: {body.period} {body.role && `· Role: ${body.role}`} · {allDocs.length} Total Documents
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleOpenDocCreate(body.id)}
|
|
className="px-2.5 py-1 text-xs rounded bg-cobalt text-white font-medium hover:bg-cobalt/90"
|
|
>
|
|
+ Add Document
|
|
</button>
|
|
<button
|
|
onClick={() => handleOpenBodyEdit(body)}
|
|
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setDeletingBodyId(body.id)}
|
|
className="px-2.5 py-1 text-xs rounded bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 dark:bg-red-950/30 dark:hover:bg-red-950/60 dark:text-red-400 dark:border-red-900 font-medium"
|
|
>
|
|
Delete Body
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Documents Table */}
|
|
<table className="w-full text-left text-sm border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-line text-xs font-semibold text-ink/60 bg-paper">
|
|
<th className="p-3 w-28">Ref / Status</th>
|
|
<th className="p-3">Title & WG</th>
|
|
<th className="p-3">Project Group</th>
|
|
<th className="p-3 w-24">Date</th>
|
|
<th className="p-3 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-line">
|
|
{allDocs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="p-4 text-center text-xs text-ink/50">
|
|
No documents added for this standards body.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
allDocs.map((d) => (
|
|
<tr key={d.id} className="hover:bg-ivory/40 text-xs">
|
|
<td className="p-3">
|
|
<div className="font-mono font-semibold text-cobalt">{d.doc_ref}</div>
|
|
<span className="inline-block mt-0.5 px-1.5 py-0.2 text-[10px] rounded bg-ink/5 text-ink/70 font-bold">
|
|
{d.status}
|
|
</span>
|
|
</td>
|
|
<td className="p-3">
|
|
<div className="font-medium text-ink">{d.title}</div>
|
|
<div className="text-[11px] text-ink/50 mt-0.5">{d.wg}</div>
|
|
</td>
|
|
<td className="p-3 text-ink/70">{d.project_name}</td>
|
|
<td className="p-3 font-mono text-ink/60">{d.published_at || "—"}</td>
|
|
<td className="p-3 text-right space-x-2">
|
|
<button
|
|
onClick={() => handleOpenDocEdit(d)}
|
|
className="px-2 py-0.5 rounded border border-line hover:bg-ivory font-medium text-ink"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setDeletingDocId(d.id)}
|
|
className="px-2 py-0.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 font-medium"
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Body Modal */}
|
|
<Modal
|
|
isOpen={isBodyModalOpen}
|
|
title={editingBody ? "Edit Standards Body" : "Create Standards Body"}
|
|
onClose={() => setIsBodyModalOpen(false)}
|
|
>
|
|
<form onSubmit={handleBodySubmit} className="space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Organization Acronym *</label>
|
|
<input
|
|
type="text"
|
|
value={bodyForm.org}
|
|
onChange={(e) => setBodyForm({ ...bodyForm, org: e.target.value })}
|
|
placeholder="e.g. ISO/IEC"
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Scope *</label>
|
|
<select
|
|
value={bodyForm.scope}
|
|
onChange={(e) => setBodyForm({ ...bodyForm, scope: e.target.value as "international" | "domestic" })}
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
>
|
|
<option value="international">International</option>
|
|
<option value="domestic">Domestic</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Full Organization Name *</label>
|
|
<input
|
|
type="text"
|
|
value={bodyForm.full_name}
|
|
onChange={(e) => setBodyForm({ ...bodyForm, full_name: e.target.value })}
|
|
placeholder="e.g. International Organization for Standardization"
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Period *</label>
|
|
<input
|
|
type="text"
|
|
value={bodyForm.period}
|
|
onChange={(e) => setBodyForm({ ...bodyForm, period: e.target.value })}
|
|
placeholder="e.g. 2020 - Present"
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Role in Committee</label>
|
|
<input
|
|
type="text"
|
|
value={bodyForm.role}
|
|
onChange={(e) => setBodyForm({ ...bodyForm, role: e.target.value })}
|
|
placeholder="e.g. Project Leader"
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsBodyModalOpen(false)}
|
|
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
|
>
|
|
{isSubmitting ? "Saving..." : editingBody ? "Save Changes" : "Create Body"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Doc Modal */}
|
|
<Modal
|
|
isOpen={isDocModalOpen}
|
|
title={editingDoc ? "Edit Standard Document" : "Add Standard Document"}
|
|
onClose={() => setIsDocModalOpen(false)}
|
|
>
|
|
<form onSubmit={handleDocSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Document Reference *</label>
|
|
<input
|
|
type="text"
|
|
value={docForm.doc_ref}
|
|
onChange={(e) => setDocForm({ ...docForm, doc_ref: e.target.value })}
|
|
placeholder="e.g. ISO/IEC 63246-1"
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Status *</label>
|
|
<select
|
|
value={docForm.status}
|
|
onChange={(e) =>
|
|
setDocForm({
|
|
...docForm,
|
|
status: e.target.value as "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress",
|
|
})
|
|
}
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
>
|
|
{DOC_STATUSES.map((st) => (
|
|
<option key={st} value={st}>
|
|
{st}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Document Title *</label>
|
|
<input
|
|
type="text"
|
|
value={docForm.title}
|
|
onChange={(e) => setDocForm({ ...docForm, title: e.target.value })}
|
|
placeholder="e.g. Part 1: Architecture and functional model"
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Working Group (WG) *</label>
|
|
<input
|
|
type="text"
|
|
value={docForm.wg}
|
|
onChange={(e) => setDocForm({ ...docForm, wg: e.target.value })}
|
|
placeholder="e.g. ISO/IEC JTC 1/SC 29/WG 11"
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Project Name *</label>
|
|
<input
|
|
type="text"
|
|
value={docForm.project_name}
|
|
onChange={(e) => setDocForm({ ...docForm, project_name: e.target.value })}
|
|
placeholder="e.g. CCIS (Camera Communications)"
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-ink mb-1">Published Date (ISO 8601 optional)</label>
|
|
<input
|
|
type="text"
|
|
value={docForm.published_at}
|
|
onChange={(e) => setDocForm({ ...docForm, published_at: e.target.value })}
|
|
placeholder="e.g. 2023-08"
|
|
className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-line">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsDocModalOpen(false)}
|
|
className="px-4 py-2 text-sm rounded border border-line hover:bg-ivory font-medium text-ink"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium disabled:opacity-50"
|
|
>
|
|
{isSubmitting ? "Saving..." : editingDoc ? "Save Changes" : "Add Document"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<ConfirmDeleteModal
|
|
isOpen={deletingBodyId !== null}
|
|
onClose={() => setDeletingBodyId(null)}
|
|
onConfirm={handleConfirmDeleteBody}
|
|
isSubmitting={isSubmitting}
|
|
title="Delete Standards Body (Cascade Warning)"
|
|
description="Are you sure you want to delete this standards body? All standard documents under this body will also be permanently deleted (Database Cascade)."
|
|
/>
|
|
|
|
<ConfirmDeleteModal
|
|
isOpen={deletingDocId !== null}
|
|
onClose={() => setDeletingDocId(null)}
|
|
onConfirm={handleConfirmDeleteDoc}
|
|
isSubmitting={isSubmitting}
|
|
title="Delete Standard Document"
|
|
description="Are you sure you want to delete this standard document?"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|