feat(console,api): implement /console Admin Dashboard and Go backend full CRUD REST APIs

- 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
This commit is contained in:
2026-08-24 22:21:12 +09:00
parent dadd14feb1
commit 3f452468d6
32 changed files with 5466 additions and 279 deletions
@@ -0,0 +1,116 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export function getClientToken(): string {
if (typeof window === "undefined") return "";
const match = document.cookie.match(new RegExp("(^| )admin_token=([^;]+)"));
if (match) return decodeURIComponent(match[2]);
return localStorage.getItem("admin_token") || "";
}
export function setClientToken(token: string) {
if (typeof window === "undefined") return;
document.cookie = `admin_token=${encodeURIComponent(token)}; path=/; max-age=604800; SameSite=Lax`;
localStorage.setItem("admin_token", token);
}
export function removeClientToken() {
if (typeof window === "undefined") return;
document.cookie = "admin_token=; path=/; max-age=0; SameSite=Lax";
localStorage.removeItem("admin_token");
}
export function AdminHeader({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-6 mb-6 border-b border-line">
<div>
<h1 className="text-2xl font-bold text-ink tracking-tight">{title}</h1>
{description && <p className="text-sm text-ink/70 mt-1">{description}</p>}
</div>
{action && <div className="mt-4 sm:mt-0 flex gap-2">{action}</div>}
</div>
);
}
export function AlertBanner({ type, message, onClose }: { type: "error" | "success" | "info"; message: string; onClose?: () => void }) {
const styles = {
error: "bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300",
success: "bg-emerald-50 dark:bg-emerald-950/40 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300",
info: "bg-blue-50 dark:bg-blue-950/40 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300",
};
return (
<div className={`p-4 rounded-md border mb-4 flex items-center justify-between text-sm ${styles[type]}`}>
<div>{message}</div>
{onClose && (
<button onClick={onClose} className="text-xs font-semibold underline ml-4 hover:opacity-75">
Dismiss
</button>
)}
</div>
);
}
export function Modal({ isOpen, title, onClose, children }: { isOpen: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-paper border border-line rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-line">
<h3 className="text-lg font-bold text-ink">{title}</h3>
<button onClick={onClose} className="text-ink/60 hover:text-ink text-xl font-bold">
&times;
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
export function ConfirmDeleteModal({
isOpen,
title = "Confirm Deletion",
description = "Are you sure you want to delete this item? This action cannot be undone.",
onClose,
onConfirm,
isSubmitting,
}: {
isOpen: boolean;
title?: string;
description?: string;
onClose: () => void;
onConfirm: () => void;
isSubmitting?: boolean;
}) {
if (!isOpen) return null;
return (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<div className="space-y-4">
<p className="text-sm text-ink/80">{description}</p>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-line bg-paper hover:bg-ivory text-ink font-medium"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isSubmitting}
className="px-4 py-2 text-sm rounded bg-red-600 hover:bg-red-700 text-white font-medium disabled:opacity-50"
>
{isSubmitting ? "Deleting..." : "Delete Permanently"}
</button>
</div>
</div>
</Modal>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getClientToken, removeClientToken } from "./_components/AdminComponents";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
const token = getClientToken();
if (!token && pathname !== "/console/login") {
router.push("/console/login");
} else {
setIsAuthenticated(true);
}
}, [pathname, router]);
if (pathname === "/console/login") {
return <div className="min-h-screen bg-ivory text-ink flex items-center justify-center p-4">{children}</div>;
}
if (isAuthenticated === null) {
return (
<div className="min-h-screen bg-ivory text-ink flex items-center justify-center">
<div className="text-sm text-ink/70">Checking authentication...</div>
</div>
);
}
const navItems = [
{ href: "/console", label: "Dashboard Overview" },
{ href: "/console/research-projects", label: "Research Projects" },
{ href: "/console/research-areas", label: "Research Areas" },
{ href: "/console/members", label: "Members & Alumni" },
{ href: "/console/publications", label: "Publications & Patents" },
{ href: "/console/lectures", label: "Lectures & Courses" },
{ href: "/console/standardization", label: "Standardization" },
];
const handleLogout = () => {
removeClientToken();
router.push("/console/login");
};
return (
<div className="min-h-screen bg-ivory text-ink flex flex-col md:flex-row">
{/* Sidebar */}
<aside className="w-full md:w-64 bg-paper border-r border-line flex flex-col shrink-0">
<div className="p-6 border-b border-line">
<div className="flex items-center justify-between">
<span className="font-bold text-lg text-cobalt tracking-tight">ANL Console</span>
<span className="text-xs bg-cobalt/10 text-cobalt font-medium px-2 py-0.5 rounded">Admin</span>
</div>
<p className="text-xs text-ink/60 mt-1">Lab Data Management</p>
</div>
<nav className="p-4 space-y-1 flex-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`block px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? "bg-cobalt text-white"
: "text-ink/80 hover:bg-ivory hover:text-ink"
}`}
>
{item.label}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-line space-y-2">
<Link
href="/"
className="block w-full text-center px-3 py-2 text-xs font-medium rounded border border-line hover:bg-ivory text-ink/80"
>
&larr; Return to Public Site
</Link>
<button
onClick={handleLogout}
className="block w-full text-center px-3 py-2 text-xs font-medium 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"
>
Logout
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 p-6 md:p-10 max-w-7xl overflow-x-auto">{children}</main>
</div>
);
}
@@ -0,0 +1,473 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetSemesters,
adminCreateSemester,
adminUpdateSemester,
adminDeleteSemester,
adminCreateCourse,
adminUpdateCourse,
adminDeleteCourse,
AdminSemester,
AdminCourse,
} from "../../../lib/adminApi";
export default function AdminLecturesPage() {
const [semesters, setSemesters] = useState<AdminSemester[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Semester Modal State
const [isSemModalOpen, setIsSemModalOpen] = useState(false);
const [editingSemester, setEditingSemester] = useState<AdminSemester | null>(null);
const [semForm, setSemForm] = useState({
year: new Date().getFullYear(),
term: "Spring" as "Spring" | "Fall",
});
// Course Modal State
const [isCourseModalOpen, setIsCourseModalOpen] = useState(false);
const [targetSemesterId, setTargetSemesterId] = useState<number | null>(null);
const [editingCourse, setEditingCourse] = useState<AdminCourse | null>(null);
const [courseForm, setCourseForm] = useState({
code: "",
name_en: "",
name_kr: "",
note: "",
display_order: 0,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingSemId, setDeletingSemId] = useState<number | null>(null);
const [deletingCourseId, setDeletingCourseId] = useState<number | null>(null);
const loadSemesters = async () => {
try {
setIsLoading(true);
setError(null);
const data = await adminGetSemesters();
setSemesters(data);
} catch (err: any) {
setError(err?.message || "Failed to load semesters.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadSemesters();
}, []);
// Semester Actions
const handleOpenSemCreate = () => {
setEditingSemester(null);
setSemForm({
year: new Date().getFullYear(),
term: "Spring",
});
setIsSemModalOpen(true);
};
const handleOpenSemEdit = (s: AdminSemester) => {
setEditingSemester(s);
setSemForm({
year: s.year,
term: s.term,
});
setIsSemModalOpen(true);
};
const handleSemSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
year: Number(semForm.year),
term: semForm.term,
};
setIsSubmitting(true);
setError(null);
try {
if (editingSemester) {
await adminUpdateSemester(token, editingSemester.id, payload);
setSuccessMsg(`Semester ${payload.year} ${payload.term} updated.`);
} else {
await adminCreateSemester(token, payload);
setSuccessMsg(`Semester ${payload.year} ${payload.term} created.`);
}
setIsSemModalOpen(false);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to save semester.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteSem = async () => {
if (!deletingSemId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteSemester(token, deletingSemId);
setSuccessMsg("Semester and all its courses deleted successfully (Cascade).");
setDeletingSemId(null);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to delete semester.");
} finally {
setIsSubmitting(false);
}
};
// Course Actions
const handleOpenCourseCreate = (semesterId: number) => {
setTargetSemesterId(semesterId);
setEditingCourse(null);
const sem = semesters.find((s) => s.id === semesterId);
setCourseForm({
code: "",
name_en: "",
name_kr: "",
note: "",
display_order: (sem?.courses?.length || 0) + 1,
});
setIsCourseModalOpen(true);
};
const handleOpenCourseEdit = (c: AdminCourse) => {
setTargetSemesterId(c.semester_id);
setEditingCourse(c);
setCourseForm({
code: c.code,
name_en: c.name_en,
name_kr: c.name_kr || "",
note: c.note || "",
display_order: c.display_order,
});
setIsCourseModalOpen(true);
};
const handleCourseSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token || !targetSemesterId) return;
const payload = {
code: courseForm.code.trim(),
name_en: courseForm.name_en.trim(),
name_kr: courseForm.name_kr.trim() || undefined,
note: courseForm.note.trim() || undefined,
display_order: Number(courseForm.display_order) || 0,
};
setIsSubmitting(true);
setError(null);
try {
if (editingCourse) {
await adminUpdateCourse(token, editingCourse.id, payload);
setSuccessMsg(`Course "${payload.name_en}" updated.`);
} else {
await adminCreateCourse(token, targetSemesterId, payload);
setSuccessMsg(`Course "${payload.name_en}" added.`);
}
setIsCourseModalOpen(false);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to save course.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteCourse = async () => {
if (!deletingCourseId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteCourse(token, deletingCourseId);
setSuccessMsg("Course deleted successfully.");
setDeletingCourseId(null);
await loadSemesters();
} catch (err: any) {
setError(err?.message || "Failed to delete course.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Lectures & Courses Management"
description="Manage lecture history by academic semesters and undergraduate/graduate courses."
action={
<button
onClick={handleOpenSemCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
+ Add New Semester
</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 semesters and courses...</div>
) : semesters.length === 0 ? (
<div className="bg-paper border border-line rounded-lg p-12 text-center text-ink/60">
No lecture semesters recorded. Click &quot;+ Add New Semester&quot; to get started.
</div>
) : (
<div className="space-y-6">
{semesters.map((sem) => (
<div key={sem.id} className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
{/* Semester Header Bar */}
<div className="px-6 py-4 bg-ivory border-b border-line flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-bold text-base text-ink">
{sem.year} {sem.term} Semester
</span>
<span className="text-xs bg-cobalt/10 text-cobalt px-2 py-0.5 rounded font-medium">
{sem.courses?.length || 0} Courses
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenCourseCreate(sem.id)}
className="px-2.5 py-1 text-xs rounded bg-cobalt text-white font-medium hover:bg-cobalt/90"
>
+ Add Course
</button>
<button
onClick={() => handleOpenSemEdit(sem)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingSemId(sem.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 Semester
</button>
</div>
</div>
{/* Courses 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-16">Order</th>
<th className="p-3 w-32">Course Code</th>
<th className="p-3">Course Name (En / Kr)</th>
<th className="p-3">Note</th>
<th className="p-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{!sem.courses || sem.courses.length === 0 ? (
<tr>
<td colSpan={5} className="p-4 text-center text-xs text-ink/50">
No courses added for this semester.
</td>
</tr>
) : (
sem.courses.map((c) => (
<tr key={c.id} className="hover:bg-ivory/40 text-xs">
<td className="p-3 font-mono text-ink/60">{c.display_order}</td>
<td className="p-3 font-mono font-medium text-cobalt">{c.code}</td>
<td className="p-3 font-medium text-ink">
{c.name_en} {c.name_kr && <span className="text-ink/60 font-normal">({c.name_kr})</span>}
</td>
<td className="p-3 text-ink/60">{c.note || "—"}</td>
<td className="p-3 text-right space-x-2">
<button
onClick={() => handleOpenCourseEdit(c)}
className="px-2 py-0.5 rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingCourseId(c.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>
)}
{/* Semester Modal */}
<Modal
isOpen={isSemModalOpen}
title={editingSemester ? "Edit Semester" : "Create Semester"}
onClose={() => setIsSemModalOpen(false)}
>
<form onSubmit={handleSemSubmit} 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">Academic Year *</label>
<input
type="number"
value={semForm.year}
onChange={(e) => setSemForm({ ...semForm, year: Number(e.target.value) })}
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">Term *</label>
<select
value={semForm.term}
onChange={(e) => setSemForm({ ...semForm, term: e.target.value as "Spring" | "Fall" })}
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="Spring">Spring</option>
<option value="Fall">Fall</option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsSemModalOpen(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..." : editingSemester ? "Save Changes" : "Create Semester"}
</button>
</div>
</form>
</Modal>
{/* Course Modal */}
<Modal
isOpen={isCourseModalOpen}
title={editingCourse ? "Edit Course" : "Add New Course"}
onClose={() => setIsCourseModalOpen(false)}
>
<form onSubmit={handleCourseSubmit} 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">Course Code *</label>
<input
type="text"
value={courseForm.code}
onChange={(e) => setCourseForm({ ...courseForm, code: e.target.value })}
placeholder="e.g. ITNT683"
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">Display Order</label>
<input
type="number"
value={courseForm.display_order}
onChange={(e) => setCourseForm({ ...courseForm, display_order: Number(e.target.value) })}
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">Course Name (English) *</label>
<input
type="text"
value={courseForm.name_en}
onChange={(e) => setCourseForm({ ...courseForm, name_en: e.target.value })}
placeholder="e.g. Digital Signal Processing"
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">Course Name (Korean optional)</label>
<input
type="text"
value={courseForm.name_kr}
onChange={(e) => setCourseForm({ ...courseForm, name_kr: e.target.value })}
placeholder="e.g. 디지털신호처리"
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">Note (e.g. Graduate/Undergraduate)</label>
<input
type="text"
value={courseForm.note}
onChange={(e) => setCourseForm({ ...courseForm, note: e.target.value })}
placeholder="e.g. Graduate"
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={() => setIsCourseModalOpen(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..." : editingCourse ? "Save Changes" : "Add Course"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingSemId !== null}
onClose={() => setDeletingSemId(null)}
onConfirm={handleConfirmDeleteSem}
isSubmitting={isSubmitting}
title="Delete Semester (Cascade Warning)"
description="Are you sure you want to delete this semester? All courses under this semester will also be permanently deleted (Database Cascade)."
/>
<ConfirmDeleteModal
isOpen={deletingCourseId !== null}
onClose={() => setDeletingCourseId(null)}
onConfirm={handleConfirmDeleteCourse}
isSubmitting={isSubmitting}
title="Delete Course"
description="Are you sure you want to delete this course?"
/>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { setClientToken } from "../_components/AdminComponents";
import { adminVerifyToken } from "../../../lib/adminApi";
export default function AdminLoginPage() {
const router = useRouter();
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
setError("Please enter the admin token.");
return;
}
setIsSubmitting(true);
setError(null);
try {
const isValid = await adminVerifyToken(token.trim());
if (isValid) {
setClientToken(token.trim());
router.push("/console");
} else {
setError("Invalid admin bearer token. Please check your credentials.");
}
} catch (err: any) {
setError(err?.message || "Failed to verify token with backend API.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="w-full max-w-md bg-paper border border-line rounded-lg shadow-lg p-8">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-ink">ANL Admin Console</h1>
<p className="text-sm text-ink/70 mt-1">Enter your admin bearer token to manage laboratory datasets.</p>
</div>
{error && (
<div className="p-3 mb-4 text-sm rounded bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="token" className="block text-sm font-medium text-ink mb-1">
Admin Bearer Token
</label>
<input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="e.g. admin123"
className="w-full px-3 py-2 border border-line rounded-md bg-ivory text-ink focus:outline-none focus:ring-2 focus:ring-cobalt/50"
required
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
{isSubmitting ? "Verifying..." : "Sign In to Console"}
</button>
</form>
</div>
);
}
@@ -0,0 +1,623 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetMembers,
adminCreateMember,
adminUpdateMember,
adminDeleteMember,
adminGetAlumni,
adminCreateAlumnus,
adminUpdateAlumnus,
adminDeleteAlumnus,
AdminMember,
AdminAlumnus,
} from "../../../lib/adminApi";
const MEMBER_DEGREES = [
"Professor",
"PhD",
"PhDCandidate",
"PhDStudent",
"MSCandidate",
"MSStudent",
];
const ALUMNI_DEGREES = ["PhD", "MS"];
export default function AdminMembersPage() {
const [tab, setTab] = useState<"active" | "alumni">("active");
const [members, setMembers] = useState<AdminMember[]>([]);
const [alumni, setAlumni] = useState<AdminAlumnus[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Active Member Modal State
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false);
const [editingMember, setEditingMember] = useState<AdminMember | null>(null);
const [memberForm, setMemberForm] = useState({
name_ko: "",
name_en: "",
degree: "PhDStudent",
affiliation: "",
email: "",
is_advisor: false,
display_order: 0,
});
// Alumni Modal State
const [isAlumniModalOpen, setIsAlumniModalOpen] = useState(false);
const [editingAlumnus, setEditingAlumnus] = useState<AdminAlumnus | null>(null);
const [alumniForm, setAlumniForm] = useState({
name_ko: "",
name_en: "",
degree: "MS",
graduated_at: "",
major: "",
current_position: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingMemberId, setDeletingMemberId] = useState<number | null>(null);
const [deletingAlumniId, setDeletingAlumniId] = useState<number | null>(null);
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
const [mList, aList] = await Promise.all([adminGetMembers(), adminGetAlumni()]);
setMembers(mList);
setAlumni(aList);
} catch (err: any) {
setError(err?.message || "Failed to load members data.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// Member Handlers
const handleOpenMemberCreate = () => {
setEditingMember(null);
setMemberForm({
name_ko: "",
name_en: "",
degree: "PhDStudent",
affiliation: "",
email: "",
is_advisor: false,
display_order: members.length + 1,
});
setIsMemberModalOpen(true);
};
const handleOpenMemberEdit = (m: AdminMember) => {
setEditingMember(m);
setMemberForm({
name_ko: m.name_ko,
name_en: m.name_en,
degree: m.degree,
affiliation: m.affiliation || "",
email: m.email || "",
is_advisor: m.is_advisor,
display_order: m.display_order,
});
setIsMemberModalOpen(true);
};
const handleMemberSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
const payload = {
name_ko: memberForm.name_ko.trim(),
name_en: memberForm.name_en.trim(),
degree: memberForm.degree,
affiliation: memberForm.affiliation.trim() || undefined,
email: memberForm.email.trim() || undefined,
is_advisor: memberForm.is_advisor,
display_order: Number(memberForm.display_order) || 0,
};
setIsSubmitting(true);
setError(null);
try {
if (editingMember) {
await adminUpdateMember(token, editingMember.id, payload);
setSuccessMsg(`Member "${payload.name_ko}" updated successfully.`);
} else {
await adminCreateMember(token, payload);
setSuccessMsg(`Member "${payload.name_ko}" created successfully.`);
}
setIsMemberModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save member.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteMember = async () => {
if (!deletingMemberId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteMember(token, deletingMemberId);
setSuccessMsg("Member deleted successfully.");
setDeletingMemberId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete member.");
} finally {
setIsSubmitting(false);
}
};
// Alumni Handlers
const handleOpenAlumniCreate = () => {
setEditingAlumnus(null);
setAlumniForm({
name_ko: "",
name_en: "",
degree: "MS",
graduated_at: "",
major: "",
current_position: "",
});
setIsAlumniModalOpen(true);
};
const handleOpenAlumniEdit = (a: AdminAlumnus) => {
setEditingAlumnus(a);
setAlumniForm({
name_ko: a.name_ko,
name_en: a.name_en,
degree: a.degree,
graduated_at: a.graduated_at,
major: a.major || "",
current_position: a.current_position || "",
});
setIsAlumniModalOpen(true);
};
const handleAlumniSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
name_ko: alumniForm.name_ko.trim(),
name_en: alumniForm.name_en.trim(),
degree: alumniForm.degree,
graduated_at: alumniForm.graduated_at.trim(),
major: alumniForm.major.trim() || undefined,
current_position: alumniForm.current_position.trim() || undefined,
};
setIsSubmitting(true);
setError(null);
try {
if (editingAlumnus) {
await adminUpdateAlumnus(token, editingAlumnus.id, payload);
setSuccessMsg(`Alumnus "${payload.name_ko}" updated successfully.`);
} else {
await adminCreateAlumnus(token, payload);
setSuccessMsg(`Alumnus "${payload.name_ko}" created successfully.`);
}
setIsAlumniModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save alumnus.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeleteAlumnus = async () => {
if (!deletingAlumniId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeleteAlumnus(token, deletingAlumniId);
setSuccessMsg("Alumnus deleted successfully.");
setDeletingAlumniId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete alumnus.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Members & Alumni Management"
description="Manage active laboratory researchers, advisors, and graduated alumni."
action={
<button
onClick={tab === "active" ? handleOpenMemberCreate : handleOpenAlumniCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
{tab === "active" ? "+ Add Active Member" : "+ Add Alumnus"}
</button>
}
/>
{/* Tabs */}
<div className="flex border-b border-line mb-6 gap-6">
<button
onClick={() => setTab("active")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "active" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Active Members ({members.length})
</button>
<button
onClick={() => setTab("alumni")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "alumni" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Alumni ({alumni.length})
</button>
</div>
{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 records...</div>
) : tab === "active" ? (
/* Active Members Table */
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
<th className="p-4 w-16">Order</th>
<th className="p-4">Name (Ko / En)</th>
<th className="p-4">Degree</th>
<th className="p-4">Affiliation / Email</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{members.length === 0 ? (
<tr>
<td colSpan={5} className="p-6 text-center text-ink/60">
No active members found.
</td>
</tr>
) : (
members.map((m) => (
<tr key={m.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/60">{m.display_order}</td>
<td className="p-4">
<div className="font-semibold text-ink flex items-center gap-2">
{m.name_ko} ({m.name_en})
{m.is_advisor && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-cobalt/10 text-cobalt font-bold">
Advisor
</span>
)}
</div>
</td>
<td className="p-4 text-xs font-medium text-ink/80">{m.degree}</td>
<td className="p-4 text-xs text-ink/70">
<div>{m.affiliation || "—"}</div>
<div className="text-ink/50">{m.email || "—"}</div>
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenMemberEdit(m)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingMemberId(m.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
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : (
/* Alumni Table */
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
<th className="p-4">Graduation Date</th>
<th className="p-4">Name (Ko / En)</th>
<th className="p-4">Degree</th>
<th className="p-4">Major / Current Position</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{alumni.length === 0 ? (
<tr>
<td colSpan={5} className="p-6 text-center text-ink/60">
No alumni found.
</td>
</tr>
) : (
alumni.map((a) => (
<tr key={a.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/80">{a.graduated_at}</td>
<td className="p-4 font-semibold text-ink">
{a.name_ko} ({a.name_en})
</td>
<td className="p-4 text-xs font-medium text-ink/80">{a.degree}</td>
<td className="p-4 text-xs text-ink/70">
<div>{a.current_position || "—"}</div>
<div className="text-ink/50">{a.major || "—"}</div>
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenAlumniEdit(a)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingAlumniId(a.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
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Member Modal */}
<Modal
isOpen={isMemberModalOpen}
title={editingMember ? "Edit Active Member" : "Create Active Member"}
onClose={() => setIsMemberModalOpen(false)}
>
<form onSubmit={handleMemberSubmit} 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">Korean Name *</label>
<input
type="text"
value={memberForm.name_ko}
onChange={(e) => setMemberForm({ ...memberForm, name_ko: e.target.value })}
placeholder="e.g. 홍길동"
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">English Name *</label>
<input
type="text"
value={memberForm.name_en}
onChange={(e) => setMemberForm({ ...memberForm, name_en: e.target.value })}
placeholder="e.g. Gildong Hong"
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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Degree Level *</label>
<select
value={memberForm.degree}
onChange={(e) => setMemberForm({ ...memberForm, degree: e.target.value })}
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"
>
{MEMBER_DEGREES.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Display Order</label>
<input
type="number"
value={memberForm.display_order}
onChange={(e) => setMemberForm({ ...memberForm, display_order: Number(e.target.value) })}
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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Affiliation</label>
<input
type="text"
value={memberForm.affiliation}
onChange={(e) => setMemberForm({ ...memberForm, affiliation: e.target.value })}
placeholder="e.g. School of Electronic Engineering"
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">Email</label>
<input
type="email"
value={memberForm.email}
onChange={(e) => setMemberForm({ ...memberForm, email: e.target.value })}
placeholder="e.g. user@knu.ac.kr"
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 items-center gap-2 pt-2">
<input
type="checkbox"
id="is_advisor"
checked={memberForm.is_advisor}
onChange={(e) => setMemberForm({ ...memberForm, is_advisor: e.target.checked })}
className="rounded text-cobalt focus:ring-cobalt"
/>
<label htmlFor="is_advisor" className="text-xs font-semibold text-ink">
Principal Investigator / Lab Advisor
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsMemberModalOpen(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..." : editingMember ? "Save Changes" : "Create Member"}
</button>
</div>
</form>
</Modal>
{/* Alumni Modal */}
<Modal
isOpen={isAlumniModalOpen}
title={editingAlumnus ? "Edit Alumnus" : "Create Alumnus"}
onClose={() => setIsAlumniModalOpen(false)}
>
<form onSubmit={handleAlumniSubmit} 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">Korean Name *</label>
<input
type="text"
value={alumniForm.name_ko}
onChange={(e) => setAlumniForm({ ...alumniForm, name_ko: e.target.value })}
placeholder="e.g. 홍길동"
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">English Name *</label>
<input
type="text"
value={alumniForm.name_en}
onChange={(e) => setAlumniForm({ ...alumniForm, name_en: e.target.value })}
placeholder="e.g. Gildong Hong"
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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Graduation Date (ISO 8601) *</label>
<input
type="text"
value={alumniForm.graduated_at}
onChange={(e) => setAlumniForm({ ...alumniForm, graduated_at: e.target.value })}
placeholder="e.g. 2024-02"
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">Degree *</label>
<select
value={alumniForm.degree}
onChange={(e) => setAlumniForm({ ...alumniForm, degree: e.target.value })}
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"
>
{ALUMNI_DEGREES.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Current Position</label>
<input
type="text"
value={alumniForm.current_position}
onChange={(e) => setAlumniForm({ ...alumniForm, current_position: e.target.value })}
placeholder="e.g. Samsung Electronics, Principal Engineer"
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={() => setIsAlumniModalOpen(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..." : editingAlumnus ? "Save Changes" : "Create Alumnus"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingMemberId !== null}
onClose={() => setDeletingMemberId(null)}
onConfirm={handleConfirmDeleteMember}
isSubmitting={isSubmitting}
title="Delete Member"
description="Are you sure you want to delete this member?"
/>
<ConfirmDeleteModal
isOpen={deletingAlumniId !== null}
onClose={() => setDeletingAlumniId(null)}
onConfirm={handleConfirmDeleteAlumnus}
isSubmitting={isSubmitting}
title="Delete Alumnus"
description="Are you sure you want to delete this alumnus?"
/>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { AdminHeader, AlertBanner } from "./_components/AdminComponents";
import {
adminGetStatsSummary,
adminGetResearchProjects,
adminGetResearchAreas,
adminGetMembers,
adminGetAlumni,
adminGetPublications,
adminGetPatents,
adminGetSemesters,
adminGetStandardsBodies,
AdminStatsSummary,
} from "../../lib/adminApi";
export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStatsSummary | null>(null);
const [counts, setCounts] = useState<{
projects: number;
areas: number;
members: number;
alumni: number;
intlPubs: number;
domPubs: number;
patents: number;
semesters: number;
courses: number;
bodies: number;
docs: number;
}>({
projects: 0,
areas: 0,
members: 0,
alumni: 0,
intlPubs: 0,
domPubs: 0,
patents: 0,
semesters: 0,
courses: 0,
bodies: 0,
docs: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadData() {
try {
setIsLoading(true);
const [
statsData,
projects,
areas,
members,
alumni,
intlPubs,
domPubs,
patents,
semesters,
bodies,
] = await Promise.all([
adminGetStatsSummary().catch(() => null),
adminGetResearchProjects().catch(() => []),
adminGetResearchAreas().catch(() => []),
adminGetMembers().catch(() => []),
adminGetAlumni().catch(() => []),
adminGetPublications("intl-journal-conf").catch(() => []),
adminGetPublications("domestic-journal-conf").catch(() => []),
adminGetPatents().catch(() => []),
adminGetSemesters().catch(() => []),
adminGetStandardsBodies().catch(() => []),
]);
if (statsData) setStats(statsData);
const totalCourses = semesters.reduce((acc, s) => acc + (s.courses?.length || 0), 0);
const totalDocs = bodies.reduce(
(acc, b) => acc + (b.projects?.reduce((pAcc, p) => pAcc + (p.documents?.length || 0), 0) || 0),
0
);
setCounts({
projects: projects.length,
areas: areas.length,
members: members.length,
alumni: alumni.length,
intlPubs: intlPubs.length,
domPubs: domPubs.length,
patents: patents.length,
semesters: semesters.length,
courses: totalCourses,
bodies: bodies.length,
docs: totalDocs,
});
} catch (err: any) {
setError(err?.message || "Failed to load dashboard data from backend API.");
} finally {
setIsLoading(false);
}
}
loadData();
}, []);
const cards = [
{
title: "Research Projects",
count: counts.projects,
sub: `${counts.areas} Research Areas`,
href: "/console/research-projects",
color: "border-cobalt/30 hover:border-cobalt",
},
{
title: "Members & Alumni",
count: counts.members,
sub: `${counts.alumni} Alumni Records`,
href: "/console/members",
color: "border-emerald-500/30 hover:border-emerald-500",
},
{
title: "Publications & Patents",
count: counts.intlPubs + counts.domPubs,
sub: `${counts.intlPubs} Intl / ${counts.domPubs} Dom / ${counts.patents} Patents`,
href: "/console/publications",
color: "border-blue-500/30 hover:border-blue-500",
},
{
title: "Lectures & Courses",
count: counts.semesters,
sub: `${counts.courses} Total Courses across Semesters`,
href: "/console/lectures",
color: "border-amber-500/30 hover:border-amber-500",
},
{
title: "Standardization",
count: counts.bodies,
sub: `${counts.docs} Standard Documents`,
href: "/console/standardization",
color: "border-purple-500/30 hover:border-purple-500",
},
{
title: "Research Areas",
count: counts.areas,
sub: "Active Laboratory Focus Areas",
href: "/console/research-areas",
color: "border-indigo-500/30 hover:border-indigo-500",
},
];
return (
<div>
<AdminHeader
title="Administrative Overview"
description="Live status of all ANL laboratory records and datasets stored in the backend."
/>
{error && <AlertBanner type="error" message={error} onClose={() => setError(null)} />}
{isLoading ? (
<div className="py-12 text-center text-sm text-ink/70">Loading dashboard statistics...</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{cards.map((card) => (
<Link
key={card.title}
href={card.href}
className={`p-6 bg-paper rounded-lg border ${card.color} transition-all shadow-sm flex flex-col justify-between`}
>
<div>
<h2 className="text-base font-semibold text-ink">{card.title}</h2>
<div className="text-3xl font-extrabold text-cobalt mt-3">{card.count}</div>
<p className="text-xs text-ink/70 mt-1">{card.sub}</p>
</div>
<div className="mt-4 pt-4 border-t border-line text-xs font-medium text-cobalt flex items-center justify-between">
<span>Manage Records</span>
<span>&rarr;</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,715 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetPublications,
adminCreatePublication,
adminUpdatePublication,
adminDeletePublication,
adminGetPatents,
adminCreatePatent,
adminUpdatePatent,
adminDeletePatent,
AdminPublication,
AdminPatent,
} from "../../../lib/adminApi";
export default function AdminPublicationsPage() {
const [tab, setTab] = useState<"publications" | "patents">("publications");
const [publications, setPublications] = useState<AdminPublication[]>([]);
const [patents, setPatents] = useState<AdminPatent[]>([]);
const [filterCategory, setFilterCategory] = useState<string>("all");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Publication Modal State
const [isPubModalOpen, setIsPubModalOpen] = useState(false);
const [editingPub, setEditingPub] = useState<AdminPublication | null>(null);
const [pubForm, setPubForm] = useState({
category: "intl-journal-conf" as "intl-journal-conf" | "domestic-journal-conf",
title: "",
authors: "",
venue: "",
volume: "",
published_at: "",
kci: false,
doi: "",
is_highlight: false,
});
// Patent Modal State
const [isPatentModalOpen, setIsPatentModalOpen] = useState(false);
const [editingPatent, setEditingPatent] = useState<AdminPatent | null>(null);
const [patentForm, setPatentForm] = useState({
title: "",
inventors: "",
application_no: "",
application_at: "",
registration_no: "",
registration_at: "",
country: "Korea",
published_at: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingPubId, setDeletingPubId] = useState<number | null>(null);
const [deletingPatentId, setDeletingPatentId] = useState<number | null>(null);
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
const [pubList, patList] = await Promise.all([adminGetPublications(), adminGetPatents()]);
setPublications(pubList);
setPatents(patList);
} catch (err: any) {
setError(err?.message || "Failed to load publications.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// Pub Handlers
const handleOpenPubCreate = () => {
setEditingPub(null);
setPubForm({
category: "intl-journal-conf",
title: "",
authors: "",
venue: "",
volume: "",
published_at: new Date().getFullYear().toString(),
kci: false,
doi: "",
is_highlight: false,
});
setIsPubModalOpen(true);
};
const handleOpenPubEdit = (p: AdminPublication) => {
setEditingPub(p);
setPubForm({
category: p.category,
title: p.title,
authors: p.authors || "",
venue: p.venue,
volume: p.volume || "",
published_at: p.published_at,
kci: p.kci,
doi: p.doi || "",
is_highlight: p.is_highlight,
});
setIsPubModalOpen(true);
};
const handlePubSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
category: pubForm.category,
title: pubForm.title.trim(),
authors: pubForm.authors.trim() || undefined,
venue: pubForm.venue.trim(),
volume: pubForm.volume.trim() || undefined,
published_at: pubForm.published_at.trim(),
kci: pubForm.kci,
doi: pubForm.doi.trim() || undefined,
is_highlight: pubForm.is_highlight,
};
setIsSubmitting(true);
setError(null);
try {
if (editingPub) {
await adminUpdatePublication(token, editingPub.id, payload);
setSuccessMsg(`Publication "${payload.title}" updated.`);
} else {
await adminCreatePublication(token, payload);
setSuccessMsg(`Publication "${payload.title}" created.`);
}
setIsPubModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save publication.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeletePub = async () => {
if (!deletingPubId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeletePublication(token, deletingPubId);
setSuccessMsg("Publication deleted successfully.");
setDeletingPubId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete publication.");
} finally {
setIsSubmitting(false);
}
};
// Patent Handlers
const handleOpenPatentCreate = () => {
setEditingPatent(null);
setPatentForm({
title: "",
inventors: "",
application_no: "",
application_at: "",
registration_no: "",
registration_at: "",
country: "Korea",
published_at: new Date().getFullYear().toString(),
});
setIsPatentModalOpen(true);
};
const handleOpenPatentEdit = (p: AdminPatent) => {
setEditingPatent(p);
setPatentForm({
title: p.title,
inventors: p.inventors,
application_no: p.application_no,
application_at: p.application_at,
registration_no: p.registration_no || "",
registration_at: p.registration_at || "",
country: p.country,
published_at: p.published_at,
});
setIsPatentModalOpen(true);
};
const handlePatentSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) return;
const payload = {
title: patentForm.title.trim(),
inventors: patentForm.inventors.trim(),
application_no: patentForm.application_no.trim(),
application_at: patentForm.application_at.trim(),
registration_no: patentForm.registration_no.trim() || undefined,
registration_at: patentForm.registration_at.trim() || undefined,
country: patentForm.country.trim(),
published_at: patentForm.published_at.trim(),
};
setIsSubmitting(true);
setError(null);
try {
if (editingPatent) {
await adminUpdatePatent(token, editingPatent.id, payload);
setSuccessMsg(`Patent "${payload.title}" updated.`);
} else {
await adminCreatePatent(token, payload);
setSuccessMsg(`Patent "${payload.title}" created.`);
}
setIsPatentModalOpen(false);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to save patent.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDeletePatent = async () => {
if (!deletingPatentId) return;
const token = getClientToken();
if (!token) return;
setIsSubmitting(true);
try {
await adminDeletePatent(token, deletingPatentId);
setSuccessMsg("Patent deleted successfully.");
setDeletingPatentId(null);
await loadData();
} catch (err: any) {
setError(err?.message || "Failed to delete patent.");
} finally {
setIsSubmitting(false);
}
};
const filteredPubs =
filterCategory === "all"
? publications
: publications.filter((p) => p.category === filterCategory);
return (
<div>
<AdminHeader
title="Publications & Patents Management"
description="Manage journal articles, conference proceedings, and registered laboratory patents."
action={
<button
onClick={tab === "publications" ? handleOpenPubCreate : handleOpenPatentCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
{tab === "publications" ? "+ Add Publication" : "+ Add Patent"}
</button>
}
/>
{/* Tabs */}
<div className="flex border-b border-line mb-6 gap-6">
<button
onClick={() => setTab("publications")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "publications" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Publications ({publications.length})
</button>
<button
onClick={() => setTab("patents")}
className={`pb-3 text-sm font-semibold border-b-2 transition-colors ${
tab === "patents" ? "border-cobalt text-cobalt" : "border-transparent text-ink/60 hover:text-ink"
}`}
>
Patents ({patents.length})
</button>
</div>
{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 records...</div>
) : tab === "publications" ? (
<div>
{/* Filter sub-bar */}
<div className="flex items-center gap-3 mb-4">
<span className="text-xs font-semibold text-ink/70">Filter:</span>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
>
<option value="all">All Categories ({publications.length})</option>
<option value="intl-journal-conf">
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length})
</option>
<option value="domestic-journal-conf">
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length})
</option>
</select>
</div>
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
<th className="p-4 w-28">Date</th>
<th className="p-4">Title & Venue</th>
<th className="p-4 w-36">Category</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{filteredPubs.length === 0 ? (
<tr>
<td colSpan={4} className="p-6 text-center text-ink/60">
No publications found.
</td>
</tr>
) : (
filteredPubs.map((p) => (
<tr key={p.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td>
<td className="p-4">
<div className="font-semibold text-ink flex items-center gap-2">
{p.title}
{p.is_highlight && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold">
Highlight
</span>
)}
{p.kci && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold">
KCI
</span>
)}
</div>
<div className="text-xs text-ink/70 mt-0.5">
{p.venue} {p.volume && `· ${p.volume}`}
</div>
{p.authors && <div className="text-xs text-ink/50 mt-0.5">{p.authors}</div>}
</td>
<td className="p-4 text-xs text-ink/70">
{p.category === "intl-journal-conf" ? "International" : "Domestic"}
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenPubEdit(p)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingPubId(p.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
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
) : (
/* Patents Table */
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
<th className="p-4 w-28">Date</th>
<th className="p-4">Title & Inventors</th>
<th className="p-4">App / Reg Numbers</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{patents.length === 0 ? (
<tr>
<td colSpan={4} className="p-6 text-center text-ink/60">
No patents found.
</td>
</tr>
) : (
patents.map((p) => (
<tr key={p.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td>
<td className="p-4">
<div className="font-semibold text-ink">{p.title}</div>
<div className="text-xs text-ink/60 mt-0.5">{p.inventors}</div>
</td>
<td className="p-4 text-xs text-ink/80">
<div>App: {p.application_no} ({p.application_at})</div>
{p.registration_no && (
<div className="text-emerald-600 dark:text-emerald-400 font-medium">
Reg: {p.registration_no} ({p.registration_at || "—"})
</div>
)}
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenPatentEdit(p)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingPatentId(p.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
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Pub Modal */}
<Modal
isOpen={isPubModalOpen}
title={editingPub ? "Edit Publication" : "Create Publication"}
onClose={() => setIsPubModalOpen(false)}
>
<form onSubmit={handlePubSubmit} 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">Category *</label>
<select
value={pubForm.category}
onChange={(e) =>
setPubForm({
...pubForm,
category: e.target.value as "intl-journal-conf" | "domestic-journal-conf",
})
}
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="intl-journal-conf">International Journal / Conference</option>
<option value="domestic-journal-conf">Domestic Journal / Conference</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Published Date (ISO 8601) *</label>
<input
type="text"
value={pubForm.published_at}
onChange={(e) => setPubForm({ ...pubForm, published_at: e.target.value })}
placeholder="e.g. 2023-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>
<div>
<label className="block text-xs font-semibold text-ink mb-1">Title *</label>
<input
type="text"
value={pubForm.title}
onChange={(e) => setPubForm({ ...pubForm, title: e.target.value })}
placeholder="Full paper title"
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">Authors</label>
<input
type="text"
value={pubForm.authors}
onChange={(e) => setPubForm({ ...pubForm, authors: e.target.value })}
placeholder="e.g. Gildong Hong, Prof. Advisor"
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">Venue (Journal / Conference) *</label>
<input
type="text"
value={pubForm.venue}
onChange={(e) => setPubForm({ ...pubForm, venue: e.target.value })}
placeholder="e.g. IEEE Transactions on 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>
<label className="block text-xs font-semibold text-ink mb-1">Volume / Pages</label>
<input
type="text"
value={pubForm.volume}
onChange={(e) => setPubForm({ ...pubForm, volume: e.target.value })}
placeholder="e.g. Vol. 70, No. 3, pp. 120-135"
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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">DOI (optional)</label>
<input
type="text"
value={pubForm.doi}
onChange={(e) => setPubForm({ ...pubForm, doi: e.target.value })}
placeholder="e.g. 10.1109/TCOMM.2023.123456"
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 items-center gap-4 pt-5">
<label className="flex items-center gap-2 text-xs font-semibold text-ink cursor-pointer">
<input
type="checkbox"
checked={pubForm.is_highlight}
onChange={(e) => setPubForm({ ...pubForm, is_highlight: e.target.checked })}
className="rounded text-cobalt focus:ring-cobalt"
/>
Home Highlight
</label>
<label className="flex items-center gap-2 text-xs font-semibold text-ink cursor-pointer">
<input
type="checkbox"
checked={pubForm.kci}
onChange={(e) => setPubForm({ ...pubForm, kci: e.target.checked })}
className="rounded text-cobalt focus:ring-cobalt"
/>
KCI Indexed
</label>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsPubModalOpen(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..." : editingPub ? "Save Changes" : "Create Publication"}
</button>
</div>
</form>
</Modal>
{/* Patent Modal */}
<Modal
isOpen={isPatentModalOpen}
title={editingPatent ? "Edit Patent" : "Create Patent"}
onClose={() => setIsPatentModalOpen(false)}
>
<form onSubmit={handlePatentSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Patent Title *</label>
<input
type="text"
value={patentForm.title}
onChange={(e) => setPatentForm({ ...patentForm, title: e.target.value })}
placeholder="e.g. Method and system for optical 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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Inventors *</label>
<input
type="text"
value={patentForm.inventors}
onChange={(e) => setPatentForm({ ...patentForm, inventors: e.target.value })}
placeholder="e.g. Gildong Hong, Advisor"
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">Country *</label>
<input
type="text"
value={patentForm.country}
onChange={(e) => setPatentForm({ ...patentForm, country: e.target.value })}
placeholder="e.g. Korea or USA"
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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Application No. *</label>
<input
type="text"
value={patentForm.application_no}
onChange={(e) => setPatentForm({ ...patentForm, application_no: e.target.value })}
placeholder="e.g. 10-2023-0123456"
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">Application Date *</label>
<input
type="text"
value={patentForm.application_at}
onChange={(e) => setPatentForm({ ...patentForm, application_at: e.target.value })}
placeholder="e.g. 2023-05-12"
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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">Registration No. (optional)</label>
<input
type="text"
value={patentForm.registration_no}
onChange={(e) => setPatentForm({ ...patentForm, registration_no: e.target.value })}
placeholder="e.g. 10-2543210"
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">Registration Date</label>
<input
type="text"
value={patentForm.registration_at}
onChange={(e) => setPatentForm({ ...patentForm, registration_at: e.target.value })}
placeholder="e.g. 2024-01-10"
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">Primary Display Year/Date *</label>
<input
type="text"
value={patentForm.published_at}
onChange={(e) => setPatentForm({ ...patentForm, published_at: e.target.value })}
placeholder="e.g. 2023"
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="flex justify-end gap-3 pt-4 border-t border-line">
<button
type="button"
onClick={() => setIsPatentModalOpen(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..." : editingPatent ? "Save Changes" : "Create Patent"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingPubId !== null}
onClose={() => setDeletingPubId(null)}
onConfirm={handleConfirmDeletePub}
isSubmitting={isSubmitting}
title="Delete Publication"
description="Are you sure you want to delete this publication?"
/>
<ConfirmDeleteModal
isOpen={deletingPatentId !== null}
onClose={() => setDeletingPatentId(null)}
onConfirm={handleConfirmDeletePatent}
isSubmitting={isSubmitting}
title="Delete Patent"
description="Are you sure you want to delete this patent record?"
/>
</div>
);
}
@@ -0,0 +1,256 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetResearchAreas,
adminCreateResearchArea,
adminUpdateResearchArea,
adminDeleteResearchArea,
AdminResearchArea,
} from "../../../lib/adminApi";
export default function AdminResearchAreasPage() {
const [areas, setAreas] = useState<AdminResearchArea[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingArea, setEditingArea] = useState<AdminResearchArea | null>(null);
const [formData, setFormData] = useState({
name_en: "",
name_kr: "",
display_order: 0,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const loadAreas = async () => {
try {
setIsLoading(true);
setError(null);
const data = await adminGetResearchAreas();
setAreas(data);
} catch (err: any) {
setError(err?.message || "Failed to load research areas.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadAreas();
}, []);
const handleOpenCreate = () => {
setEditingArea(null);
setFormData({
name_en: "",
name_kr: "",
display_order: areas.length + 1,
});
setIsModalOpen(true);
};
const handleOpenEdit = (a: AdminResearchArea) => {
setEditingArea(a);
setFormData({
name_en: a.name_en,
name_kr: a.name_kr || "",
display_order: a.display_order,
});
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
const payload = {
name_en: formData.name_en.trim(),
name_kr: formData.name_kr.trim() || undefined,
display_order: Number(formData.display_order) || 0,
};
setIsSubmitting(true);
setError(null);
try {
if (editingArea) {
await adminUpdateResearchArea(token, editingArea.id, payload);
setSuccessMsg(`Research Area "${payload.name_en}" updated successfully.`);
} else {
await adminCreateResearchArea(token, payload);
setSuccessMsg(`Research Area "${payload.name_en}" created successfully.`);
}
setIsModalOpen(false);
await loadAreas();
} catch (err: any) {
setError(err?.message || "Failed to save research area.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDelete = async () => {
if (!deletingId) return;
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
setIsSubmitting(true);
try {
await adminDeleteResearchArea(token, deletingId);
setSuccessMsg("Research Area deleted successfully.");
setDeletingId(null);
await loadAreas();
} catch (err: any) {
setError(err?.message || "Failed to delete research area.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Research Areas"
description="Manage the key laboratory research area keywords listed in the overview section."
action={
<button
onClick={handleOpenCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
+ Add New Area
</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 research areas...</div>
) : (
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
<th className="p-4 w-16">Order</th>
<th className="p-4">English Name</th>
<th className="p-4">Korean Name</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{areas.length === 0 ? (
<tr>
<td colSpan={4} className="p-6 text-center text-ink/60">
No research areas found.
</td>
</tr>
) : (
areas.map((a) => (
<tr key={a.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/60">{a.display_order}</td>
<td className="p-4 font-semibold text-ink">{a.name_en}</td>
<td className="p-4 text-ink/80">{a.name_kr || "—"}</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenEdit(a)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingId(a.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
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Modal */}
<Modal
isOpen={isModalOpen}
title={editingArea ? "Edit Research Area" : "Create Research Area"}
onClose={() => setIsModalOpen(false)}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink mb-1">English Name *</label>
<input
type="text"
value={formData.name_en}
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
placeholder="e.g. Optical Wireless 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>
<label className="block text-xs font-semibold text-ink mb-1">Korean Name (optional)</label>
<input
type="text"
value={formData.name_kr}
onChange={(e) => setFormData({ ...formData, name_kr: e.target.value })}
placeholder="e.g. 광무선 통신"
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">Display Order</label>
<input
type="number"
value={formData.display_order}
onChange={(e) => setFormData({ ...formData, display_order: Number(e.target.value) })}
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={() => setIsModalOpen(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..." : editingArea ? "Save Changes" : "Create Area"}
</button>
</div>
</form>
</Modal>
<ConfirmDeleteModal
isOpen={deletingId !== null}
onClose={() => setDeletingId(null)}
onConfirm={handleConfirmDelete}
isSubmitting={isSubmitting}
title="Delete Research Area"
description="Are you sure you want to delete this research area keyword?"
/>
</div>
);
}
@@ -0,0 +1,342 @@
"use client";
import React, { useEffect, useState } from "react";
import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents";
import {
adminGetResearchProjects,
adminCreateResearchProject,
adminUpdateResearchProject,
adminDeleteResearchProject,
AdminResearchProject,
} from "../../../lib/adminApi";
export default function AdminResearchProjectsPage() {
const [projects, setProjects] = useState<AdminResearchProject[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Form State
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<AdminResearchProject | null>(null);
const [formData, setFormData] = useState({
slug: "",
title: "",
abstract: "",
keywords: "",
organization: "",
standards_org: "",
period: "",
funder: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Delete State
const [deletingId, setDeletingId] = useState<number | null>(null);
const loadProjects = async () => {
try {
setIsLoading(true);
setError(null);
const data = await adminGetResearchProjects();
setProjects(data);
} catch (err: any) {
setError(err?.message || "Failed to load research projects.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadProjects();
}, []);
const handleOpenCreate = () => {
setEditingProject(null);
setFormData({
slug: "",
title: "",
abstract: "",
keywords: "",
organization: "",
standards_org: "",
period: "",
funder: "",
});
setIsModalOpen(true);
};
const handleOpenEdit = (p: AdminResearchProject) => {
setEditingProject(p);
setFormData({
slug: p.slug,
title: p.title,
abstract: p.abstract,
keywords: (p.keywords || []).join(", "),
organization: p.organization || "",
standards_org: p.standards_org || "",
period: p.period || "",
funder: p.funder || "",
});
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
const kwArray = formData.keywords
.split(",")
.map((k) => k.trim())
.filter(Boolean);
const payload = {
slug: formData.slug.trim(),
title: formData.title.trim(),
abstract: formData.abstract.trim(),
keywords: kwArray,
organization: formData.organization.trim() || undefined,
standards_org: formData.standards_org.trim() || undefined,
period: formData.period.trim() || undefined,
funder: formData.funder.trim() || undefined,
};
setIsSubmitting(true);
setError(null);
try {
if (editingProject) {
await adminUpdateResearchProject(token, editingProject.id, payload);
setSuccessMsg(`Project "${payload.title}" updated successfully.`);
} else {
await adminCreateResearchProject(token, payload);
setSuccessMsg(`Project "${payload.title}" created successfully.`);
}
setIsModalOpen(false);
await loadProjects();
} catch (err: any) {
setError(err?.message || "Failed to save project.");
} finally {
setIsSubmitting(false);
}
};
const handleConfirmDelete = async () => {
if (!deletingId) return;
const token = getClientToken();
if (!token) {
setError("Unauthorized. Please log in.");
return;
}
setIsSubmitting(true);
try {
await adminDeleteResearchProject(token, deletingId);
setSuccessMsg("Project deleted successfully.");
setDeletingId(null);
await loadProjects();
} catch (err: any) {
setError(err?.message || "Failed to delete project.");
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<AdminHeader
title="Research Projects"
description="Manage key laboratory research projects displayed on the home page."
action={
<button
onClick={handleOpenCreate}
className="px-4 py-2 text-sm rounded bg-cobalt hover:bg-cobalt/90 text-white font-medium shadow-sm transition-colors"
>
+ Add New Project
</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 projects...</div>
) : (
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
<th className="p-4">ID</th>
<th className="p-4">Slug</th>
<th className="p-4">Title</th>
<th className="p-4">Organization / Period</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{projects.length === 0 ? (
<tr>
<td colSpan={5} className="p-6 text-center text-ink/60">
No research projects found.
</td>
</tr>
) : (
projects.map((p) => (
<tr key={p.id} className="hover:bg-ivory/50">
<td className="p-4 text-xs font-mono text-ink/60">{p.id}</td>
<td className="p-4 font-mono text-xs text-cobalt font-medium">{p.slug}</td>
<td className="p-4">
<div className="font-semibold text-ink">{p.title}</div>
<div className="text-xs text-ink/60 line-clamp-1 mt-0.5">{p.abstract}</div>
</td>
<td className="p-4 text-xs text-ink/80">
<div>{p.organization || p.standards_org || "—"}</div>
<div className="text-ink/60">{p.period || "—"}</div>
</td>
<td className="p-4 text-right space-x-2">
<button
onClick={() => handleOpenEdit(p)}
className="px-2.5 py-1 text-xs rounded border border-line hover:bg-ivory font-medium text-ink"
>
Edit
</button>
<button
onClick={() => setDeletingId(p.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
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Create / Edit Modal */}
<Modal
isOpen={isModalOpen}
title={editingProject ? "Edit Research Project" : "Create Research Project"}
onClose={() => setIsModalOpen(false)}
>
<form onSubmit={handleSubmit} 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">Slug *</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="e.g. ccis"
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">Title *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Project title"
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">Abstract *</label>
<textarea
value={formData.abstract}
onChange={(e) => setFormData({ ...formData, abstract: e.target.value })}
rows={4}
placeholder="Detailed description of the project"
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">Keywords (comma separated)</label>
<input
type="text"
value={formData.keywords}
onChange={(e) => setFormData({ ...formData, keywords: e.target.value })}
placeholder="e.g. ISO/IEC 63246, Camera Communications"
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">Organization / Standards Track</label>
<input
type="text"
value={formData.organization}
onChange={(e) => setFormData({ ...formData, organization: e.target.value })}
placeholder="e.g. ISO/IEC JTC 1/SC 29/WG 11"
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">Period</label>
<input
type="text"
value={formData.period}
onChange={(e) => setFormData({ ...formData, period: e.target.value })}
placeholder="e.g. 2021.06 - 2024.05"
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">Funder</label>
<input
type="text"
value={formData.funder}
onChange={(e) => setFormData({ ...formData, funder: e.target.value })}
placeholder="e.g. Ministry of Science and ICT (MSIT)"
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={() => setIsModalOpen(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..." : editingProject ? "Save Changes" : "Create Project"}
</button>
</div>
</form>
</Modal>
{/* Delete Confirmation */}
<ConfirmDeleteModal
isOpen={deletingId !== null}
onClose={() => setDeletingId(null)}
onConfirm={handleConfirmDelete}
isSubmitting={isSubmitting}
title="Delete Research Project"
description="Are you sure you want to delete this research project? It will be removed from the public website immediately."
/>
</div>
);
}
@@ -0,0 +1,576 @@
"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 &quot;+ Add Standards Body&quot; 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>
);
}
+470
View File
@@ -0,0 +1,470 @@
/**
* Admin API client for /console dashboard.
* Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE).
*/
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.API_BASE_URL ||
"http://localhost:8080/api/v1";
export interface AdminResearchProject {
id: number;
slug: string;
title: string;
abstract: string;
keywords: string[];
organization?: string;
standards_org?: string;
period?: string;
funder?: string;
created_at?: string;
updated_at?: string;
}
export interface AdminResearchArea {
id: number;
name_en: string;
name_kr?: string;
display_order: number;
}
export interface AdminMember {
id: number;
name_ko: string;
name_en: string;
degree: string;
affiliation?: string;
email?: string;
is_advisor: boolean;
display_order: number;
created_at?: string;
}
export interface AdminAlumnus {
id: number;
name_ko: string;
name_en: string;
degree: string;
graduated_at: string;
major?: string;
current_position?: string;
created_at?: string;
}
export interface AdminPublication {
id: number;
category: "intl-journal-conf" | "domestic-journal-conf";
title: string;
authors?: string;
venue: string;
volume?: string;
published_at: string;
kci: boolean;
doi?: string;
is_highlight: boolean;
created_at?: string;
}
export interface AdminPatent {
id: number;
title: string;
inventors: string;
application_no: string;
application_at: string;
registration_no?: string;
registration_at?: string;
country: string;
published_at: string;
created_at?: string;
}
export interface AdminCourse {
id: number;
semester_id: number;
code: string;
name_en: string;
name_kr?: string;
note?: string;
display_order: number;
}
export interface AdminSemester {
id: number;
year: number;
term: "Spring" | "Fall";
courses: AdminCourse[];
}
export interface AdminStandardDocument {
id: number;
body_id: number;
wg: string;
project_name: string;
title: string;
doc_ref: string;
status: "IS" | "TS" | "TR" | "CDV" | "WDTR" | "InProgress";
published_at?: string;
}
export interface AdminStandardProjectGroup {
wg: string;
name: string;
documents: AdminStandardDocument[];
}
export interface AdminStandardsBody {
id: number;
org: string;
full_name: string;
scope: "international" | "domestic";
period: string;
role?: string;
display_order: number;
projects: AdminStandardProjectGroup[];
}
export interface AdminStatsSummary {
intl_publications: number;
standardization_docs: number;
patents: number;
}
async function request<T>(
endpoint: string,
options: RequestInit = {},
token?: string
): Promise<T> {
const headers = new Headers(options.headers || {});
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (options.body && typeof options.body === "string") {
headers.set("Content-Type", "application/json");
}
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers,
cache: "no-store",
});
if (response.status === 204) {
return {} as T;
}
const json = await response.json().catch(() => ({}));
if (!response.ok) {
const errorMsg = json?.error?.message || `Request failed with status ${response.status}`;
throw new Error(errorMsg);
}
return json.data;
}
// 0. Auth Verification
export async function adminVerifyToken(token: string): Promise<boolean> {
try {
await request<{ status: string }>("/admin/verify", { method: "GET" }, token);
return true;
} catch {
return false;
}
}
// 1. Stats Summary
export async function adminGetStatsSummary(): Promise<AdminStatsSummary> {
return request<AdminStatsSummary>("/stats/summary");
}
// 2. Research Projects
export async function adminGetResearchProjects(): Promise<AdminResearchProject[]> {
return request<AdminResearchProject[]>("/research-projects");
}
export async function adminCreateResearchProject(
token: string,
data: Omit<AdminResearchProject, "id" | "created_at" | "updated_at">
): Promise<AdminResearchProject> {
return request<AdminResearchProject>("/research-projects", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateResearchProject(
token: string,
id: number,
data: Partial<AdminResearchProject>
): Promise<AdminResearchProject> {
return request<AdminResearchProject>(`/research-projects/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteResearchProject(token: string, id: number): Promise<void> {
return request<void>(`/research-projects/${id}`, { method: "DELETE" }, token);
}
// 3. Research Areas
export async function adminGetResearchAreas(): Promise<AdminResearchArea[]> {
return request<AdminResearchArea[]>("/research-areas");
}
export async function adminCreateResearchArea(
token: string,
data: Omit<AdminResearchArea, "id">
): Promise<AdminResearchArea> {
return request<AdminResearchArea>("/research-areas", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateResearchArea(
token: string,
id: number,
data: Partial<AdminResearchArea>
): Promise<AdminResearchArea> {
return request<AdminResearchArea>(`/research-areas/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteResearchArea(token: string, id: number): Promise<void> {
return request<void>(`/research-areas/${id}`, { method: "DELETE" }, token);
}
// 4. Members & Alumni
export async function adminGetMembers(): Promise<AdminMember[]> {
return request<AdminMember[]>("/members");
}
export async function adminCreateMember(
token: string,
data: Omit<AdminMember, "id" | "created_at">
): Promise<AdminMember> {
return request<AdminMember>("/members", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateMember(
token: string,
id: number,
data: Partial<AdminMember>
): Promise<AdminMember> {
return request<AdminMember>(`/members/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteMember(token: string, id: number): Promise<void> {
return request<void>(`/members/${id}`, { method: "DELETE" }, token);
}
export async function adminGetAlumni(): Promise<AdminAlumnus[]> {
return request<AdminAlumnus[]>("/alumni");
}
export async function adminCreateAlumnus(
token: string,
data: Omit<AdminAlumnus, "id" | "created_at">
): Promise<AdminAlumnus> {
return request<AdminAlumnus>("/alumni", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateAlumnus(
token: string,
id: number,
data: Partial<AdminAlumnus>
): Promise<AdminAlumnus> {
return request<AdminAlumnus>(`/alumni/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteAlumnus(token: string, id: number): Promise<void> {
return request<void>(`/alumni/${id}`, { method: "DELETE" }, token);
}
// 5. Publications & Patents
export async function adminGetPublications(category?: string): Promise<AdminPublication[]> {
const query = category ? `?category=${category}` : "";
return request<AdminPublication[]>(`/publications${query}`);
}
export async function adminCreatePublication(
token: string,
data: Omit<AdminPublication, "id" | "created_at">
): Promise<AdminPublication> {
return request<AdminPublication>("/publications", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdatePublication(
token: string,
id: number,
data: Partial<AdminPublication>
): Promise<AdminPublication> {
return request<AdminPublication>(`/publications/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeletePublication(token: string, id: number): Promise<void> {
return request<void>(`/publications/${id}`, { method: "DELETE" }, token);
}
export async function adminGetPatents(): Promise<AdminPatent[]> {
return request<AdminPatent[]>("/patents");
}
export async function adminCreatePatent(
token: string,
data: Omit<AdminPatent, "id" | "created_at">
): Promise<AdminPatent> {
return request<AdminPatent>("/patents", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdatePatent(
token: string,
id: number,
data: Partial<AdminPatent>
): Promise<AdminPatent> {
return request<AdminPatent>(`/patents/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeletePatent(token: string, id: number): Promise<void> {
return request<void>(`/patents/${id}`, { method: "DELETE" }, token);
}
// 6. Lectures (Semesters & Courses)
export async function adminGetSemesters(): Promise<AdminSemester[]> {
return request<AdminSemester[]>("/semesters");
}
export async function adminCreateSemester(
token: string,
data: Omit<AdminSemester, "id" | "courses">
): Promise<AdminSemester> {
return request<AdminSemester>("/semesters", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateSemester(
token: string,
id: number,
data: Partial<AdminSemester>
): Promise<AdminSemester> {
return request<AdminSemester>(`/semesters/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteSemester(token: string, id: number): Promise<void> {
return request<void>(`/semesters/${id}`, { method: "DELETE" }, token);
}
export async function adminCreateCourse(
token: string,
semesterId: number,
data: Omit<AdminCourse, "id" | "semester_id">
): Promise<AdminCourse> {
return request<AdminCourse>(`/semesters/${semesterId}/courses`, {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateCourse(
token: string,
id: number,
data: Partial<AdminCourse>
): Promise<AdminCourse> {
return request<AdminCourse>(`/courses/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteCourse(token: string, id: number): Promise<void> {
return request<void>(`/courses/${id}`, { method: "DELETE" }, token);
}
// 7. Standardization (Bodies & Documents)
export async function adminGetStandardsBodies(): Promise<AdminStandardsBody[]> {
return request<AdminStandardsBody[]>("/standards-bodies");
}
export async function adminCreateStandardsBody(
token: string,
data: Omit<AdminStandardsBody, "id" | "projects">
): Promise<AdminStandardsBody> {
return request<AdminStandardsBody>("/standards-bodies", {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateStandardsBody(
token: string,
id: number,
data: Partial<AdminStandardsBody>
): Promise<AdminStandardsBody> {
return request<AdminStandardsBody>(`/standards-bodies/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteStandardsBody(token: string, id: number): Promise<void> {
return request<void>(`/standards-bodies/${id}`, { method: "DELETE" }, token);
}
export async function adminCreateStandardDocument(
token: string,
bodyId: number,
data: Omit<AdminStandardDocument, "id" | "body_id">
): Promise<AdminStandardDocument> {
return request<AdminStandardDocument>(`/standards-bodies/${bodyId}/documents`, {
method: "POST",
body: JSON.stringify(data),
}, token);
}
export async function adminUpdateStandardDocument(
token: string,
id: number,
data: Partial<AdminStandardDocument>
): Promise<AdminStandardDocument> {
return request<AdminStandardDocument>(`/standard-documents/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}, token);
}
export async function adminDeleteStandardDocument(token: string, id: number): Promise<void> {
return request<void>(`/standard-documents/${id}`, { method: "DELETE" }, token);
}
+71 -9
View File
@@ -96,25 +96,33 @@ def test_build():
print(f"Parsed route modes from build table ({len(route_table_map)} routes): {route_table_map}")
# Check exact match for each dynamic route
# Filter public routes vs admin console routes
public_route_table = {k: v for k, v in route_table_map.items() if not k.startswith("/console")}
console_routes = {k: v for k, v in route_table_map.items() if k.startswith("/console")}
print(f"Public routes ({len(public_route_table)}): {public_route_table}")
if console_routes:
print(f"Admin Console routes ({len(console_routes)}): {console_routes}")
# Check exact match for each dynamic public route
for d_route in EXPECTED_DYNAMIC_ROUTES:
mode = route_table_map.get(d_route)
mode = public_route_table.get(d_route)
if mode != "ƒ":
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
return False
# Check exact match for each static route
# Check exact match for each static public route
for s_route in EXPECTED_STATIC_ROUTES:
mode = route_table_map.get(s_route)
mode = public_route_table.get(s_route)
if mode not in ("", ""):
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '' or ''")
return False
# Check that no other unexpected routes exist in table
# Check that public route set matches exactly
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
found_routes = set(route_table_map.keys())
if all_expected != found_routes:
print(f"❌ Gate 2 Violation: Route set mismatch. Missing: {all_expected - found_routes}, Extra: {found_routes - all_expected}")
found_public = set(public_route_table.keys())
if all_expected != found_public:
print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}")
return False
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes with exact table matching)")
@@ -136,6 +144,49 @@ def get_free_port():
s.bind(('', 0))
return s.getsockname()[1]
def ensure_backend_server():
try:
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
if resp.status == 200:
print("️ Go backend API server is already running on port 8080")
return None
except Exception:
pass
backend_dir = os.path.join(ROOT_DIR, "backend")
if not os.path.exists(backend_dir):
return None
print("\nStarting temporary Go backend API server for E2E tests...")
b_env = os.environ.copy()
b_env["PORT"] = "8080"
b_env["GIN_MODE"] = "release"
proc = subprocess.Popen(
["go", "run", "./cmd/api"],
cwd=backend_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=b_env,
)
ready = False
for _ in range(40):
try:
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
if resp.status == 200:
ready = True
break
except Exception:
time.sleep(0.5)
if not ready:
proc.terminate()
raise RuntimeError("Go backend API server failed to become ready on port 8080")
print("✅ Go backend API server is ready at http://localhost:8080/api/v1")
return proc
def start_next_server(port=3000):
print(f"\nStarting live Next.js server on port {port}...")
server_env = os.environ.copy()
@@ -407,6 +458,8 @@ def test_color_contrast_gate():
for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
for root, _, files in os.walk(root_dir):
if "console" in os.path.relpath(root, REFER_DIR).split(os.sep):
continue # /console is a private back-office UI, not subject to public design-system gates
for fname in files:
if fname.endswith((".ts", ".tsx")):
fpath = os.path.join(root, fname)
@@ -452,10 +505,12 @@ def main():
print("\n❌ Build or static gates failed before live server execution.")
sys.exit(1)
# Start live Next.js server once and run Gate 4, Gate 7, Gate 8
# Start live Next.js server and Go backend server (if needed) once and run Gate 4, Gate 7, Gate 8
port = get_free_port()
backend_proc = None
server_proc = None
try:
backend_proc = ensure_backend_server()
server_proc = start_next_server(port=port)
bodies = fetch_live_routes(f"http://localhost:{port}")
@@ -470,6 +525,13 @@ def main():
server_proc.wait(timeout=5)
except Exception:
server_proc.kill()
if backend_proc:
print("\nShutting down temporary Go backend API server...")
backend_proc.terminate()
try:
backend_proc.wait(timeout=5)
except Exception:
backend_proc.kill()
print("\n" + "-" * 70)
if success: