Files
landing_page/refer_landing_page/app/console/members/page.tsx
T
Godopu 3f452468d6 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
2026-08-24 22:21:12 +09:00

624 lines
23 KiB
TypeScript

"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>
);
}