- 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
624 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|