- 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
474 lines
18 KiB
TypeScript
474 lines
18 KiB
TypeScript
"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 "+ Add New Semester" 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>
|
|
);
|
|
}
|