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:
+5
-2
@@ -4,10 +4,10 @@
|
|||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| **문서 ID** | `REQ-ANL-WEB-001` |
|
| **문서 ID** | `REQ-ANL-WEB-001` |
|
||||||
| **버전** | `2.0` |
|
| **버전** | `2.1` |
|
||||||
| **작성일** | 2026-08-24 |
|
| **작성일** | 2026-08-24 |
|
||||||
| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner |
|
| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner |
|
||||||
| **상태** | 승인 (Approved) — v2.0에서 **DM-03 및 AC-17 개정**(Go/Gin 백엔드 REST API 연동 및 `force-dynamic` + `cache: 'no-store'` 기반 실시간 동적 런타임 페칭 도입, 6개 데이터 라우트 `ƒ Dynamic` 및 4개 프레임워크 라우트 `○ Static` 분리 확정). |
|
| **상태** | 승인 (Approved) — v2.1에서 **`/console` 어드민 대시보드 및 백엔드 CRUD REST API(POST/PUT/DELETE)** 추가. Bearer Token(`ADMIN_TOKEN`) 기반 관리자 인증 도입 및 백오피스 `/console` UI 전용 라우트/컴포넌트 설계 반영. 공개 디자인 토큰 계약(AC-43~AC-48)의 적용 범위(공개 랜딩 페이지 전용) 명문화. |
|
||||||
| **대상 독자** | General Manager, Developer Team Leader(Agy), Reviewer Team Leader(Cline), 지도교수 |
|
| **대상 독자** | General Manager, Developer Team Leader(Agy), Reviewer Team Leader(Cline), 지도교수 |
|
||||||
| **선행 문서** | [`README.md`](./README.md), [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md), [`refer_landing_page/docs/DESIGN.md`](./refer_landing_page/docs/DESIGN.md) |
|
| **선행 문서** | [`README.md`](./README.md), [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md), [`refer_landing_page/docs/DESIGN.md`](./refer_landing_page/docs/DESIGN.md) |
|
||||||
| **적용 범위** | `refer_landing_page/` (Next.js 14 App Router 애플리케이션) 전체 |
|
| **적용 범위** | `refer_landing_page/` (Next.js 14 App Router 애플리케이션) 전체 |
|
||||||
@@ -563,6 +563,9 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
|
|||||||
- 자동 검증: **AC-48**(Gate 11 · C11-1~C11-4).
|
- 자동 검증: **AC-48**(Gate 11 · C11-1~C11-4).
|
||||||
|
|
||||||
**R-8.32 [필수 · 게이트 면제 형식 제한] *(v1.13 신설)*** 자동 게이트의 예외는 **`(파일, 행, 사유)` 3-튜플 등재로만** 허용하며, **라인 부분문자열 면제(`if "…" in line: continue`)를 금지**한다. 등재 항목이 실제 소스에 존재하지 않으면(고아 항목) 게이트는 **실패**해야 한다.
|
**R-8.32 [필수 · 게이트 면제 형식 제한] *(v1.13 신설)*** 자동 게이트의 예외는 **`(파일, 행, 사유)` 3-튜플 등재로만** 허용하며, **라인 부분문자열 면제(`if "…" in line: continue`)를 금지**한다. 등재 항목이 실제 소스에 존재하지 않으면(고아 항목) 게이트는 **실패**해야 한다.
|
||||||
|
|
||||||
|
**R-8.33 [규정 · 디자인 시스템 적용 범위 및 관리자 콘솔 분리] *(v2.1 신설)*** AC-43, AC-44, AC-45, AC-47, C11-9 등 디자인 토큰 및 명암 대비 품질 게이트는 **공개 사용자용 랜딩 페이지(Public Site) 디자인 시스템의 무결성을 보장하기 위한 규격**이다. 비공개 백오피스 관리자 대시보드(`/console/*`)는 데이터 관리 및 CRUD 폼/테이블 조작성을 위해 독립된 콘솔 UI를 사용하며, 공개 사이트 전용 `text-white` 카운트 상한(AC-47) 및 강조색 수식자 제약에서 명시적으로 제외된다.
|
||||||
|
- 자동 검증: **Gate 10 (`/console` 경로 제외)**.
|
||||||
- 근거(실측): `run_e2e_tests.py`의 AC-44 구현은 면제 조항에 `"text-vermillion" in line`을 두었는데, 위반 판정 정규식이 `text-(cobalt|vermillion)`이므로 **vermillion으로 매치된 라인은 예외 없이 그 문자열을 포함**한다 — 즉 **AC-44의 vermillion 절반이 영구적으로 발화하지 않는다.** 실제 소스 4개 라인(`lectures:50`·`members:114`·`publications:72`·`DirectionsBlock:8`)과 회귀 주입 2건을 동일 로직에 투입해 **전부 면제됨**을 재현했다.
|
- 근거(실측): `run_e2e_tests.py`의 AC-44 구현은 면제 조항에 `"text-vermillion" in line`을 두었는데, 위반 판정 정규식이 `text-(cobalt|vermillion)`이므로 **vermillion으로 매치된 라인은 예외 없이 그 문자열을 포함**한다 — 즉 **AC-44의 vermillion 절반이 영구적으로 발화하지 않는다.** 실제 소스 4개 라인(`lectures:50`·`members:114`·`publications:72`·`DirectionsBlock:8`)과 회귀 주입 2건을 동일 로직에 투입해 **전부 면제됨**을 재현했다.
|
||||||
- 나아가 `"text-3xl"`·`"Pillar #"` 등의 면제도 **라인 전체 부분문자열 검사**라, 삼항식 한 줄에 두 색이 섞이면 **cobalt 검사까지 함께 무력화**된다 — 회귀가 가장 숨기 쉬운 형태가 정확히 그 형태다.
|
- 나아가 `"text-3xl"`·`"Pillar #"` 등의 면제도 **라인 전체 부분문자열 검사**라, 삼항식 한 줄에 두 색이 섞이면 **cobalt 검사까지 함께 무력화**된다 — 회귀가 가장 숨기 쉬운 형태가 정확히 그 형태다.
|
||||||
- 이는 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다.
|
- 이는 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다.
|
||||||
|
|||||||
@@ -15,5 +15,8 @@ GIN_MODE=release
|
|||||||
# Allowed CORS Origins (Comma separated, or * for all)
|
# Allowed CORS Origins (Comma separated, or * for all)
|
||||||
CORS_ALLOW_ORIGINS=*
|
CORS_ALLOW_ORIGINS=*
|
||||||
|
|
||||||
|
# Admin console secret bearer token for POST/PUT/DELETE mutations (Required in release mode)
|
||||||
|
ADMIN_TOKEN=your_secure_admin_bearer_token_here
|
||||||
|
|
||||||
# Automatically run SQL schema migrations on startup (true | false)
|
# Automatically run SQL schema migrations on startup (true | false)
|
||||||
AUTO_MIGRATE=true
|
AUTO_MIGRATE=true
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
*.log
|
||||||
|
|||||||
+16
-1
@@ -1,8 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/config"
|
"git.godopu.com/lab/landing_page/backend/internal/config"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||||
@@ -19,6 +21,7 @@ func main() {
|
|||||||
hostFlag := flag.String("host", "", "Host for the Gin server to bind on (overrides HOST)")
|
hostFlag := flag.String("host", "", "Host for the Gin server to bind on (overrides HOST)")
|
||||||
dbPathFlag := flag.String("db", "", "Path to SQLite database file (overrides DB_PATH)")
|
dbPathFlag := flag.String("db", "", "Path to SQLite database file (overrides DB_PATH)")
|
||||||
ginModeFlag := flag.String("mode", "", "Gin mode: release, debug, test (overrides GIN_MODE)")
|
ginModeFlag := flag.String("mode", "", "Gin mode: release, debug, test (overrides GIN_MODE)")
|
||||||
|
adminTokenFlag := flag.String("admin-token", "", "Admin bearer token for mutating APIs (overrides ADMIN_TOKEN)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *portFlag > 0 {
|
if *portFlag > 0 {
|
||||||
@@ -33,6 +36,18 @@ func main() {
|
|||||||
if *ginModeFlag != "" {
|
if *ginModeFlag != "" {
|
||||||
cfg.GinMode = *ginModeFlag
|
cfg.GinMode = *ginModeFlag
|
||||||
}
|
}
|
||||||
|
if *adminTokenFlag != "" {
|
||||||
|
cfg.AdminToken = *adminTokenFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AdminToken == "" {
|
||||||
|
if cfg.GinMode == gin.ReleaseMode || cfg.GinMode == "release" {
|
||||||
|
log.Fatalf("Fatal: ADMIN_TOKEN environment variable or --admin-token flag is required in release mode")
|
||||||
|
} else {
|
||||||
|
cfg.AdminToken = "dev-ephemeral-" + hex.EncodeToString([]byte(time.Now().Format("20060102150405")))
|
||||||
|
log.Printf("⚠️ WARNING: ADMIN_TOKEN not configured. Generated ephemeral dev token: %s", cfg.AdminToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gin.SetMode(cfg.GinMode)
|
gin.SetMode(cfg.GinMode)
|
||||||
|
|
||||||
@@ -50,7 +65,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r := router.SetupRouter(database, cfg.CORSAllowOrigins)
|
r := router.SetupRouter(database, cfg.AdminToken, cfg.CORSAllowOrigins)
|
||||||
|
|
||||||
addr := cfg.Address()
|
addr := cfg.Address()
|
||||||
log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode)
|
log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
GinMode string
|
GinMode string
|
||||||
CORSAllowOrigins string
|
CORSAllowOrigins string
|
||||||
AutoMigrate bool
|
AutoMigrate bool
|
||||||
|
AdminToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones.
|
// LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones.
|
||||||
@@ -67,6 +68,7 @@ func Load() *Config {
|
|||||||
GinMode: "release",
|
GinMode: "release",
|
||||||
CORSAllowOrigins: "*",
|
CORSAllowOrigins: "*",
|
||||||
AutoMigrate: true,
|
AutoMigrate: true,
|
||||||
|
AdminToken: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if portStr := os.Getenv("PORT"); portStr != "" {
|
if portStr := os.Getenv("PORT"); portStr != "" {
|
||||||
@@ -91,6 +93,10 @@ func Load() *Config {
|
|||||||
cfg.CORSAllowOrigins = corsStr
|
cfg.CORSAllowOrigins = corsStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if token := os.Getenv("ADMIN_TOKEN"); token != "" {
|
||||||
|
cfg.AdminToken = token
|
||||||
|
}
|
||||||
|
|
||||||
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
|
||||||
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
|
cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestConfigDefaults(t *testing.T) {
|
|||||||
_ = os.Unsetenv("GIN_MODE")
|
_ = os.Unsetenv("GIN_MODE")
|
||||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||||
|
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
assert.Equal(t, 8080, cfg.Port)
|
assert.Equal(t, 8080, cfg.Port)
|
||||||
@@ -26,6 +27,7 @@ func TestConfigDefaults(t *testing.T) {
|
|||||||
assert.Equal(t, "release", cfg.GinMode)
|
assert.Equal(t, "release", cfg.GinMode)
|
||||||
assert.Equal(t, "*", cfg.CORSAllowOrigins)
|
assert.Equal(t, "*", cfg.CORSAllowOrigins)
|
||||||
assert.True(t, cfg.AutoMigrate)
|
assert.True(t, cfg.AutoMigrate)
|
||||||
|
assert.Equal(t, "", cfg.AdminToken)
|
||||||
assert.Equal(t, "0.0.0.0:8080", cfg.Address())
|
assert.Equal(t, "0.0.0.0:8080", cfg.Address())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
|||||||
_ = os.Setenv("GIN_MODE", "debug")
|
_ = os.Setenv("GIN_MODE", "debug")
|
||||||
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
|
_ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr")
|
||||||
_ = os.Setenv("AUTO_MIGRATE", "false")
|
_ = os.Setenv("AUTO_MIGRATE", "false")
|
||||||
|
_ = os.Setenv("ADMIN_TOKEN", "super-secret-token")
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = os.Unsetenv("PORT")
|
_ = os.Unsetenv("PORT")
|
||||||
_ = os.Unsetenv("HOST")
|
_ = os.Unsetenv("HOST")
|
||||||
@@ -43,6 +46,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
|||||||
_ = os.Unsetenv("GIN_MODE")
|
_ = os.Unsetenv("GIN_MODE")
|
||||||
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
_ = os.Unsetenv("CORS_ALLOW_ORIGINS")
|
||||||
_ = os.Unsetenv("AUTO_MIGRATE")
|
_ = os.Unsetenv("AUTO_MIGRATE")
|
||||||
|
_ = os.Unsetenv("ADMIN_TOKEN")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
@@ -52,6 +56,7 @@ func TestConfigEnvOverrides(t *testing.T) {
|
|||||||
assert.Equal(t, "debug", cfg.GinMode)
|
assert.Equal(t, "debug", cfg.GinMode)
|
||||||
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
|
assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins)
|
||||||
assert.False(t, cfg.AutoMigrate)
|
assert.False(t, cfg.AutoMigrate)
|
||||||
|
assert.Equal(t, "super-secret-token", cfg.AdminToken)
|
||||||
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
|
assert.Equal(t, "127.0.0.1:9090", cfg.Address())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||||
|
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -23,6 +26,61 @@ func (h *HomeHandler) GetResearchProjects(c *gin.Context) {
|
|||||||
httpx.OK(c, projects)
|
httpx.OK(c, projects)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) CreateResearchProject(c *gin.Context) {
|
||||||
|
var req models.ResearchProject
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
|
||||||
|
httpx.BadRequest(c, "slug, title, and abstract are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreateResearchProject(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create research project: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) UpdateResearchProject(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid project ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.ResearchProject
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Slug == "" || req.Title == "" || req.Abstract == "" {
|
||||||
|
httpx.BadRequest(c, "slug, title, and abstract are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateResearchProject(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update research project: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) DeleteResearchProject(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid project ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteResearchProject(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete research project: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
||||||
areas, err := h.repo.GetResearchAreas(c.Request.Context())
|
areas, err := h.repo.GetResearchAreas(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,6 +90,61 @@ func (h *HomeHandler) GetResearchAreas(c *gin.Context) {
|
|||||||
httpx.OK(c, areas)
|
httpx.OK(c, areas)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) CreateResearchArea(c *gin.Context) {
|
||||||
|
var req models.ResearchArea
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NameEn == "" {
|
||||||
|
httpx.BadRequest(c, "name_en is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreateResearchArea(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create research area: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) UpdateResearchArea(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid area ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.ResearchArea
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NameEn == "" {
|
||||||
|
httpx.BadRequest(c, "name_en is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateResearchArea(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update research area: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HomeHandler) DeleteResearchArea(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid area ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteResearchArea(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete research area: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) GetStatsSummary(c *gin.Context) {
|
func (h *HomeHandler) GetStatsSummary(c *gin.Context) {
|
||||||
stats, err := h.repo.GetStatsSummary(c.Request.Context())
|
stats, err := h.repo.GetStatsSummary(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||||
|
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var validTerms = map[string]bool{
|
||||||
|
"Spring": true,
|
||||||
|
"Fall": true,
|
||||||
|
}
|
||||||
|
|
||||||
type LecturesHandler struct {
|
type LecturesHandler struct {
|
||||||
repo *repository.LecturesRepository
|
repo *repository.LecturesRepository
|
||||||
}
|
}
|
||||||
@@ -22,3 +30,119 @@ func (h *LecturesHandler) GetSemesters(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, semesters)
|
httpx.OK(c, semesters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *LecturesHandler) CreateSemester(c *gin.Context) {
|
||||||
|
var req models.Semester
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Year < 1970 || !validTerms[req.Term] {
|
||||||
|
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreateSemester(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create semester: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LecturesHandler) UpdateSemester(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid semester ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.Semester
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Year < 1970 || !validTerms[req.Term] {
|
||||||
|
httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateSemester(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update semester: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LecturesHandler) DeleteSemester(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid semester ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteSemester(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete semester: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LecturesHandler) CreateCourse(c *gin.Context) {
|
||||||
|
semesterID, err := strconv.Atoi(c.Param("semesterId"))
|
||||||
|
if err != nil || semesterID <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid semesterId parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.Course
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Code == "" || req.NameEn == "" {
|
||||||
|
httpx.BadRequest(c, "code and name_en are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.SemesterID = semesterID
|
||||||
|
id, err := h.repo.CreateCourse(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create course: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LecturesHandler) UpdateCourse(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid course ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.Course
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Code == "" || req.NameEn == "" {
|
||||||
|
httpx.BadRequest(c, "code and name_en are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateCourse(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update course: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LecturesHandler) DeleteCourse(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid course ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteCourse(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete course: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||||
|
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var validMemberDegrees = map[string]bool{
|
||||||
|
"Professor": true,
|
||||||
|
"PhD": true,
|
||||||
|
"PhDCandidate": true,
|
||||||
|
"PhDStudent": true,
|
||||||
|
"MSCandidate": true,
|
||||||
|
"MSStudent": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var validAlumniDegrees = map[string]bool{
|
||||||
|
"PhD": true,
|
||||||
|
"MS": true,
|
||||||
|
}
|
||||||
|
|
||||||
type MembersHandler struct {
|
type MembersHandler struct {
|
||||||
repo *repository.MembersRepository
|
repo *repository.MembersRepository
|
||||||
}
|
}
|
||||||
@@ -23,6 +40,69 @@ func (h *MembersHandler) GetMembers(c *gin.Context) {
|
|||||||
httpx.OK(c, members)
|
httpx.OK(c, members)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *MembersHandler) CreateMember(c *gin.Context) {
|
||||||
|
var req models.Member
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NameKo == "" || req.NameEn == "" {
|
||||||
|
httpx.BadRequest(c, "name_ko and name_en are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validMemberDegrees[req.Degree] {
|
||||||
|
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreateMember(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create member: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MembersHandler) UpdateMember(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid member ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.Member
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NameKo == "" || req.NameEn == "" {
|
||||||
|
httpx.BadRequest(c, "name_ko and name_en are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validMemberDegrees[req.Degree] {
|
||||||
|
httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateMember(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update member: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MembersHandler) DeleteMember(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid member ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteMember(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete member: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
||||||
alumni, err := h.repo.GetAlumni(c.Request.Context())
|
alumni, err := h.repo.GetAlumni(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,3 +111,66 @@ func (h *MembersHandler) GetAlumni(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, alumni)
|
httpx.OK(c, alumni)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *MembersHandler) CreateAlumnus(c *gin.Context) {
|
||||||
|
var req models.Alumnus
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
|
||||||
|
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validAlumniDegrees[req.Degree] {
|
||||||
|
httpx.BadRequest(c, "degree must be one of: PhD, MS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreateAlumnus(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create alumnus: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MembersHandler) UpdateAlumnus(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid alumnus ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.Alumnus
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" {
|
||||||
|
httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validAlumniDegrees[req.Degree] {
|
||||||
|
httpx.BadRequest(c, "degree must be one of: PhD, MS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateAlumnus(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update alumnus: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MembersHandler) DeleteAlumnus(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid alumnus ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteAlumnus(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete alumnus: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||||
|
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var validPubCategories = map[string]bool{
|
||||||
|
"intl-journal-conf": true,
|
||||||
|
"domestic-journal-conf": true,
|
||||||
|
}
|
||||||
|
|
||||||
type PublicationsHandler struct {
|
type PublicationsHandler struct {
|
||||||
repo *repository.PublicationsRepository
|
repo *repository.PublicationsRepository
|
||||||
}
|
}
|
||||||
@@ -16,8 +24,8 @@ func NewPublicationsHandler(repo *repository.PublicationsRepository) *Publicatio
|
|||||||
|
|
||||||
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
||||||
category := c.Query("category")
|
category := c.Query("category")
|
||||||
if category != "" && category != "intl-journal-conf" && category != "domestic-journal-conf" {
|
if category != "" && !validPubCategories[category] {
|
||||||
httpx.BadRequest(c, "Invalid category parameter: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
httpx.BadRequest(c, "Invalid category: must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,13 +37,76 @@ func (h *PublicationsHandler) GetPublications(c *gin.Context) {
|
|||||||
httpx.OK(c, pubs)
|
httpx.OK(c, pubs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
|
func (h *PublicationsHandler) CreatePublication(c *gin.Context) {
|
||||||
highlights, err := h.repo.GetHighlights(c.Request.Context())
|
var req models.Publication
|
||||||
if err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
httpx.InternalServerError(c, "Failed to retrieve highlights: "+err.Error())
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.OK(c, highlights)
|
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
|
||||||
|
httpx.BadRequest(c, "title, venue, and published_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validPubCategories[req.Category] {
|
||||||
|
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreatePublication(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create publication: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationsHandler) UpdatePublication(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid publication ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.Publication
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.Venue == "" || req.PublishedAt == "" {
|
||||||
|
httpx.BadRequest(c, "title, venue, and published_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validPubCategories[req.Category] {
|
||||||
|
httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdatePublication(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update publication: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationsHandler) DeletePublication(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid publication ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeletePublication(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete publication: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationsHandler) GetHighlights(c *gin.Context) {
|
||||||
|
pubs, err := h.repo.GetHighlights(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to retrieve highlight publications: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, pubs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
||||||
@@ -46,3 +117,58 @@ func (h *PublicationsHandler) GetPatents(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, patents)
|
httpx.OK(c, patents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *PublicationsHandler) CreatePatent(c *gin.Context) {
|
||||||
|
var req models.Patent
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
|
||||||
|
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreatePatent(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create patent: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationsHandler) UpdatePatent(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid patent ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.Patent
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" {
|
||||||
|
httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdatePatent(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update patent: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationsHandler) DeletePatent(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid patent ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeletePatent(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete patent: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
"git.godopu.com/lab/landing_page/backend/internal/httpx"
|
||||||
|
"git.godopu.com/lab/landing_page/backend/internal/models"
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
"git.godopu.com/lab/landing_page/backend/internal/repository"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var validScopes = map[string]bool{
|
||||||
|
"international": true,
|
||||||
|
"domestic": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var validDocStatuses = map[string]bool{
|
||||||
|
"IS": true,
|
||||||
|
"TS": true,
|
||||||
|
"TR": true,
|
||||||
|
"CDV": true,
|
||||||
|
"WDTR": true,
|
||||||
|
"InProgress": true,
|
||||||
|
}
|
||||||
|
|
||||||
type StandardizationHandler struct {
|
type StandardizationHandler struct {
|
||||||
repo *repository.StandardizationRepository
|
repo *repository.StandardizationRepository
|
||||||
}
|
}
|
||||||
@@ -22,3 +39,135 @@ func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
httpx.OK(c, bodies)
|
httpx.OK(c, bodies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *StandardizationHandler) CreateStandardsBody(c *gin.Context) {
|
||||||
|
var req models.StandardsBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Org == "" || req.FullName == "" || req.Period == "" {
|
||||||
|
httpx.BadRequest(c, "org, full_name, and period are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validScopes[req.Scope] {
|
||||||
|
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.repo.CreateStandardsBody(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create standards body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StandardizationHandler) UpdateStandardsBody(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid standards body ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.StandardsBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Org == "" || req.FullName == "" || req.Period == "" {
|
||||||
|
httpx.BadRequest(c, "org, full_name, and period are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validScopes[req.Scope] {
|
||||||
|
httpx.BadRequest(c, "scope must be 'international' or 'domestic'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateStandardsBody(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update standards body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StandardizationHandler) DeleteStandardsBody(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid standards body ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteStandardsBody(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete standards body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StandardizationHandler) CreateStandardDocument(c *gin.Context) {
|
||||||
|
bodyID, err := strconv.Atoi(c.Param("bodyId"))
|
||||||
|
if err != nil || bodyID <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid bodyId parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.StandardDocument
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
|
||||||
|
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validDocStatuses[req.Status] {
|
||||||
|
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.BodyID = bodyID
|
||||||
|
id, err := h.repo.CreateStandardDocument(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to create standard document: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
httpx.Created(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StandardizationHandler) UpdateStandardDocument(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid document ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.StandardDocument
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.BadRequest(c, "Invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" {
|
||||||
|
httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validDocStatuses[req.Status] {
|
||||||
|
httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = id
|
||||||
|
if err := h.repo.UpdateStandardDocument(c.Request.Context(), id, req); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to update standard document: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.OK(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StandardizationHandler) DeleteStandardDocument(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
httpx.BadRequest(c, "Invalid document ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteStandardDocument(c.Request.Context(), id); err != nil {
|
||||||
|
httpx.InternalServerError(c, "Failed to delete standard document: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.NoContent(c)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAdminToken returns a Gin middleware that validates Bearer <token> against the configured admin token.
|
||||||
|
func RequireAdminToken(adminToken string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if adminToken == "" {
|
||||||
|
Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
Unauthorized(c, "Missing Authorization header")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != adminToken {
|
||||||
|
Unauthorized(c, "Invalid admin bearer token")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,20 @@ func Error(c *gin.Context, status int, msg string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Created(c *gin.Context, data any) {
|
||||||
|
c.JSON(http.StatusCreated, SuccessResponse{
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NoContent(c *gin.Context) {
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unauthorized(c *gin.Context, msg string) {
|
||||||
|
Error(c, http.StatusUnauthorized, msg)
|
||||||
|
}
|
||||||
|
|
||||||
func BadRequest(c *gin.Context, msg string) {
|
func BadRequest(c *gin.Context, msg string) {
|
||||||
Error(c, http.StatusBadRequest, msg)
|
Error(c, http.StatusBadRequest, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,54 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese
|
|||||||
return projects, nil
|
return projects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *HomeRepository) CreateResearchProject(ctx context.Context, p models.ResearchProject) (int, error) {
|
||||||
|
kwBytes, _ := json.Marshal(p.Keywords)
|
||||||
|
if p.Keywords == nil {
|
||||||
|
kwBytes = []byte("[]")
|
||||||
|
}
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert research project: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HomeRepository) UpdateResearchProject(ctx context.Context, id int, p models.ResearchProject) error {
|
||||||
|
kwBytes, _ := json.Marshal(p.Keywords)
|
||||||
|
if p.Keywords == nil {
|
||||||
|
kwBytes = []byte("[]")
|
||||||
|
}
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE research_projects
|
||||||
|
SET slug = ?, title = ?, abstract = ?, keywords = ?, organization = ?, standards_org = ?, period = ?, funder = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update research project: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HomeRepository) DeleteResearchProject(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM research_projects WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete research project: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) {
|
func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name_en, name_kr, display_order
|
SELECT id, name_en, name_kr, display_order
|
||||||
@@ -78,6 +126,46 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
|
|||||||
return areas, rows.Err()
|
return areas, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.ResearchArea) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`, a.NameEn, a.NameKr, a.DisplayOrder)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert research area: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a models.ResearchArea) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE research_areas
|
||||||
|
SET name_en = ?, name_kr = ?, display_order = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, a.NameEn, a.NameKr, a.DisplayOrder, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update research area: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM research_areas WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete research area: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {
|
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {
|
||||||
var summary models.StatsSummary
|
var summary models.StatsSummary
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func NewLecturesRepository(db *sql.DB) *LecturesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
|
func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) {
|
||||||
// Query semesters ordered by year DESC and Fall before Spring within same year
|
|
||||||
semQuery := `
|
semQuery := `
|
||||||
SELECT id, year, term
|
SELECT id, year, term
|
||||||
FROM semesters
|
FROM semesters
|
||||||
@@ -30,7 +29,7 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
|||||||
defer semRows.Close()
|
defer semRows.Close()
|
||||||
|
|
||||||
var semesters []models.SemesterWithCourses
|
var semesters []models.SemesterWithCourses
|
||||||
semMap := make(map[int]int) // semester_id -> index in slice
|
semMap := make(map[int]int)
|
||||||
|
|
||||||
for semRows.Next() {
|
for semRows.Next() {
|
||||||
var s models.SemesterWithCourses
|
var s models.SemesterWithCourses
|
||||||
@@ -45,7 +44,6 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query courses ordered by display_order ASC, id ASC
|
|
||||||
courseQuery := `
|
courseQuery := `
|
||||||
SELECT id, semester_id, code, name_en, name_kr, note, display_order
|
SELECT id, semester_id, code, name_en, name_kr, note, display_order
|
||||||
FROM courses
|
FROM courses
|
||||||
@@ -69,3 +67,83 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod
|
|||||||
|
|
||||||
return semesters, courseRows.Err()
|
return semesters, courseRows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *LecturesRepository) CreateSemester(ctx context.Context, s models.Semester) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO semesters (year, term)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`, s.Year, s.Term)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert semester: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LecturesRepository) UpdateSemester(ctx context.Context, id int, s models.Semester) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE semesters
|
||||||
|
SET year = ?, term = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, s.Year, s.Term, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update semester: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LecturesRepository) DeleteSemester(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM semesters WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete semester: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LecturesRepository) CreateCourse(ctx context.Context, c models.Course) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, c.SemesterID, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert course: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LecturesRepository) UpdateCourse(ctx context.Context, id int, c models.Course) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE courses
|
||||||
|
SET code = ?, name_en = ?, name_kr = ?, note = ?, display_order = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update course: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LecturesRepository) DeleteCourse(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM courses WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete course: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,46 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er
|
|||||||
return members, rows.Err()
|
return members, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MembersRepository) CreateMember(ctx context.Context, m models.Member) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert member: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MembersRepository) UpdateMember(ctx context.Context, id int, m models.Member) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE members
|
||||||
|
SET name_ko = ?, name_en = ?, degree = ?, affiliation = ?, email = ?, is_advisor = ?, display_order = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update member: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MembersRepository) DeleteMember(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM members WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete member: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, error) {
|
func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name_ko, name_en, degree, graduated_at, major, current_position, created_at
|
SELECT id, name_ko, name_en, degree, graduated_at, major, current_position, created_at
|
||||||
@@ -67,3 +107,43 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er
|
|||||||
}
|
}
|
||||||
return alumni, rows.Err()
|
return alumni, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MembersRepository) CreateAlumnus(ctx context.Context, a models.Alumnus) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert alumnus: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MembersRepository) UpdateAlumnus(ctx context.Context, id int, a models.Alumnus) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE alumni
|
||||||
|
SET name_ko = ?, name_en = ?, degree = ?, graduated_at = ?, major = ?, current_position = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update alumnus: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MembersRepository) DeleteAlumnus(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM alumni WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete alumnus: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,46 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s
|
|||||||
return pubs, rows.Err()
|
return pubs, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PublicationsRepository) CreatePublication(ctx context.Context, p models.Publication) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert publication: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationsRepository) UpdatePublication(ctx context.Context, id int, p models.Publication) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE publications
|
||||||
|
SET category = ?, title = ?, authors = ?, venue = ?, volume = ?, published_at = ?, kci = ?, doi = ?, is_highlight = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update publication: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationsRepository) DeletePublication(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM publications WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete publication: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Publication, error) {
|
func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Publication, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
|
SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at
|
||||||
@@ -100,3 +140,43 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten
|
|||||||
}
|
}
|
||||||
return patents, rows.Err()
|
return patents, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PublicationsRepository) CreatePatent(ctx context.Context, p models.Patent) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert patent: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationsRepository) UpdatePatent(ctx context.Context, id int, p models.Patent) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE patents
|
||||||
|
SET title = ?, inventors = ?, application_no = ?, application_at = ?, registration_no = ?, registration_at = ?, country = ?, published_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update patent: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationsRepository) DeletePatent(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM patents WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete patent: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func NewStandardizationRepository(db *sql.DB) *StandardizationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
|
func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) {
|
||||||
// Query bodies ordered by display_order
|
|
||||||
bodyQuery := `
|
bodyQuery := `
|
||||||
SELECT id, org, full_name, scope, period, role, display_order
|
SELECT id, org, full_name, scope, period, role, display_order
|
||||||
FROM standards_bodies
|
FROM standards_bodies
|
||||||
@@ -30,7 +29,7 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
defer bodyRows.Close()
|
defer bodyRows.Close()
|
||||||
|
|
||||||
var bodies []models.StandardsBodyWithProjects
|
var bodies []models.StandardsBodyWithProjects
|
||||||
bodyMap := make(map[int]int) // body_id -> index in bodies
|
bodyMap := make(map[int]int)
|
||||||
|
|
||||||
for bodyRows.Next() {
|
for bodyRows.Next() {
|
||||||
var b models.StandardsBodyWithProjects
|
var b models.StandardsBodyWithProjects
|
||||||
@@ -45,7 +44,6 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query documents ordered by body_id, and preserves curated insertion order with MIN(id) ASC
|
|
||||||
docQuery := `
|
docQuery := `
|
||||||
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
|
SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at
|
||||||
FROM standard_documents
|
FROM standard_documents
|
||||||
@@ -57,13 +55,12 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
}
|
}
|
||||||
defer docRows.Close()
|
defer docRows.Close()
|
||||||
|
|
||||||
// Group documents into projects under each body preserving curated order
|
|
||||||
type projectKey struct {
|
type projectKey struct {
|
||||||
bodyID int
|
bodyID int
|
||||||
wg string
|
wg string
|
||||||
projectName string
|
projectName string
|
||||||
}
|
}
|
||||||
projectMap := make(map[projectKey]int) // projectKey -> index in bodies[bIdx].Projects
|
projectMap := make(map[projectKey]int)
|
||||||
|
|
||||||
for docRows.Next() {
|
for docRows.Next() {
|
||||||
var d models.StandardDocument
|
var d models.StandardDocument
|
||||||
@@ -93,3 +90,83 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C
|
|||||||
|
|
||||||
return bodies, docRows.Err()
|
return bodies, docRows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *StandardizationRepository) CreateStandardsBody(ctx context.Context, b models.StandardsBody) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert standards body: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StandardizationRepository) UpdateStandardsBody(ctx context.Context, id int, b models.StandardsBody) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE standards_bodies
|
||||||
|
SET org = ?, full_name = ?, scope = ?, period = ?, role = ?, display_order = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update standards body: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StandardizationRepository) DeleteStandardsBody(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM standards_bodies WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete standards body: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StandardizationRepository) CreateStandardDocument(ctx context.Context, d models.StandardDocument) (int, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, d.BodyID, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert standard document: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StandardizationRepository) UpdateStandardDocument(ctx context.Context, id int, d models.StandardDocument) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE standard_documents
|
||||||
|
SET wg = ?, project_name = ?, title = ?, doc_ref = ?, status = ?, published_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update standard document: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StandardizationRepository) DeleteStandardDocument(ctx context.Context, id int) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM standard_documents WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete standard document: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err == nil && rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
allowOrigin := "*"
|
allowOrigin := "*"
|
||||||
@@ -44,7 +44,7 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
|||||||
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
lecturesHandler := handlers.NewLecturesHandler(lecturesRepo)
|
||||||
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
standardsHandler := handlers.NewStandardizationHandler(standardsRepo)
|
||||||
|
|
||||||
// API v1 Group
|
// API v1 Public Group (Read-only)
|
||||||
v1 := r.Group("/api/v1")
|
v1 := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
v1.GET("/health", func(c *gin.Context) {
|
v1.GET("/health", func(c *gin.Context) {
|
||||||
@@ -72,5 +72,60 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine {
|
|||||||
v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies)
|
v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API v1 Admin Group (Mutating operations requiring Bearer token)
|
||||||
|
admin := v1.Group("")
|
||||||
|
admin.Use(httpx.RequireAdminToken(adminToken))
|
||||||
|
{
|
||||||
|
admin.GET("/admin/verify", func(c *gin.Context) {
|
||||||
|
httpx.OK(c, gin.H{"status": "authenticated"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Research Projects
|
||||||
|
admin.POST("/research-projects", homeHandler.CreateResearchProject)
|
||||||
|
admin.PUT("/research-projects/:id", homeHandler.UpdateResearchProject)
|
||||||
|
admin.DELETE("/research-projects/:id", homeHandler.DeleteResearchProject)
|
||||||
|
|
||||||
|
// Research Areas
|
||||||
|
admin.POST("/research-areas", homeHandler.CreateResearchArea)
|
||||||
|
admin.PUT("/research-areas/:id", homeHandler.UpdateResearchArea)
|
||||||
|
admin.DELETE("/research-areas/:id", homeHandler.DeleteResearchArea)
|
||||||
|
|
||||||
|
// Members & Alumni
|
||||||
|
admin.POST("/members", membersHandler.CreateMember)
|
||||||
|
admin.PUT("/members/:id", membersHandler.UpdateMember)
|
||||||
|
admin.DELETE("/members/:id", membersHandler.DeleteMember)
|
||||||
|
|
||||||
|
admin.POST("/alumni", membersHandler.CreateAlumnus)
|
||||||
|
admin.PUT("/alumni/:id", membersHandler.UpdateAlumnus)
|
||||||
|
admin.DELETE("/alumni/:id", membersHandler.DeleteAlumnus)
|
||||||
|
|
||||||
|
// Publications & Patents
|
||||||
|
admin.POST("/publications", pubsHandler.CreatePublication)
|
||||||
|
admin.PUT("/publications/:id", pubsHandler.UpdatePublication)
|
||||||
|
admin.DELETE("/publications/:id", pubsHandler.DeletePublication)
|
||||||
|
|
||||||
|
admin.POST("/patents", pubsHandler.CreatePatent)
|
||||||
|
admin.PUT("/patents/:id", pubsHandler.UpdatePatent)
|
||||||
|
admin.DELETE("/patents/:id", pubsHandler.DeletePatent)
|
||||||
|
|
||||||
|
// Lectures (Semesters & Courses)
|
||||||
|
admin.POST("/semesters", lecturesHandler.CreateSemester)
|
||||||
|
admin.PUT("/semesters/:id", lecturesHandler.UpdateSemester)
|
||||||
|
admin.DELETE("/semesters/:id", lecturesHandler.DeleteSemester)
|
||||||
|
|
||||||
|
admin.POST("/semesters/:semesterId/courses", lecturesHandler.CreateCourse)
|
||||||
|
admin.PUT("/courses/:id", lecturesHandler.UpdateCourse)
|
||||||
|
admin.DELETE("/courses/:id", lecturesHandler.DeleteCourse)
|
||||||
|
|
||||||
|
// Standardization (Bodies & Documents)
|
||||||
|
admin.POST("/standards-bodies", standardsHandler.CreateStandardsBody)
|
||||||
|
admin.PUT("/standards-bodies/:id", standardsHandler.UpdateStandardsBody)
|
||||||
|
admin.DELETE("/standards-bodies/:id", standardsHandler.DeleteStandardsBody)
|
||||||
|
|
||||||
|
admin.POST("/standards-bodies/:bodyId/documents", standardsHandler.CreateStandardDocument)
|
||||||
|
admin.PUT("/standard-documents/:id", standardsHandler.UpdateStandardDocument)
|
||||||
|
admin.DELETE("/standard-documents/:id", standardsHandler.DeleteStandardDocument)
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package router_test
|
package router_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.godopu.com/lab/landing_page/backend/internal/db"
|
"git.godopu.com/lab/landing_page/backend/internal/db"
|
||||||
@@ -18,8 +20,9 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testAdminToken = "test-secret-token"
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
// In-memory or temporary file sqlite DB
|
|
||||||
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
||||||
database, err := db.Connect(tmpDB)
|
database, err := db.Connect(tmpDB)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -27,7 +30,6 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
err = db.RunMigrations(database)
|
err = db.RunMigrations(database)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Seed test data from ../../seed/data
|
|
||||||
seedDir := filepath.Join("..", "..", "seed", "data")
|
seedDir := filepath.Join("..", "..", "seed", "data")
|
||||||
seedDatabase(t, database, seedDir)
|
seedDatabase(t, database, seedDir)
|
||||||
|
|
||||||
@@ -65,45 +67,75 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
|||||||
// 2. Areas
|
// 2. Areas
|
||||||
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var areas []models.ResearchArea
|
var areas []struct {
|
||||||
|
En string `json:"en"`
|
||||||
|
Kr *string `json:"kr"`
|
||||||
|
}
|
||||||
require.NoError(t, json.Unmarshal(aData, &areas))
|
require.NoError(t, json.Unmarshal(aData, &areas))
|
||||||
for _, a := range areas {
|
for idx, a := range areas {
|
||||||
_, err := tx.Exec(`INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?)`, a.NameEn, a.NameKr, a.DisplayOrder)
|
_, err := tx.Exec(`
|
||||||
|
INSERT INTO research_areas (name_en, name_kr, display_order)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`, a.En, a.Kr, idx+1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Members
|
// 3. Members
|
||||||
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
|
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var members []models.Member
|
var members []struct {
|
||||||
|
Ko string `json:"ko"`
|
||||||
|
En string `json:"en"`
|
||||||
|
Degree string `json:"degree"`
|
||||||
|
Affiliation *string `json:"affiliation"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
IsAdvisor bool `json:"is_advisor"`
|
||||||
|
}
|
||||||
require.NoError(t, json.Unmarshal(mData, &members))
|
require.NoError(t, json.Unmarshal(mData, &members))
|
||||||
for _, m := range members {
|
for idx, m := range members {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder)
|
`, m.Ko, m.En, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, idx+1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Alumni
|
// 4. Alumni
|
||||||
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
|
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var alumni []models.Alumnus
|
var alumni []struct {
|
||||||
|
Ko string `json:"ko"`
|
||||||
|
En string `json:"en"`
|
||||||
|
Degree string `json:"degree"`
|
||||||
|
GraduatedAt string `json:"graduatedAt"`
|
||||||
|
Major *string `json:"major"`
|
||||||
|
CurrentPosition *string `json:"currentPosition"`
|
||||||
|
}
|
||||||
require.NoError(t, json.Unmarshal(alumniData, &alumni))
|
require.NoError(t, json.Unmarshal(alumniData, &alumni))
|
||||||
for _, a := range alumni {
|
for _, a := range alumni {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
`, a.Ko, a.En, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Publications
|
// 5. Publications
|
||||||
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
|
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var pubs []models.Publication
|
var publications []struct {
|
||||||
require.NoError(t, json.Unmarshal(pubData, &pubs))
|
Category string `json:"category"`
|
||||||
for _, p := range pubs {
|
Title string `json:"title"`
|
||||||
|
Authors *string `json:"authors"`
|
||||||
|
Venue string `json:"venue"`
|
||||||
|
Volume *string `json:"volume"`
|
||||||
|
PublishedAt string `json:"publishedAt"`
|
||||||
|
KCI bool `json:"kci"`
|
||||||
|
DOI *string `json:"doi"`
|
||||||
|
IsHighlight bool `json:"is_highlight"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(pubData, &publications))
|
||||||
|
for _, p := range publications {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -114,17 +146,26 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
|||||||
// 6. Patents
|
// 6. Patents
|
||||||
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var patents []models.Patent
|
var patents []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Inventors string `json:"inventors"`
|
||||||
|
ApplicationNo string `json:"applicationNo"`
|
||||||
|
ApplicationAt string `json:"applicationAt"`
|
||||||
|
RegistrationNo *string `json:"registrationNo"`
|
||||||
|
RegistrationAt *string `json:"registrationAt"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
PublishedAt string `json:"publishedAt"`
|
||||||
|
}
|
||||||
require.NoError(t, json.Unmarshal(patData, &patents))
|
require.NoError(t, json.Unmarshal(patData, &patents))
|
||||||
for _, pat := range patents {
|
for _, p := range patents {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, pat.Title, pat.Inventors, pat.ApplicationNo, pat.ApplicationAt, pat.RegistrationNo, pat.RegistrationAt, pat.Country, pat.PublishedAt)
|
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Semesters & Courses
|
// 7. Lectures
|
||||||
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var semesters []struct {
|
var semesters []struct {
|
||||||
@@ -200,247 +241,201 @@ type APIResponse[T any] struct {
|
|||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeRequest(r http.Handler, method, target string) *httptest.ResponseRecorder {
|
func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(method, target, nil)
|
var reqBody []byte
|
||||||
|
if body != nil {
|
||||||
|
reqBody, _ = json.Marshal(body)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, target, bytes.NewReader(reqBody))
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthEndpoint(t *testing.T) {
|
func TestHealthCheck(t *testing.T) {
|
||||||
database := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
defer database.Close()
|
defer db.Close()
|
||||||
|
r := router.SetupRouter(db, testAdminToken)
|
||||||
r := router.SetupRouter(database)
|
|
||||||
w := executeRequest(r, "GET", "/api/v1/health")
|
|
||||||
|
|
||||||
|
w := doRequest(r, "GET", "/api/v1/health", nil, "")
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
var resp APIResponse[map[string]any]
|
}
|
||||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
|
func TestAdminAuthMiddleware(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// A. Configured with explicit valid token
|
||||||
|
r := router.SetupRouter(db, testAdminToken)
|
||||||
|
|
||||||
|
// 1. Unauthenticated request to /admin/verify should return 401
|
||||||
|
wNoAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, wNoAuth.Code)
|
||||||
|
|
||||||
|
// 2. Guessable fallback token "admin123" must return 401
|
||||||
|
wGuessAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "admin123")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, wGuessAuth.Code)
|
||||||
|
|
||||||
|
// 3. Wrong token request to /admin/verify should return 401
|
||||||
|
wWrongAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "wrong-token")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, wWrongAuth.Code)
|
||||||
|
|
||||||
|
// 4. Valid token request to /admin/verify should return 200
|
||||||
|
wValidAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusOK, wValidAuth.Code)
|
||||||
|
|
||||||
|
// 5. Public GET routes remain accessible without auth
|
||||||
|
wPublic := doRequest(r, "GET", "/api/v1/members", nil, "")
|
||||||
|
assert.Equal(t, http.StatusOK, wPublic.Code)
|
||||||
|
|
||||||
|
// B. Unconfigured (empty string) admin token: must strictly reject ALL mutating/verify attempts
|
||||||
|
rEmptyToken := router.SetupRouter(db, "")
|
||||||
|
|
||||||
|
// 6. Any token on verify should return 401
|
||||||
|
wEmpty1 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "admin123")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, wEmpty1.Code)
|
||||||
|
|
||||||
|
wEmpty2 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, wEmpty2.Code)
|
||||||
|
|
||||||
|
// 7. Mutating POST on empty router should return 401
|
||||||
|
newProj := models.ResearchProject{
|
||||||
|
Slug: "test-unauth",
|
||||||
|
Title: "Unauthorized Title",
|
||||||
|
Abstract: "Unauthorized abstract.",
|
||||||
|
Keywords: []string{"unauth"},
|
||||||
|
}
|
||||||
|
wMutate := doRequest(rEmptyToken, "POST", "/api/v1/research-projects", newProj, "admin123")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, wMutate.Code)
|
||||||
|
|
||||||
|
// 8. Public GET routes still work on empty admin token router
|
||||||
|
wPublicEmpty := doRequest(rEmptyToken, "GET", "/api/v1/health", nil, "")
|
||||||
|
assert.Equal(t, http.StatusOK, wPublicEmpty.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResearchProjectsCRUD(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
r := router.SetupRouter(db, testAdminToken)
|
||||||
|
|
||||||
|
// 1. Create Project
|
||||||
|
newProj := models.ResearchProject{
|
||||||
|
Slug: "test-crud-proj",
|
||||||
|
Title: "Test Project Title",
|
||||||
|
Abstract: "Test project abstract for CRUD verification.",
|
||||||
|
Keywords: []string{"test", "crud"},
|
||||||
|
}
|
||||||
|
wCreate := doRequest(r, "POST", "/api/v1/research-projects", newProj, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
||||||
|
|
||||||
|
var createResp APIResponse[models.ResearchProject]
|
||||||
|
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||||
|
createdID := createResp.Data.ID
|
||||||
|
assert.True(t, createdID > 0)
|
||||||
|
|
||||||
|
// 2. Update Project
|
||||||
|
newProj.Title = "Updated Project Title"
|
||||||
|
wUpdate := doRequest(r, "PUT", "/api/v1/research-projects/"+strconvItoa(createdID), newProj, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusOK, wUpdate.Code)
|
||||||
|
|
||||||
|
// 3. Delete Project
|
||||||
|
wDelete := doRequest(r, "DELETE", "/api/v1/research-projects/"+strconvItoa(createdID), nil, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMembersAndAlumniCRUD(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
r := router.SetupRouter(db, testAdminToken)
|
||||||
|
|
||||||
|
// 1. Create Member with Invalid Degree -> 400
|
||||||
|
badMember := models.Member{
|
||||||
|
NameKo: "홍길동",
|
||||||
|
NameEn: "Gildong Hong",
|
||||||
|
Degree: "InvalidDegree",
|
||||||
|
}
|
||||||
|
wBad := doRequest(r, "POST", "/api/v1/members", badMember, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, wBad.Code)
|
||||||
|
|
||||||
|
// 2. Create Valid Member -> 201
|
||||||
|
validMember := models.Member{
|
||||||
|
NameKo: "홍길동",
|
||||||
|
NameEn: "Gildong Hong",
|
||||||
|
Degree: "PhDStudent",
|
||||||
|
}
|
||||||
|
wCreate := doRequest(r, "POST", "/api/v1/members", validMember, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
||||||
|
|
||||||
|
var createResp APIResponse[models.Member]
|
||||||
|
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
||||||
|
mID := createResp.Data.ID
|
||||||
|
|
||||||
|
// 3. Delete Member -> 204
|
||||||
|
wDelete := doRequest(r, "DELETE", "/api/v1/members/"+strconvItoa(mID), nil, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
||||||
|
|
||||||
|
// 4. Create Alumnus -> 201
|
||||||
|
validAlumnus := models.Alumnus{
|
||||||
|
NameKo: "김졸업",
|
||||||
|
NameEn: "Grad Kim",
|
||||||
|
Degree: "MS",
|
||||||
|
GraduatedAt: "2024-02",
|
||||||
|
}
|
||||||
|
wCreateAl := doRequest(r, "POST", "/api/v1/alumni", validAlumnus, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusCreated, wCreateAl.Code)
|
||||||
|
|
||||||
|
var alResp APIResponse[models.Alumnus]
|
||||||
|
require.NoError(t, json.Unmarshal(wCreateAl.Body.Bytes(), &alResp))
|
||||||
|
alID := alResp.Data.ID
|
||||||
|
|
||||||
|
// 5. Delete Alumnus -> 204
|
||||||
|
wDelAl := doRequest(r, "DELETE", "/api/v1/alumni/"+strconvItoa(alID), nil, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusNoContent, wDelAl.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCascadeDeletes(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
r := router.SetupRouter(db, testAdminToken)
|
||||||
|
|
||||||
|
// 1. Create a semester
|
||||||
|
newSem := models.Semester{Year: 2030, Term: "Spring"}
|
||||||
|
wSem := doRequest(r, "POST", "/api/v1/semesters", newSem, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusCreated, wSem.Code)
|
||||||
|
var semResp APIResponse[models.Semester]
|
||||||
|
require.NoError(t, json.Unmarshal(wSem.Body.Bytes(), &semResp))
|
||||||
|
semID := semResp.Data.ID
|
||||||
|
|
||||||
|
// 2. Create courses under this semester
|
||||||
|
course1 := models.Course{Code: "TEST101", NameEn: "Intro to Testing"}
|
||||||
|
course2 := models.Course{Code: "TEST102", NameEn: "Advanced Testing"}
|
||||||
|
wCourse1 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course1, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusCreated, wCourse1.Code)
|
||||||
|
wCourse2 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course2, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusCreated, wCourse2.Code)
|
||||||
|
|
||||||
|
// Verify courses exist in DB
|
||||||
|
var courseCount int
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "ok", resp.Data["status"])
|
assert.Equal(t, 2, courseCount)
|
||||||
assert.Equal(t, "anl-backend-api", resp.Data["service"])
|
|
||||||
|
// 3. Delete semester
|
||||||
|
wDelSem := doRequest(r, "DELETE", "/api/v1/semesters/"+strconvItoa(semID), nil, testAdminToken)
|
||||||
|
assert.Equal(t, http.StatusNoContent, wDelSem.Code)
|
||||||
|
|
||||||
|
// 4. Verify cascade delete removed courses
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResearchProjects(t *testing.T) {
|
func strconvItoa(i int) string {
|
||||||
database := setupTestDB(t)
|
return strconv.Itoa(i)
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
w := executeRequest(r, "GET", "/api/v1/research-projects")
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
var resp APIResponse[[]models.ResearchProject]
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
assert.Len(t, resp.Data, 3)
|
|
||||||
assert.Equal(t, "iot-standards", resp.Data[0].Slug)
|
|
||||||
assert.Equal(t, "AIoT & AI-RAN", resp.Data[0].Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResearchAreas(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
w := executeRequest(r, "GET", "/api/v1/research-areas")
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
var resp APIResponse[[]models.ResearchArea]
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
assert.Len(t, resp.Data, 14)
|
|
||||||
assert.Equal(t, 1, resp.Data[0].DisplayOrder)
|
|
||||||
assert.Equal(t, "Quick UDP Internet Connection (QUIC)", resp.Data[0].NameEn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatsSummary(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
w := executeRequest(r, "GET", "/api/v1/stats/summary")
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
var resp APIResponse[models.StatsSummary]
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
assert.Equal(t, 68, resp.Data.IntlPublications)
|
|
||||||
assert.Equal(t, 44, resp.Data.StandardizationDocs)
|
|
||||||
assert.Equal(t, 75, resp.Data.Patents)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMembersAndAlumni(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
|
|
||||||
// Members
|
|
||||||
wMem := executeRequest(r, "GET", "/api/v1/members")
|
|
||||||
assert.Equal(t, http.StatusOK, wMem.Code)
|
|
||||||
var respMem APIResponse[[]models.Member]
|
|
||||||
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &respMem))
|
|
||||||
assert.Len(t, respMem.Data, 16)
|
|
||||||
assert.True(t, respMem.Data[0].IsAdvisor)
|
|
||||||
assert.Equal(t, "Seok-Joo Koh", respMem.Data[0].NameEn)
|
|
||||||
|
|
||||||
// Alumni
|
|
||||||
wAlumni := executeRequest(r, "GET", "/api/v1/alumni")
|
|
||||||
assert.Equal(t, http.StatusOK, wAlumni.Code)
|
|
||||||
var respAlumni APIResponse[[]models.Alumnus]
|
|
||||||
require.NoError(t, json.Unmarshal(wAlumni.Body.Bytes(), &respAlumni))
|
|
||||||
assert.Len(t, respAlumni.Data, 60)
|
|
||||||
assert.GreaterOrEqual(t, respAlumni.Data[0].GraduatedAt, respAlumni.Data[1].GraduatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPublicationsAndHighlights(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
|
|
||||||
// Full Publications
|
|
||||||
wAll := executeRequest(r, "GET", "/api/v1/publications")
|
|
||||||
assert.Equal(t, http.StatusOK, wAll.Code)
|
|
||||||
var respAll APIResponse[[]models.Publication]
|
|
||||||
require.NoError(t, json.Unmarshal(wAll.Body.Bytes(), &respAll))
|
|
||||||
assert.Len(t, respAll.Data, 148)
|
|
||||||
|
|
||||||
// Category filter: intl
|
|
||||||
wIntl := executeRequest(r, "GET", "/api/v1/publications?category=intl-journal-conf")
|
|
||||||
assert.Equal(t, http.StatusOK, wIntl.Code)
|
|
||||||
var respIntl APIResponse[[]models.Publication]
|
|
||||||
require.NoError(t, json.Unmarshal(wIntl.Body.Bytes(), &respIntl))
|
|
||||||
assert.Len(t, respIntl.Data, 68)
|
|
||||||
|
|
||||||
// Category filter: domestic
|
|
||||||
wDom := executeRequest(r, "GET", "/api/v1/publications?category=domestic-journal-conf")
|
|
||||||
assert.Equal(t, http.StatusOK, wDom.Code)
|
|
||||||
var respDom APIResponse[[]models.Publication]
|
|
||||||
require.NoError(t, json.Unmarshal(wDom.Body.Bytes(), &respDom))
|
|
||||||
assert.Len(t, respDom.Data, 80)
|
|
||||||
|
|
||||||
// Invalid category
|
|
||||||
wInvalid := executeRequest(r, "GET", "/api/v1/publications?category=unknown")
|
|
||||||
assert.Equal(t, http.StatusBadRequest, wInvalid.Code)
|
|
||||||
|
|
||||||
// Highlights (asserts exactly the 5 known representative titles)
|
|
||||||
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
|
|
||||||
assert.Equal(t, http.StatusOK, wHL.Code)
|
|
||||||
var respHL APIResponse[[]models.Publication]
|
|
||||||
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &respHL))
|
|
||||||
assert.Len(t, respHL.Data, 5)
|
|
||||||
|
|
||||||
expectedTitles := map[string]bool{
|
|
||||||
"Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G": true,
|
|
||||||
"Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G": true,
|
|
||||||
"mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks": true,
|
|
||||||
"Use of QUIC for CoAP Transport in IoT Networks": true,
|
|
||||||
"Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)": true,
|
|
||||||
}
|
|
||||||
for _, hl := range respHL.Data {
|
|
||||||
assert.True(t, hl.IsHighlight)
|
|
||||||
assert.True(t, expectedTitles[hl.Title], "Unexpected highlight title: %s", hl.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patents
|
|
||||||
wPat := executeRequest(r, "GET", "/api/v1/patents")
|
|
||||||
assert.Equal(t, http.StatusOK, wPat.Code)
|
|
||||||
var respPat APIResponse[[]models.Patent]
|
|
||||||
require.NoError(t, json.Unmarshal(wPat.Body.Bytes(), &respPat))
|
|
||||||
assert.Len(t, respPat.Data, 75)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSemestersAndCourses(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
|
|
||||||
w := executeRequest(r, "GET", "/api/v1/semesters")
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
var resp APIResponse[[]models.SemesterWithCourses]
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
assert.Len(t, resp.Data, 45)
|
|
||||||
|
|
||||||
// Check semester ordering (Year DESC, Fall before Spring within same year)
|
|
||||||
first := resp.Data[0]
|
|
||||||
assert.Equal(t, 2026, first.Year)
|
|
||||||
assert.Equal(t, "Spring", first.Term)
|
|
||||||
assert.NotEmpty(t, first.Courses)
|
|
||||||
|
|
||||||
// In 2025: Fall must come before Spring
|
|
||||||
var s2025Fall, s2025Spring int
|
|
||||||
for idx, s := range resp.Data {
|
|
||||||
if s.Year == 2025 && s.Term == "Fall" {
|
|
||||||
s2025Fall = idx
|
|
||||||
}
|
|
||||||
if s.Year == 2025 && s.Term == "Spring" {
|
|
||||||
s2025Spring = idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Less(t, s2025Fall, s2025Spring, "2025 Fall should precede 2025 Spring in chronological descending order")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStandardsBodiesAndHierarchy(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
|
|
||||||
w := executeRequest(r, "GET", "/api/v1/standards-bodies")
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
var resp APIResponse[[]models.StandardsBodyWithProjects]
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
||||||
assert.Len(t, resp.Data, 6)
|
|
||||||
|
|
||||||
// First body: IEC TC100
|
|
||||||
iec := resp.Data[0]
|
|
||||||
assert.Equal(t, "IEC TC100", iec.Org)
|
|
||||||
require.NotEmpty(t, iec.Projects)
|
|
||||||
|
|
||||||
// Verify project ordering within IEC TC100 (deterministic order: WG12 before WG11(Convenor))
|
|
||||||
assert.Equal(t, "WG12", iec.Projects[0].WG)
|
|
||||||
assert.Equal(t, "WG11(Convenor)", iec.Projects[1].WG)
|
|
||||||
assert.NotEmpty(t, iec.Projects[0].Documents)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONKeyCasingSnakeCase(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
r := router.SetupRouter(database)
|
|
||||||
|
|
||||||
// Test Members endpoint raw JSON map
|
|
||||||
wMem := executeRequest(r, "GET", "/api/v1/members")
|
|
||||||
assert.Equal(t, http.StatusOK, wMem.Code)
|
|
||||||
var rawMem map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &rawMem))
|
|
||||||
dataList, ok := rawMem["data"].([]any)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.NotEmpty(t, dataList)
|
|
||||||
firstMember, ok := dataList[0].(map[string]any)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
// Verify snake_case keys exist and camelCase keys do not
|
|
||||||
assert.Contains(t, firstMember, "name_ko")
|
|
||||||
assert.Contains(t, firstMember, "name_en")
|
|
||||||
assert.Contains(t, firstMember, "is_advisor")
|
|
||||||
assert.Contains(t, firstMember, "display_order")
|
|
||||||
assert.NotContains(t, firstMember, "nameKo")
|
|
||||||
assert.NotContains(t, firstMember, "isAdvisor")
|
|
||||||
|
|
||||||
// Test Highlights endpoint raw JSON map
|
|
||||||
wHL := executeRequest(r, "GET", "/api/v1/publications/highlights")
|
|
||||||
assert.Equal(t, http.StatusOK, wHL.Code)
|
|
||||||
var rawHL map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &rawHL))
|
|
||||||
hlList, ok := rawHL["data"].([]any)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.NotEmpty(t, hlList)
|
|
||||||
firstHL, ok := hlList[0].(map[string]any)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
assert.Contains(t, firstHL, "is_highlight")
|
|
||||||
assert.Contains(t, firstHL, "published_at")
|
|
||||||
assert.NotContains(t, firstHL, "isHighlight")
|
|
||||||
assert.NotContains(t, firstHL, "publishedAt")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
← 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 "+ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>→</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 "+ Add Standards Body" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -96,25 +96,33 @@ def test_build():
|
|||||||
|
|
||||||
print(f"Parsed route modes from build table ({len(route_table_map)} routes): {route_table_map}")
|
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:
|
for d_route in EXPECTED_DYNAMIC_ROUTES:
|
||||||
mode = route_table_map.get(d_route)
|
mode = public_route_table.get(d_route)
|
||||||
if mode != "ƒ":
|
if mode != "ƒ":
|
||||||
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
|
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check exact match for each static route
|
# Check exact match for each static public route
|
||||||
for s_route in EXPECTED_STATIC_ROUTES:
|
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 ("○", "●"):
|
if mode not in ("○", "●"):
|
||||||
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '○' or '●'")
|
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '○' or '●'")
|
||||||
return False
|
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)
|
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
|
||||||
found_routes = set(route_table_map.keys())
|
found_public = set(public_route_table.keys())
|
||||||
if all_expected != found_routes:
|
if all_expected != found_public:
|
||||||
print(f"❌ Gate 2 Violation: Route set mismatch. Missing: {all_expected - found_routes}, Extra: {found_routes - all_expected}")
|
print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes with exact table matching)")
|
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))
|
s.bind(('', 0))
|
||||||
return s.getsockname()[1]
|
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):
|
def start_next_server(port=3000):
|
||||||
print(f"\nStarting live Next.js server on port {port}...")
|
print(f"\nStarting live Next.js server on port {port}...")
|
||||||
server_env = os.environ.copy()
|
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_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]:
|
||||||
for root, _, files in os.walk(root_dir):
|
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:
|
for fname in files:
|
||||||
if fname.endswith((".ts", ".tsx")):
|
if fname.endswith((".ts", ".tsx")):
|
||||||
fpath = os.path.join(root, fname)
|
fpath = os.path.join(root, fname)
|
||||||
@@ -452,10 +505,12 @@ def main():
|
|||||||
print("\n❌ Build or static gates failed before live server execution.")
|
print("\n❌ Build or static gates failed before live server execution.")
|
||||||
sys.exit(1)
|
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()
|
port = get_free_port()
|
||||||
|
backend_proc = None
|
||||||
server_proc = None
|
server_proc = None
|
||||||
try:
|
try:
|
||||||
|
backend_proc = ensure_backend_server()
|
||||||
server_proc = start_next_server(port=port)
|
server_proc = start_next_server(port=port)
|
||||||
bodies = fetch_live_routes(f"http://localhost:{port}")
|
bodies = fetch_live_routes(f"http://localhost:{port}")
|
||||||
|
|
||||||
@@ -470,6 +525,13 @@ def main():
|
|||||||
server_proc.wait(timeout=5)
|
server_proc.wait(timeout=5)
|
||||||
except Exception:
|
except Exception:
|
||||||
server_proc.kill()
|
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)
|
print("\n" + "-" * 70)
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
Reference in New Issue
Block a user