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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user