From 53a5713ddd21b90a378adca5059f5debc89b7d27 Mon Sep 17 00:00:00 2001 From: wallace Date: Wed, 26 Nov 2025 21:40:56 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=9E=91=EC=97=85=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=A4=911?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/README.md | 162 --------- prisma/schema.prisma | 313 ------------------ prisma/seed.ts | 205 ------------ src/app/NavBar.tsx | 63 +++- .../admin/courses/CourseRegistrationModal.tsx | 25 +- src/app/admin/courses/mockData.ts | 37 ++- src/app/admin/id/mockData.ts | 151 ++++++--- src/app/admin/id/page.tsx | 251 +++++++++++--- src/app/admin/page.tsx | 6 + src/app/login/page.tsx | 56 +++- src/app/menu/ChangePasswordModal.tsx | 12 +- src/app/menu/account/page.tsx | 22 +- 12 files changed, 494 insertions(+), 809 deletions(-) delete mode 100644 prisma/README.md delete mode 100644 prisma/schema.prisma delete mode 100644 prisma/seed.ts create mode 100644 src/app/admin/page.tsx diff --git a/prisma/README.md b/prisma/README.md deleted file mode 100644 index f7927c5..0000000 --- a/prisma/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# Prisma 데이터베이스 관리 가이드 - -## 📋 개요 - -이 문서는 Prisma를 사용한 데이터베이스 스키마 관리 및 마이그레이션 워크플로우를 설명합니다. - -## 🚀 개발 환경 워크플로우 - -### 1. 스키마 변경 후 데이터베이스 최신화 - -#### 방법 A: 마이그레이션 생성 및 적용 (권장) -```bash -# 1. 스키마 변경 (prisma/schema.prisma 수정) - -# 2. 마이그레이션 생성 및 적용 -npm run db:migrate - -# 마이그레이션 이름을 지정하려면: -npx prisma migrate dev --name add_new_field -``` - -이 명령어는: -- 마이그레이션 파일 생성 -- 데이터베이스에 마이그레이션 적용 -- Prisma Client 자동 재생성 - -#### 방법 B: 개발 중 빠른 프로토타이핑 (데이터 손실 가능) -```bash -# 스키마 변경 후 즉시 적용 (마이그레이션 파일 생성 안 함) -npm run db:push -``` - -⚠️ **주의**: `db:push`는 프로덕션 환경에서는 사용하지 마세요! - -### 2. Seed 데이터 재실행 - -```bash -# Seed 데이터 삽입 -npm run db:seed - -# 또는 마이그레이션 리셋 후 자동으로 seed 실행 -npm run db:migrate:reset -``` - -### 3. 전체 초기화 (개발 환경 전용) - -```bash -# 데이터베이스 완전히 리셋 + 마이그레이션 재적용 + seed 실행 -npm run db:migrate:reset -``` - -⚠️ **주의**: 모든 데이터가 삭제됩니다! - -## 📦 프로덕션 환경 워크플로우 - -### 1. 마이그레이션 배포 - -```bash -# 생성된 마이그레이션 파일들을 프로덕션 DB에 적용 -npm run db:migrate:deploy -``` - -이 명령어는: -- 마이그레이션 파일만 적용 (새로운 마이그레이션 생성 안 함) -- 안전하게 프로덕션에 적용 가능 - -### 2. Prisma Client 재생성 - -```bash -npm run db:generate -``` - -## 🔄 일반적인 워크플로우 시나리오 - -### 시나리오 1: 새로운 필드 추가 - -```bash -# 1. schema.prisma에 필드 추가 -# 예: model User { ... newField String? } - -# 2. 마이그레이션 생성 및 적용 -npm run db:migrate --name add_new_field - -# 3. (선택) Seed 데이터 업데이트 필요 시 -npm run db:seed -``` - -### 시나리오 2: 관계 추가/변경 - -```bash -# 1. schema.prisma에 관계 추가 -# 2. 마이그레이션 생성 -npm run db:migrate --name add_relation - -# 3. 기존 데이터 마이그레이션 필요 시 수동으로 처리 -``` - -### 시나리오 3: 개발 중 스키마 실험 - -```bash -# 빠르게 스키마 변경 테스트 (마이그레이션 파일 생성 안 함) -npm run db:push - -# 만족스러우면 마이그레이션 생성 -npm run db:migrate --name experimental_changes -``` - -## 🛠️ 유용한 명령어 - -### Prisma Studio (데이터베이스 GUI) - -```bash -npm run db:studio -``` - -브라우저에서 데이터베이스를 시각적으로 확인하고 편집할 수 있습니다. - -### 기존 데이터베이스에서 스키마 가져오기 - -```bash -npm run db:pull -``` - -기존 데이터베이스 구조를 분석하여 `schema.prisma`를 생성합니다. - -## 📝 마이그레이션 파일 관리 - -- 마이그레이션 파일은 `prisma/migrations/` 폴더에 저장됩니다 -- 각 마이그레이션은 타임스탬프와 이름으로 식별됩니다 -- 마이그레이션 파일은 Git에 커밋해야 합니다 -- 팀원들과 마이그레이션을 공유하여 동일한 스키마를 유지합니다 - -## ⚠️ 주의사항 - -1. **프로덕션 환경**에서는 절대 `db:push`나 `db:migrate:reset`을 사용하지 마세요 -2. **마이그레이션 파일**은 항상 Git에 커밋하세요 -3. **스키마 변경 전**에 백업을 권장합니다 -4. **Seed 데이터**는 개발 환경에서만 사용하세요 - -## 🔍 문제 해결 - -### 마이그레이션 충돌 시 - -```bash -# 마이그레이션 상태 확인 -npx prisma migrate status - -# 문제 해결 후 -npm run db:migrate:resolve -``` - -### Prisma Client가 최신이 아닐 때 - -```bash -npm run db:generate -``` - -## 📚 추가 리소스 - -- [Prisma 마이그레이션 가이드](https://www.prisma.io/docs/concepts/components/prisma-migrate) -- [Prisma CLI 참조](https://www.prisma.io/docs/reference/api-reference/command-reference) - diff --git a/prisma/schema.prisma b/prisma/schema.prisma deleted file mode 100644 index 2728af9..0000000 --- a/prisma/schema.prisma +++ /dev/null @@ -1,313 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -// ============================================ -// 사용자 관련 모델 -// ============================================ - -/// 사용자 (User) -/// 권한 설정 페이지, 로그인/회원가입에서 사용 -model User { - id String @id @default(uuid()) - email String @unique // 이메일 (아이디로 사용) - password String // 비밀번호 (해시화되어 저장) - name String // 성명 - phone String? // 휴대폰 번호 - gender String? // 성별 (M/F) - birthdate DateTime? // 생년월일 - role UserRole @default(LEARNER) // 권한: 학습자, 관리자 - status UserStatus @default(ACTIVE) // 계정 상태: 활성화, 비활성화 - joinDate DateTime @default(now()) // 가입일 - - // 관계 - createdCourses Course[] @relation("CourseCreator") - instructedCourses Course[] @relation("CourseInstructor") - createdLessons Lesson[] @relation("LessonCreator") - createdNotices Notice[] @relation("NoticeWriter") - enrollments Enrollment[] // 수강 등록 - certificates Certificate[] // 수료증 - logs Log[] // 로그 기록 - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([email]) - @@index([role]) - @@index([status]) - @@map("users") -} - -enum UserRole { - LEARNER // 학습자 - ADMIN // 관리자 (강사 권한 포함) -} - -enum UserStatus { - ACTIVE // 활성화 - INACTIVE // 비활성화 -} - -// ============================================ -// 교육과정 관련 모델 -// ============================================ - -/// 교육과정 (Course) -/// 교육과정 관리 페이지에서 사용 -model Course { - id String @id @default(uuid()) - courseName String // 교육과정명 - instructorId String // 강사 ID (User의 ADMIN 역할) - instructor User @relation("CourseInstructor", fields: [instructorId], references: [id]) - createdById String // 등록자 ID - createdBy User @relation("CourseCreator", fields: [createdById], references: [id]) - createdAt DateTime @default(now()) // 생성일 - - // 관계 - lessons Lesson[] // 강좌 목록 - - updatedAt DateTime @updatedAt - - @@index([instructorId]) - @@index([createdById]) - @@index([createdAt]) - @@map("courses") -} - -/// 강좌 (Lesson) -/// 강좌 관리 페이지에서 사용 -model Lesson { - id String @id @default(uuid()) - courseId String // 교육과정 ID - course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - lessonName String // 강좌명 - learningGoal String? @db.Text // 학습 목표 (최대 1000자) - createdById String // 등록자 ID - createdBy User @relation("LessonCreator", fields: [createdById], references: [id]) - createdAt DateTime @default(now()) // 등록일 - - // 관계 - videos LessonVideo[] // 강좌 영상 - vrContents LessonVRContent[] // VR 콘텐츠 - questions Question[] // 학습 평가 문제 - attachments LessonAttachment[] // 첨부파일 - enrollments Enrollment[] // 수강 등록 - - updatedAt DateTime @updatedAt - - @@index([courseId]) - @@index([createdById]) - @@index([createdAt]) - @@map("lessons") -} - -/// 강좌 영상 (LessonVideo) -/// 강좌 등록 시 첨부되는 영상 파일 (최대 10개, 30MB 미만) -model LessonVideo { - id String @id @default(uuid()) - lessonId String // 강좌 ID - lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) - fileName String // 파일명 - filePath String // 파일 저장 경로 - fileSize Int // 파일 크기 (bytes) - order Int // 순서 - createdAt DateTime @default(now()) - - @@index([lessonId]) - @@map("lesson_videos") -} - -/// VR 콘텐츠 (LessonVRContent) -/// 강좌 등록 시 첨부되는 VR 콘텐츠 파일 (최대 10개, 30MB 미만) -model LessonVRContent { - id String @id @default(uuid()) - lessonId String // 강좌 ID - lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) - fileName String // 파일명 - filePath String // 파일 저장 경로 - fileSize Int // 파일 크기 (bytes) - order Int // 순서 - createdAt DateTime @default(now()) - - @@index([lessonId]) - @@map("lesson_vr_contents") -} - -/// 강좌 첨부파일 (LessonAttachment) -/// 강좌 관련 기타 첨부파일 -model LessonAttachment { - id String @id @default(uuid()) - lessonId String // 강좌 ID - lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) - fileName String // 파일명 - filePath String // 파일 저장 경로 - fileSize Int // 파일 크기 (bytes) - createdAt DateTime @default(now()) - - @@index([lessonId]) - @@map("lesson_attachments") -} - -// ============================================ -// 학습 평가 관련 모델 -// ============================================ - -/// 문제 (Question) -/// 문제 은행 페이지에서 사용, 강좌별 학습 평가 문제 -model Question { - id String @id @default(uuid()) - lessonId String? // 강좌 ID (선택적, 문제 은행에만 있을 수도 있음) - lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull) - question String @db.Text // 문제 내용 - type QuestionType @default(MULTIPLE_CHOICE) // 문제 유형 - options Json? // 선택지 (객관식인 경우) - correctAnswer String @db.Text // 정답 - explanation String? @db.Text // 해설 - points Int @default(1) // 배점 - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([lessonId]) - @@map("questions") -} - -enum QuestionType { - MULTIPLE_CHOICE // 객관식 - SHORT_ANSWER // 단답형 - ESSAY // 서술형 -} - -// ============================================ -// 공지사항 관련 모델 -// ============================================ - -/// 공지사항 (Notice) -/// 공지사항 관리 페이지에서 사용 -model Notice { - id String @id @default(uuid()) - title String // 제목 - content String @db.Text // 내용 (최대 1000자) - writerId String // 작성자 ID - writer User @relation("NoticeWriter", fields: [writerId], references: [id]) - views Int @default(0) // 조회수 - hasAttachment Boolean @default(false) // 첨부파일 여부 - date DateTime @default(now()) // 게시일 - - // 관계 - attachment NoticeAttachment? // 첨부파일 - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([writerId]) - @@index([date]) - @@map("notices") -} - -/// 공지사항 첨부파일 (NoticeAttachment) -/// 공지사항에 첨부되는 파일 (최대 1개, 30MB 미만) -model NoticeAttachment { - id String @id @default(uuid()) - noticeId String @unique // 공지사항 ID - notice Notice @relation(fields: [noticeId], references: [id], onDelete: Cascade) - fileName String // 파일명 - filePath String // 파일 저장 경로 - fileSize Int // 파일 크기 (bytes) - createdAt DateTime @default(now()) - - @@map("notice_attachments") -} - -// ============================================ -// 학습 자료 관련 모델 -// ============================================ - -/// 학습 자료 (Resource) -/// 학습 자료실 페이지에서 사용 -model Resource { - id String @id @default(uuid()) - title String // 제목 - description String? @db.Text // 설명 - filePath String? // 파일 경로 (파일이 있는 경우) - fileName String? // 파일명 - fileSize Int? // 파일 크기 (bytes) - category String? // 카테고리 - views Int @default(0) // 조회수 - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([category]) - @@index([createdAt]) - @@map("resources") -} - -// ============================================ -// 수강 및 수료 관련 모델 -// ============================================ - -/// 수강 등록 (Enrollment) -/// 사용자가 강좌를 수강하는 관계 -model Enrollment { - id String @id @default(uuid()) - userId String // 사용자 ID - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - lessonId String // 강좌 ID - lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) - progress Int @default(0) // 학습 진행률 (0-100) - completedAt DateTime? // 완료일 - enrolledAt DateTime @default(now()) // 등록일 - - @@unique([userId, lessonId]) - @@index([userId]) - @@index([lessonId]) - @@map("enrollments") -} - -/// 수료증 (Certificate) -/// 수료증 발급/검증키 관리 페이지에서 사용 -model Certificate { - id String @id @default(uuid()) - userId String // 사용자 ID - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - lessonId String? // 강좌 ID (강좌 완료 시 발급) - courseId String? // 교육과정 ID (과정 완료 시 발급) - verificationKey String @unique // 검증 키 - issuedAt DateTime @default(now()) // 발급일 - - @@index([userId]) - @@index([verificationKey]) - @@map("certificates") -} - -// ============================================ -// 로그 관련 모델 -// ============================================ - -/// 로그 (Log) -/// 로그/접속 기록 페이지에서 사용 -model Log { - id String @id @default(uuid()) - userId String? // 사용자 ID (로그인한 경우) - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - action String // 액션 (예: LOGIN, LOGOUT, VIEW_LESSON, etc.) - ipAddress String? // IP 주소 - userAgent String? // User Agent - details Json? // 추가 상세 정보 - createdAt DateTime @default(now()) // 기록 시간 - - @@index([userId]) - @@index([action]) - @@index([createdAt]) - @@map("logs") -} diff --git a/prisma/seed.ts b/prisma/seed.ts deleted file mode 100644 index 68c8a26..0000000 --- a/prisma/seed.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { PrismaClient, UserRole, UserStatus } from '@prisma/client'; - -const prisma = new PrismaClient(); - -// 간단한 비밀번호 해시 함수 (실제 프로덕션에서는 bcrypt 사용 권장) -function hashPassword(password: string): string { - // 개발 환경용 간단한 해시 (실제로는 bcrypt 사용) - // 모든 사용자의 기본 비밀번호는 "password123"으로 설정 - return '$2a$10$placeholder_hash_for_development_only'; -} - -async function main() { - console.log('🌱 Seeding database...'); - - // 1. 사용자 데이터 생성 - const mockUsers = [ - { id: "1", joinDate: "2024-01-15", name: "김민준", email: "user1@example.com", role: "learner", status: "active" }, - { id: "2", joinDate: "2024-01-20", name: "이서준", email: "user2@example.com", role: "learner", status: "active" }, - { id: "3", joinDate: "2024-02-05", name: "박도윤", email: "user3@example.com", role: "learner", status: "inactive" }, - { id: "4", joinDate: "2024-02-10", name: "최예준", email: "user4@example.com", role: "instructor", status: "active" }, - { id: "5", joinDate: "2024-02-15", name: "정시우", email: "user5@example.com", role: "instructor", status: "active" }, - { id: "6", joinDate: "2024-02-20", name: "강하준", email: "user6@example.com", role: "learner", status: "active" }, - { id: "7", joinDate: "2024-03-01", name: "조주원", email: "user7@example.com", role: "admin", status: "active" }, - { id: "8", joinDate: "2024-03-05", name: "윤지호", email: "user8@example.com", role: "learner", status: "active" }, - { id: "9", joinDate: "2024-03-10", name: "장준서", email: "user9@example.com", role: "learner", status: "inactive" }, - { id: "10", joinDate: "2024-03-15", name: "임건우", email: "user10@example.com", role: "instructor", status: "active" }, - { id: "11", joinDate: "2024-03-20", name: "한서연", email: "user11@example.com", role: "learner", status: "active" }, - { id: "12", joinDate: "2024-04-01", name: "오서윤", email: "user12@example.com", role: "learner", status: "active" }, - { id: "13", joinDate: "2024-04-05", name: "서지우", email: "user13@example.com", role: "instructor", status: "inactive" }, - { id: "14", joinDate: "2024-04-10", name: "신서현", email: "user14@example.com", role: "learner", status: "active" }, - { id: "15", joinDate: "2024-04-15", name: "권민서", email: "user15@example.com", role: "admin", status: "active" }, - { id: "16", joinDate: "2024-04-20", name: "황하은", email: "user16@example.com", role: "learner", status: "active" }, - { id: "17", joinDate: "2024-05-01", name: "안예은", email: "user17@example.com", role: "learner", status: "inactive" }, - { id: "18", joinDate: "2024-05-05", name: "송윤서", email: "user18@example.com", role: "instructor", status: "active" }, - { id: "19", joinDate: "2024-05-10", name: "전채원", email: "user19@example.com", role: "learner", status: "active" }, - { id: "20", joinDate: "2024-05-15", name: "홍지원", email: "user20@example.com", role: "learner", status: "active" }, - { id: "21", joinDate: "2024-05-20", name: "김민수", email: "user21@example.com", role: "instructor", status: "active" }, - { id: "22", joinDate: "2024-06-01", name: "이영희", email: "user22@example.com", role: "learner", status: "active" }, - { id: "23", joinDate: "2024-06-05", name: "박철수", email: "user23@example.com", role: "learner", status: "inactive" }, - { id: "24", joinDate: "2024-06-10", name: "최수진", email: "user24@example.com", role: "admin", status: "active" }, - { id: "25", joinDate: "2024-06-15", name: "정대현", email: "user25@example.com", role: "instructor", status: "active" }, - { id: "26", joinDate: "2024-06-20", name: "강미영", email: "user26@example.com", role: "learner", status: "active" }, - { id: "27", joinDate: "2024-07-01", name: "조성호", email: "user27@example.com", role: "learner", status: "active" }, - { id: "28", joinDate: "2024-07-05", name: "윤지은", email: "user28@example.com", role: "instructor", status: "inactive" }, - { id: "29", joinDate: "2024-07-10", name: "장현우", email: "user29@example.com", role: "learner", status: "active" }, - { id: "30", joinDate: "2024-07-15", name: "임소영", email: "user30@example.com", role: "learner", status: "active" }, - ]; - - // 사용자 생성 및 ID 매핑 저장 - const userMap = new Map(); - - for (const userData of mockUsers) { - const role = userData.role === 'instructor' ? UserRole.ADMIN : - userData.role === 'admin' ? UserRole.ADMIN : - UserRole.LEARNER; - const status = userData.status === 'active' ? UserStatus.ACTIVE : UserStatus.INACTIVE; - - const user = await prisma.user.upsert({ - where: { email: userData.email }, - update: {}, - create: { - email: userData.email, - password: hashPassword('password123'), // 기본 비밀번호 - name: userData.name, - role, - status, - joinDate: new Date(userData.joinDate), - }, - }); - - userMap.set(userData.id, user.id); - } - - console.log(`✅ Created ${userMap.size} users`); - - // 관리자 계정 찾기 (공지사항 작성자용) - const adminUsers = await prisma.user.findMany({ - where: { role: UserRole.ADMIN, status: UserStatus.ACTIVE }, - }); - const defaultAdmin = adminUsers[0]; - - // 2. 교육과정 데이터 생성 - const mockCourses = [ - { id: "1", courseName: "웹 개발 기초", instructorName: "최예준", createdAt: "2024-01-15", createdBy: "관리자" }, - { id: "2", courseName: "React 실전 프로젝트", instructorName: "정시우", createdAt: "2024-02-20", createdBy: "관리자" }, - { id: "3", courseName: "데이터베이스 설계", instructorName: "임건우", createdAt: "2024-03-10", createdBy: "관리자" }, - { id: "4", courseName: "Node.js 백엔드 개발", instructorName: "송윤서", createdAt: "2024-03-25", createdBy: "관리자" }, - { id: "5", courseName: "TypeScript 마스터", instructorName: "김민수", createdAt: "2024-04-05", createdBy: "관리자" }, - { id: "6", courseName: "UI/UX 디자인 기초", instructorName: "정대현", createdAt: "2024-04-18", createdBy: "관리자" }, - { id: "7", courseName: "모바일 앱 개발", instructorName: "최예준", createdAt: "2024-05-02", createdBy: "관리자" }, - { id: "8", courseName: "클라우드 인프라", instructorName: "정시우", createdAt: "2024-05-15", createdBy: "관리자" }, - { id: "9", courseName: "머신러닝 입문", instructorName: "임건우", createdAt: "2024-06-01", createdBy: "관리자" }, - { id: "10", courseName: "DevOps 실무", instructorName: "송윤서", createdAt: "2024-06-20", createdBy: "관리자" }, - ]; - - const courseMap = new Map(); - - for (const courseData of mockCourses) { - // 강사 이름으로 사용자 찾기 - const instructor = await prisma.user.findFirst({ - where: { name: courseData.instructorName, role: UserRole.ADMIN }, - }); - - if (!instructor) { - console.warn(`⚠️ Instructor not found: ${courseData.instructorName}`); - continue; - } - - const course = await prisma.course.create({ - data: { - courseName: courseData.courseName, - instructorId: instructor.id, - createdById: defaultAdmin?.id || instructor.id, - createdAt: new Date(courseData.createdAt), - }, - }); - - courseMap.set(courseData.id, course.id); - } - - console.log(`✅ Created ${courseMap.size} courses`); - - // 3. 공지사항 데이터 생성 - const mockNotices = [ - { - id: 2, - title: '공지사항 제목이 노출돼요', - date: '2025-09-10', - views: 1230, - writer: '문지호', - content: [ - '사이트 이용 관련 주요 변경 사항을 안내드립니다.', - '변경되는 내용은 공지일자로부터 즉시 적용됩니다.', - ], - }, - { - id: 1, - title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지', - date: '2025-06-28', - views: 594, - writer: '문지호', - hasAttachment: true, - content: [ - '온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.', - '수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.', - ], - }, - ]; - - // 공지사항 작성자 찾기 또는 생성 - let noticeWriter = await prisma.user.findFirst({ - where: { name: '문지호' }, - }); - - if (!noticeWriter) { - noticeWriter = await prisma.user.create({ - data: { - email: 'munjih@example.com', - password: hashPassword('password123'), - name: '문지호', - role: UserRole.ADMIN, - status: UserStatus.ACTIVE, - }, - }); - } - - for (const noticeData of mockNotices) { - const notice = await prisma.notice.create({ - data: { - title: noticeData.title, - content: noticeData.content?.join('\n') || '', - writerId: noticeWriter.id, - views: noticeData.views, - hasAttachment: noticeData.hasAttachment || false, - date: new Date(noticeData.date), - }, - }); - - // 첨부파일이 있는 경우 - if (noticeData.hasAttachment) { - await prisma.noticeAttachment.create({ - data: { - noticeId: notice.id, - fileName: '공지사항_첨부파일.pdf', - filePath: '/uploads/notices/sample.pdf', - fileSize: 1024000, // 1MB - }, - }); - } - } - - console.log(`✅ Created ${mockNotices.length} notices`); - - console.log('🎉 Seeding completed!'); -} - -main() - .catch((e) => { - console.error('❌ Error seeding database:', e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); - diff --git a/src/app/NavBar.tsx b/src/app/NavBar.tsx index 2351efe..1083d1f 100644 --- a/src/app/NavBar.tsx +++ b/src/app/NavBar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import MainLogoSvg from "./svgs/mainlogosvg"; import ChevronDownSvg from "./svgs/chevrondownsvg"; @@ -14,6 +14,7 @@ const NAV_ITEMS = [ export default function NavBar() { const pathname = usePathname(); + const router = useRouter(); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [userName, setUserName] = useState(''); const userMenuRef = useRef(null); @@ -21,18 +22,35 @@ export default function NavBar() { const hideCenterNav = /^\/[^/]+\/review$/.test(pathname); const isAdminPage = pathname.startsWith('/admin'); - // 사용자 정보 가져오기 + // 사용자 정보 가져오기 및 비활성화 계정 체크 useEffect(() => { let isMounted = true; async function fetchUserInfo() { try { - const token = localStorage.getItem('token'); + // localStorage와 쿠키 모두에서 토큰 확인 + const localStorageToken = localStorage.getItem('token'); + const cookieToken = document.cookie + .split('; ') + .find(row => row.startsWith('token=')) + ?.split('=')[1]; + + const token = localStorageToken || cookieToken; + if (!token) { return; } - const response = await fetch('https://hrdi.coconutmeet.net/auth/me', { + // localStorage에 토큰이 없고 쿠키에만 있으면 localStorage에도 저장 (동기화) + if (!localStorageToken && cookieToken) { + localStorage.setItem('token', cookieToken); + } + + const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL + ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/me` + : 'https://hrdi.coconutmeet.net/auth/me'; + + const response = await fetch(apiUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -44,11 +62,30 @@ export default function NavBar() { if (response.status === 401) { // 토큰이 만료되었거나 유효하지 않은 경우 localStorage.removeItem('token'); + document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + // 로그인 페이지가 아닐 때만 리다이렉트 + if (isMounted && pathname !== '/login') { + router.push('/login'); + } } return; } const data = await response.json(); + + // 계정 상태 확인 + const userStatus = data.status || data.userStatus; + if (userStatus === 'INACTIVE' || userStatus === 'inactive') { + // 비활성화된 계정인 경우 로그아웃 처리 + localStorage.removeItem('token'); + document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + // 로그인 페이지가 아닐 때만 리다이렉트 + if (isMounted && pathname !== '/login') { + router.push('/login'); + } + return; + } + if (isMounted && data.name) { setUserName(data.name); } @@ -62,7 +99,7 @@ export default function NavBar() { return () => { isMounted = false; }; - }, []); + }, [router, pathname]); useEffect(() => { if (!isUserMenuOpen) return; @@ -118,9 +155,21 @@ export default function NavBar() { 내 정보 - + ) : ( <> diff --git a/src/app/admin/courses/CourseRegistrationModal.tsx b/src/app/admin/courses/CourseRegistrationModal.tsx index 6bfc4a6..5c6daf3 100644 --- a/src/app/admin/courses/CourseRegistrationModal.tsx +++ b/src/app/admin/courses/CourseRegistrationModal.tsx @@ -24,10 +24,27 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet const modalRef = useRef(null); // 강사 목록 가져오기 - // TODO: 나중에 DB에서 가져오도록 변경 시 async/await 사용 - // 예: const [instructors, setInstructors] = useState([]); - // useEffect(() => { getInstructors().then(setInstructors); }, []); - const instructors = useMemo(() => getInstructors(), []); + const [instructors, setInstructors] = useState([]); + const [isLoadingInstructors, setIsLoadingInstructors] = useState(false); + + useEffect(() => { + async function loadInstructors() { + setIsLoadingInstructors(true); + try { + const data = await getInstructors(); + setInstructors(data); + } catch (error) { + console.error('강사 목록 로드 오류:', error); + setInstructors([]); + } finally { + setIsLoadingInstructors(false); + } + } + + if (open) { + loadInstructors(); + } + }, [open]); // 선택된 강사 정보 const selectedInstructor = useMemo(() => { diff --git a/src/app/admin/courses/mockData.ts b/src/app/admin/courses/mockData.ts index 5dc5131..433a698 100644 --- a/src/app/admin/courses/mockData.ts +++ b/src/app/admin/courses/mockData.ts @@ -1,5 +1,3 @@ -import { getInstructors } from "@/app/admin/id/mockData"; - export type Course = { id: string; courseName: string; @@ -12,17 +10,22 @@ export type Course = { // TODO: 나중에 DB에서 가져오도록 변경 export const MOCK_CURRENT_USER = "관리자"; // 현재 로그인한 사용자 이름 -// 강사 목록 가져오기 -const instructors = getInstructors(); -const instructorNames = instructors.map(instructor => instructor.name); - // TODO: 이 부분도 나중에는 db에서 받아오도록 변경 예정 -// 임시 데이터 - 강사명은 mockData.ts의 강사 데이터 활용 +// 임시 데이터 - 기본 강사명 목록 (getInstructors는 async이므로 모듈 레벨에서 사용 불가) +const defaultInstructorNames = [ + "최예준", + "정시우", + "임건우", + "송윤서", + "김민수", + "정대현", +]; + export const MOCK_COURSES: Course[] = [ { id: "1", courseName: "웹 개발 기초", - instructorName: instructorNames[0] || "최예준", + instructorName: defaultInstructorNames[0] || "최예준", createdAt: "2024-01-15", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -30,7 +33,7 @@ export const MOCK_COURSES: Course[] = [ { id: "2", courseName: "React 실전 프로젝트", - instructorName: instructorNames[1] || "정시우", + instructorName: defaultInstructorNames[1] || "정시우", createdAt: "2024-02-20", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -38,7 +41,7 @@ export const MOCK_COURSES: Course[] = [ { id: "3", courseName: "데이터베이스 설계", - instructorName: instructorNames[2] || "임건우", + instructorName: defaultInstructorNames[2] || "임건우", createdAt: "2024-03-10", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -46,7 +49,7 @@ export const MOCK_COURSES: Course[] = [ { id: "4", courseName: "Node.js 백엔드 개발", - instructorName: instructorNames[3] || "송윤서", + instructorName: defaultInstructorNames[3] || "송윤서", createdAt: "2024-03-25", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -54,7 +57,7 @@ export const MOCK_COURSES: Course[] = [ { id: "5", courseName: "TypeScript 마스터", - instructorName: instructorNames[4] || "김민수", + instructorName: defaultInstructorNames[4] || "김민수", createdAt: "2024-04-05", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -62,7 +65,7 @@ export const MOCK_COURSES: Course[] = [ { id: "6", courseName: "UI/UX 디자인 기초", - instructorName: instructorNames[5] || "정대현", + instructorName: defaultInstructorNames[5] || "정대현", createdAt: "2024-04-18", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -70,7 +73,7 @@ export const MOCK_COURSES: Course[] = [ { id: "7", courseName: "모바일 앱 개발", - instructorName: instructorNames[0] || "최예준", + instructorName: defaultInstructorNames[0] || "최예준", createdAt: "2024-05-02", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -78,7 +81,7 @@ export const MOCK_COURSES: Course[] = [ { id: "8", courseName: "클라우드 인프라", - instructorName: instructorNames[1] || "정시우", + instructorName: defaultInstructorNames[1] || "정시우", createdAt: "2024-05-15", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -86,7 +89,7 @@ export const MOCK_COURSES: Course[] = [ { id: "9", courseName: "머신러닝 입문", - instructorName: instructorNames[2] || "임건우", + instructorName: defaultInstructorNames[2] || "임건우", createdAt: "2024-06-01", createdBy: MOCK_CURRENT_USER, hasLessons: false, @@ -94,7 +97,7 @@ export const MOCK_COURSES: Course[] = [ { id: "10", courseName: "DevOps 실무", - instructorName: instructorNames[3] || "송윤서", + instructorName: defaultInstructorNames[3] || "송윤서", createdAt: "2024-06-20", createdBy: MOCK_CURRENT_USER, hasLessons: false, diff --git a/src/app/admin/id/mockData.ts b/src/app/admin/id/mockData.ts index 607aeb4..4760f3c 100644 --- a/src/app/admin/id/mockData.ts +++ b/src/app/admin/id/mockData.ts @@ -10,48 +10,115 @@ export type UserRow = { status: AccountStatus; }; -// 임시 데이터 -export const mockUsers: UserRow[] = [ - { id: "1", joinDate: "2024-01-15", name: "김민준", email: "user1@example.com", role: "learner", status: "active" }, - { id: "2", joinDate: "2024-01-20", name: "이서준", email: "user2@example.com", role: "learner", status: "active" }, - { id: "3", joinDate: "2024-02-05", name: "박도윤", email: "user3@example.com", role: "learner", status: "inactive" }, - { id: "4", joinDate: "2024-02-10", name: "최예준", email: "user4@example.com", role: "instructor", status: "active" }, - { id: "5", joinDate: "2024-02-15", name: "정시우", email: "user5@example.com", role: "instructor", status: "active" }, - { id: "6", joinDate: "2024-02-20", name: "강하준", email: "user6@example.com", role: "learner", status: "active" }, - { id: "7", joinDate: "2024-03-01", name: "조주원", email: "user7@example.com", role: "admin", status: "active" }, - { id: "8", joinDate: "2024-03-05", name: "윤지호", email: "user8@example.com", role: "learner", status: "active" }, - { id: "9", joinDate: "2024-03-10", name: "장준서", email: "user9@example.com", role: "learner", status: "inactive" }, - { id: "10", joinDate: "2024-03-15", name: "임건우", email: "user10@example.com", role: "instructor", status: "active" }, - { id: "11", joinDate: "2024-03-20", name: "한서연", email: "user11@example.com", role: "learner", status: "active" }, - { id: "12", joinDate: "2024-04-01", name: "오서윤", email: "user12@example.com", role: "learner", status: "active" }, - { id: "13", joinDate: "2024-04-05", name: "서지우", email: "user13@example.com", role: "instructor", status: "inactive" }, - { id: "14", joinDate: "2024-04-10", name: "신서현", email: "user14@example.com", role: "learner", status: "active" }, - { id: "15", joinDate: "2024-04-15", name: "권민서", email: "user15@example.com", role: "admin", status: "active" }, - { id: "16", joinDate: "2024-04-20", name: "황하은", email: "user16@example.com", role: "learner", status: "active" }, - { id: "17", joinDate: "2024-05-01", name: "안예은", email: "user17@example.com", role: "learner", status: "inactive" }, - { id: "18", joinDate: "2024-05-05", name: "송윤서", email: "user18@example.com", role: "instructor", status: "active" }, - { id: "19", joinDate: "2024-05-10", name: "전채원", email: "user19@example.com", role: "learner", status: "active" }, - { id: "20", joinDate: "2024-05-15", name: "홍지원", email: "user20@example.com", role: "learner", status: "active" }, - { id: "21", joinDate: "2024-05-20", name: "김민수", email: "user21@example.com", role: "instructor", status: "active" }, - { id: "22", joinDate: "2024-06-01", name: "이영희", email: "user22@example.com", role: "learner", status: "active" }, - { id: "23", joinDate: "2024-06-05", name: "박철수", email: "user23@example.com", role: "learner", status: "inactive" }, - { id: "24", joinDate: "2024-06-10", name: "최수진", email: "user24@example.com", role: "admin", status: "active" }, - { id: "25", joinDate: "2024-06-15", name: "정대현", email: "user25@example.com", role: "instructor", status: "active" }, - { id: "26", joinDate: "2024-06-20", name: "강미영", email: "user26@example.com", role: "learner", status: "active" }, - { id: "27", joinDate: "2024-07-01", name: "조성호", email: "user27@example.com", role: "learner", status: "active" }, - { id: "28", joinDate: "2024-07-05", name: "윤지은", email: "user28@example.com", role: "instructor", status: "inactive" }, - { id: "29", joinDate: "2024-07-10", name: "장현우", email: "user29@example.com", role: "learner", status: "active" }, - { id: "30", joinDate: "2024-07-15", name: "임소영", email: "user30@example.com", role: "learner", status: "active" }, -]; +// 더미 데이터 제거됨 - 이제 API에서 데이터를 가져옵니다 -// 강사 목록 가져오기 함수 export -// TODO: 나중에 DB에서 가져오도록 변경 예정 -// 예: export async function getInstructors(): Promise { -// const response = await fetch('/api/instructors'); -// return response.json(); -// } -export function getInstructors(): UserRow[] { - // 현재는 mock 데이터 사용, 나중에 DB에서 가져오도록 변경 - return mockUsers.filter(user => user.role === 'instructor' && user.status === 'active'); +// 강사 목록 가져오기 함수 - API에서 가져오기 +export async function getInstructors(): Promise { + try { + const token = typeof window !== 'undefined' + ? (localStorage.getItem('token') || document.cookie + .split('; ') + .find(row => row.startsWith('token=')) + ?.split('=')[1]) + : null; + + const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL + ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/compact` + : 'https://hrdi.coconutmeet.net/admin/users/compact'; + + console.log('🔍 [getInstructors] API 호출 정보:', { + url: apiUrl, + hasToken: !!token, + tokenLength: token?.length || 0 + }); + + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + console.log('📡 [getInstructors] API 응답 상태:', { + status: response.status, + statusText: response.statusText, + ok: response.ok + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('❌ [getInstructors] API 에러 응답:', errorText); + console.error('강사 목록 가져오기 실패:', response.status); + return []; + } + + const data = await response.json(); + + console.log('📦 [getInstructors] 원본 API 응답 데이터:', { + type: typeof data, + isArray: Array.isArray(data), + length: Array.isArray(data) ? data.length : 'N/A', + data: data, + sampleItem: Array.isArray(data) && data.length > 0 ? data[0] : null + }); + + // API 응답 데이터를 UserRow 형식으로 변환하고 강사만 필터링 (null 값도 표시) + const users: UserRow[] = Array.isArray(data) + ? data + .map((user: any, index: number) => { + // null 값을 명시적으로 처리 (null이면 "(없음)" 표시) + const getValue = (value: any, fallback: string = '(없음)') => { + if (value === null || value === undefined) return fallback; + if (typeof value === 'string' && value.trim() === '') return fallback; + return String(value); + }; + + const transformed = { + id: getValue(user.id || user.userId, `user-${index + 1}`), + joinDate: getValue(user.joinDate || user.createdAt || user.join_date, new Date().toISOString().split('T')[0]), + name: getValue(user.name || user.userName, '(없음)'), + email: getValue(user.email || user.userEmail, '(없음)'), + role: (user.role === 'instructor' || user.role === 'INSTRUCTOR' ? 'instructor' : + user.role === 'admin' || user.role === 'ADMIN' ? 'admin' : 'learner') as RoleType, + status: (user.status === 'inactive' || user.status === 'INACTIVE' ? 'inactive' : 'active') as AccountStatus, + }; + + // 모든 항목의 null 체크 + console.log(`🔎 [getInstructors] [${index + 1}번째] 사용자 데이터 변환:`, { + 원본: user, + 변환됨: transformed, + null체크: { + id: user.id === null || user.id === undefined, + userId: user.userId === null || user.userId === undefined, + joinDate: user.joinDate === null || user.joinDate === undefined, + createdAt: user.createdAt === null || user.createdAt === undefined, + join_date: user.join_date === null || user.join_date === undefined, + name: user.name === null || user.name === undefined, + userName: user.userName === null || user.userName === undefined, + email: user.email === null || user.email === undefined, + userEmail: user.userEmail === null || user.userEmail === undefined, + role: user.role === null || user.role === undefined, + status: user.status === null || user.status === undefined, + } + }); + + return transformed; + }) + .filter((user: UserRow) => user.role === 'instructor' && user.status === 'active') + : []; + + console.log('✅ [getInstructors] 변환된 강사 데이터:', { + 총개수: users.length, + 필터링전개수: Array.isArray(data) ? data.length : 0, + 데이터: users, + 빈배열여부: users.length === 0 + }); + + return users; + } catch (error) { + console.error('강사 목록 가져오기 오류:', error); + return []; + } } diff --git a/src/app/admin/id/page.tsx b/src/app/admin/id/page.tsx index 8733817..8e3e334 100644 --- a/src/app/admin/id/page.tsx +++ b/src/app/admin/id/page.tsx @@ -2,9 +2,8 @@ import { useState, useEffect, useRef, useMemo } from "react"; import AdminSidebar from "@/app/components/AdminSidebar"; -import DropdownIcon from "@/app/svgs/dropdownicon"; import ChevronDownSvg from "@/app/svgs/chevrondownsvg"; -import { mockUsers, type UserRow } from "./mockData"; +import { type UserRow } from "./mockData"; type TabType = 'all' | 'learner' | 'instructor' | 'admin'; type RoleType = 'learner' | 'instructor' | 'admin'; @@ -24,7 +23,9 @@ const statusLabels: Record = { export default function AdminIdPage() { const [activeTab, setActiveTab] = useState('all'); - const [users, setUsers] = useState(mockUsers); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const [openDropdownId, setOpenDropdownId] = useState(null); const [isActivateModalOpen, setIsActivateModalOpen] = useState(false); const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false); @@ -32,10 +33,112 @@ export default function AdminIdPage() { const [showToast, setShowToast] = useState(false); const [toastMessage, setToastMessage] = useState(''); const [currentPage, setCurrentPage] = useState(1); + const [deactivateReason, setDeactivateReason] = useState(''); const dropdownRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); const ITEMS_PER_PAGE = 10; + // API에서 사용자 데이터 가져오기 + useEffect(() => { + async function fetchUsers() { + try { + setIsLoading(true); + setError(null); + + const token = localStorage.getItem('token') || document.cookie + .split('; ') + .find(row => row.startsWith('token=')) + ?.split('=')[1]; + + // 외부 API 호출 + const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL + ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/compact` + : 'https://hrdi.coconutmeet.net/admin/users/compact'; + + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + if (!response.ok) { + throw new Error(`사용자 데이터를 가져오는데 실패했습니다. (${response.status})`); + } + + const data = await response.json(); + + // API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태) + let usersArray: any[] = []; + if (Array.isArray(data)) { + usersArray = data; + } else if (data && typeof data === 'object') { + usersArray = data.items || data.users || data.data || data.list || []; + } + + // API 응답 데이터를 UserRow 형식으로 변환 + const transformedUsers: UserRow[] = usersArray.length > 0 + ? usersArray.map((user: any) => { + // 가입일을 YYYY-MM-DD 형식으로 변환 + const formatDate = (dateString: string | null | undefined): string => { + if (!dateString) return new Date().toISOString().split('T')[0]; + try { + const date = new Date(dateString); + return date.toISOString().split('T')[0]; + } catch { + return new Date().toISOString().split('T')[0]; + } + }; + + // null 값을 명시적으로 처리 + const getValue = (value: any, fallback: string = '-') => { + if (value === null || value === undefined) return fallback; + if (typeof value === 'string' && value.trim() === '') return fallback; + return String(value); + }; + + // status가 "ACTIVE"이면 활성화, 아니면 비활성화 + const accountStatus: AccountStatus = + user.status === 'ACTIVE' || user.status === 'active' ? 'active' : 'inactive'; + + // role 데이터 처리 (API에서 role이 없을 수 있음) + let userRole: RoleType = 'learner'; // 기본값 + if (user.role) { + const roleLower = String(user.role).toLowerCase(); + if (roleLower === 'instructor' || roleLower === '강사') { + userRole = 'instructor'; + } else if (roleLower === 'admin' || roleLower === '관리자') { + userRole = 'admin'; + } else { + userRole = 'learner'; + } + } + + return { + id: String(user.id || user.userId || Math.random()), + joinDate: formatDate(user.createdAt || user.joinDate || user.join_date), + name: getValue(user.name || user.userName, '-'), + email: getValue(user.email || user.userEmail, '-'), + role: userRole, + status: accountStatus, + }; + }) + : []; + + setUsers(transformedUsers); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '사용자 데이터를 불러오는 중 오류가 발생했습니다.'; + setError(errorMessage); + console.error('사용자 데이터 로드 오류:', err); + } finally { + setIsLoading(false); + } + } + + fetchUsers(); + }, []); + const filteredUsers = useMemo(() => { return activeTab === 'all' ? users @@ -105,8 +208,63 @@ export default function AdminIdPage() { setIsDeactivateModalOpen(true); } - function handleDeactivateConfirm() { - if (selectedUserId) { + async function handleDeactivateConfirm() { + if (!selectedUserId) { + setIsDeactivateModalOpen(false); + setSelectedUserId(null); + setDeactivateReason(''); + return; + } + + try { + const token = localStorage.getItem('token') || document.cookie + .split('; ') + .find(row => row.startsWith('token=')) + ?.split('=')[1]; + + if (!token) { + setToastMessage('로그인이 필요합니다.'); + setShowToast(true); + setTimeout(() => { + setShowToast(false); + }, 3000); + setIsDeactivateModalOpen(false); + setSelectedUserId(null); + setDeactivateReason(''); + return; + } + + const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL + ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/${selectedUserId}/unsuspend` + : `https://hrdi.coconutmeet.net/admin/users/${selectedUserId}/unsuspend`; + + const response = await fetch(apiUrl, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + reason: deactivateReason, + }), + }); + + if (!response.ok) { + let errorMessage = `계정 비활성화 실패 (${response.status})`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage = errorData.error; + } else if (errorData.message) { + errorMessage = errorData.message; + } + } catch (parseError) { + // ignore + } + throw new Error(errorMessage); + } + + // API 호출 성공 시 로컬 상태 업데이트 setUsers(prevUsers => prevUsers.map(user => user.id === selectedUserId @@ -119,14 +277,25 @@ export default function AdminIdPage() { setTimeout(() => { setShowToast(false); }, 3000); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '계정 비활성화 중 오류가 발생했습니다.'; + setToastMessage(errorMessage); + setShowToast(true); + setTimeout(() => { + setShowToast(false); + }, 3000); + console.error('계정 비활성화 오류:', err); + } finally { + setIsDeactivateModalOpen(false); + setSelectedUserId(null); + setDeactivateReason(''); } - setIsDeactivateModalOpen(false); - setSelectedUserId(null); } function handleDeactivateCancel() { setIsDeactivateModalOpen(false); setSelectedUserId(null); + setDeactivateReason(''); } function toggleAccountStatus(userId: string) { @@ -203,7 +372,19 @@ export default function AdminIdPage() { {/* 콘텐츠 영역 */}
- {filteredUsers.length === 0 ? ( + {isLoading ? ( +
+

+ 데이터를 불러오는 중... +

+
+ ) : error ? ( +
+

+ {error} +

+
+ ) : filteredUsers.length === 0 ? (

현재 관리할 수 있는 회원 계정이 없습니다. @@ -215,19 +396,17 @@ export default function AdminIdPage() {

- + - - - + + - @@ -247,43 +426,6 @@ export default function AdminIdPage() { -
가입일 성명 아이디(이메일)권한설정 계정상태 계정관리
{user.email} -
- {roleLabels[user.role]} -
{ dropdownRefs.current[user.id] = el; }} - className="relative" - > - - {openDropdownId === user.id && ( -
- {(['learner', 'instructor', 'admin'] as RoleType[]).map((role) => ( - - ))} -
- )} -
-
-
{user.status === 'active' ? (
@@ -460,6 +602,15 @@ export default function AdminIdPage() {
계정을 비활성화 처리하시겠습니까?

+
+ setDeactivateReason(e.target.value)} + placeholder="비활성화 사유를 입력해주세요" + className="w-full h-[40px] px-[12px] rounded-[8px] border border-[#dee1e6] text-[14px] leading-[1.5] text-[#1b2027] placeholder:text-[#9ca3af] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent" + /> +