Compare commits

...

8 Commits

36 changed files with 6804 additions and 124 deletions

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

565
API_USAGE.md Normal file
View File

@@ -0,0 +1,565 @@
# API 사용 가이드
이 문서는 데이터베이스에 데이터를 생성하는 API의 사용 방법을 설명합니다.
## 📋 목차
1. [기본 설정](#기본-설정)
2. [사용자 API](#1-사용자-api)
3. [교육과정 API](#2-교육과정-api)
4. [강좌 API](#3-강좌-api)
5. [공지사항 API](#4-공지사항-api)
6. [에러 처리](#에러-처리)
7. [실전 예제](#실전-예제)
---
## 기본 설정
### 환경 변수
`.env` 파일에 데이터베이스 연결 정보가 설정되어 있어야 합니다:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
```
### API 기본 URL
- 개발 환경: `http://localhost:3000/api`
- 프로덕션: `https://your-domain.com/api`
---
## 1. 사용자 API
### POST /api/users - 사용자 생성
새로운 사용자를 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'hashed_password_here', // 실제로는 해시화된 비밀번호
name: '홍길동',
phone: '010-1234-5678', // 선택사항
gender: 'M', // 선택사항: 'M' 또는 'F'
birthdate: '1990-01-01', // 선택사항: YYYY-MM-DD 형식
role: 'LEARNER', // 선택사항: 'LEARNER' 또는 'ADMIN' (기본값: 'LEARNER')
status: 'ACTIVE', // 선택사항: 'ACTIVE' 또는 'INACTIVE' (기본값: 'ACTIVE')
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "사용자가 성공적으로 생성되었습니다.",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "홍길동",
"phone": "010-1234-5678",
"gender": "M",
"birthdate": "1990-01-01T00:00:00.000Z",
"role": "LEARNER",
"status": "ACTIVE",
"joinDate": "2024-11-21T00:00:00.000Z",
"createdAt": "2024-11-21T00:00:00.000Z",
"updatedAt": "2024-11-21T00:00:00.000Z"
}
}
```
#### 에러 응답
**400 Bad Request** - 필수 필드 누락
```json
{
"error": "이메일, 비밀번호, 이름은 필수입니다."
}
```
**409 Conflict** - 이메일 중복
```json
{
"error": "이미 존재하는 이메일입니다."
}
```
### GET /api/users - 사용자 목록 조회
사용자 목록을 조회합니다. 필터링 및 페이지네이션을 지원합니다.
#### 요청
```typescript
// 전체 사용자 조회
const response = await fetch('/api/users');
// 필터링 및 페이지네이션
const response = await fetch('/api/users?role=LEARNER&status=ACTIVE&page=1&limit=10');
const data = await response.json();
```
#### 쿼리 파라미터
- `role` (선택): `LEARNER` 또는 `ADMIN`
- `status` (선택): `ACTIVE` 또는 `INACTIVE`
- `page` (선택): 페이지 번호 (기본값: 1)
- `limit` (선택): 페이지당 항목 수 (기본값: 10)
#### 성공 응답 (200)
```json
{
"users": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "홍길동",
"role": "LEARNER",
"status": "ACTIVE",
...
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 30,
"totalPages": 3
}
}
```
---
## 2. 교육과정 API
### POST /api/courses - 교육과정 생성
새로운 교육과정을 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/courses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseName: '웹 개발 기초',
instructorId: 'instructor-uuid-here', // 필수: 강사(ADMIN 역할)의 ID
createdById: 'admin-uuid-here', // 선택사항: 등록자 ID (기본값: instructorId)
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "교육과정이 성공적으로 생성되었습니다.",
"course": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"courseName": "웹 개발 기초",
"instructorId": "instructor-uuid",
"createdById": "admin-uuid",
"createdAt": "2024-11-21T00:00:00.000Z",
"instructor": {
"id": "instructor-uuid",
"name": "최예준",
"email": "instructor@example.com"
},
"createdBy": {
"id": "admin-uuid",
"name": "관리자"
}
}
}
```
#### 에러 응답
**400 Bad Request** - 필수 필드 누락
```json
{
"error": "교육과정명과 강사 ID는 필수입니다."
}
```
**404 Not Found** - 강사를 찾을 수 없음
```json
{
"error": "강사를 찾을 수 없습니다."
}
```
### GET /api/courses - 교육과정 목록 조회
교육과정 목록을 조회합니다.
#### 요청
```typescript
// 전체 교육과정 조회
const response = await fetch('/api/courses');
// 특정 강사의 교육과정 조회
const response = await fetch('/api/courses?instructorId=instructor-uuid&page=1&limit=10');
const data = await response.json();
```
#### 쿼리 파라미터
- `instructorId` (선택): 강사 ID로 필터링
- `page` (선택): 페이지 번호 (기본값: 1)
- `limit` (선택): 페이지당 항목 수 (기본값: 10)
---
## 3. 강좌 API
### POST /api/lessons - 강좌 생성
새로운 강좌를 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/lessons', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseId: 'course-uuid-here', // 필수: 교육과정 ID
lessonName: 'HTML 기초', // 필수: 강좌명
learningGoal: 'HTML의 기본 문법을 이해하고 활용할 수 있다.', // 선택사항: 학습 목표
createdById: 'admin-uuid-here', // 선택사항: 등록자 ID
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "강좌가 성공적으로 생성되었습니다.",
"lesson": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"courseId": "course-uuid",
"lessonName": "HTML 기초",
"learningGoal": "HTML의 기본 문법을 이해하고 활용할 수 있다.",
"createdAt": "2024-11-21T00:00:00.000Z",
"course": {
"id": "course-uuid",
"courseName": "웹 개발 기초"
},
"createdBy": {
"id": "admin-uuid",
"name": "관리자"
},
"_count": {
"videos": 0,
"vrContents": 0,
"questions": 0
}
}
}
```
### GET /api/lessons - 강좌 목록 조회
강좌 목록을 조회합니다.
#### 요청
```typescript
// 전체 강좌 조회
const response = await fetch('/api/lessons');
// 특정 교육과정의 강좌 조회
const response = await fetch('/api/lessons?courseId=course-uuid&page=1&limit=10');
const data = await response.json();
```
---
## 4. 공지사항 API
### POST /api/notices - 공지사항 생성
새로운 공지사항을 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/notices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '공지사항 제목',
content: '공지사항 내용입니다.\n여러 줄로 작성할 수 있습니다.',
writerId: 'admin-uuid-here', // 필수: 작성자 ID
hasAttachment: false, // 선택사항: 첨부파일 여부 (기본값: false)
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "공지사항이 성공적으로 생성되었습니다.",
"notice": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "공지사항 제목",
"content": "공지사항 내용입니다.\n여러 줄로 작성할 수 있습니다.",
"writerId": "admin-uuid",
"views": 0,
"hasAttachment": false,
"date": "2024-11-21T00:00:00.000Z",
"writer": {
"id": "admin-uuid",
"name": "관리자",
"email": "admin@example.com"
}
}
}
```
### GET /api/notices - 공지사항 목록 조회
공지사항 목록을 조회합니다.
#### 요청
```typescript
// 전체 공지사항 조회
const response = await fetch('/api/notices');
// 특정 작성자의 공지사항 조회
const response = await fetch('/api/notices?writerId=admin-uuid&page=1&limit=10');
const data = await response.json();
```
---
## 에러 처리
모든 API는 일관된 에러 응답 형식을 사용합니다:
```typescript
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) {
const error = await response.json();
console.error('에러:', error.error);
// 에러 처리 로직
return;
}
const data = await response.json();
console.log('성공:', data);
} catch (error) {
console.error('네트워크 오류:', error);
}
```
### HTTP 상태 코드
- `200` - 성공 (GET 요청)
- `201` - 생성 성공 (POST 요청)
- `400` - 잘못된 요청 (필수 필드 누락 등)
- `404` - 리소스를 찾을 수 없음
- `409` - 충돌 (중복 데이터 등)
- `500` - 서버 오류
---
## 실전 예제
### React 컴포넌트에서 사용하기
```typescript
'use client';
import { useState } from 'react';
export default function CreateUserForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
name: '',
role: 'LEARNER',
});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
const data = await response.json();
if (!response.ok) {
setMessage(`오류: ${data.error}`);
return;
}
setMessage('사용자가 성공적으로 생성되었습니다!');
// 폼 초기화
setFormData({ email: '', password: '', name: '', role: 'LEARNER' });
} catch (error) {
setMessage('네트워크 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="이메일"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<input
type="password"
placeholder="비밀번호"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<input
type="text"
placeholder="이름"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
>
<option value="LEARNER">학습자</option>
<option value="ADMIN">관리자</option>
</select>
<button type="submit" disabled={loading}>
{loading ? '생성 중...' : '사용자 생성'}
</button>
{message && <p>{message}</p>}
</form>
);
}
```
### Server Component에서 사용하기
```typescript
// app/admin/users/page.tsx
import { prisma } from '@/lib/prisma';
export default async function UsersPage() {
const users = await prisma.user.findMany({
take: 10,
orderBy: { createdAt: 'desc' },
});
return (
<div>
<h1>사용자 목록</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email}) - {user.role}
</li>
))}
</ul>
</div>
);
}
```
### cURL로 테스트하기
```bash
# 사용자 생성
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "test123",
"name": "테스트 사용자",
"role": "LEARNER"
}'
# 사용자 목록 조회
curl http://localhost:3000/api/users?role=LEARNER&page=1&limit=10
# 교육과정 생성
curl -X POST http://localhost:3000/api/courses \
-H "Content-Type: application/json" \
-d '{
"courseName": "웹 개발 기초",
"instructorId": "instructor-uuid-here"
}'
```
---
## 🔒 보안 고려사항
1. **비밀번호 해시화**: 실제 프로덕션에서는 bcrypt 등을 사용하여 비밀번호를 해시화해야 합니다.
2. **인증/인가**: 현재 API는 인증이 없습니다. 프로덕션에서는 JWT 또는 세션 기반 인증을 추가해야 합니다.
3. **입력 검증**: 클라이언트 측 검증 외에도 서버 측 검증이 필요합니다.
4. **CORS 설정**: 필요시 CORS 설정을 추가해야 합니다.
---
## 📚 추가 리소스
- [Next.js API Routes 문서](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
- [Prisma Client 문서](https://www.prisma.io/docs/concepts/components/prisma-client)

937
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,24 @@
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write"
"format": "biome format --write",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:migrate:reset": "prisma migrate reset",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:push": "prisma db push",
"db:pull": "prisma db pull"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"next": "16.0.3",
"prisma": "^6.19.0",
"react": "19.2.0",
"react-dom": "19.2.0"
},
@@ -21,6 +35,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
}
}

13
prisma.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig, env } from "prisma/config";
import "dotenv/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

162
prisma/README.md Normal file
View File

@@ -0,0 +1,162 @@
# 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)

View File

@@ -0,0 +1,311 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('LEARNER', 'ADMIN');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "QuestionType" AS ENUM ('MULTIPLE_CHOICE', 'SHORT_ANSWER', 'ESSAY');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"gender" TEXT,
"birthdate" TIMESTAMP(3),
"role" "UserRole" NOT NULL DEFAULT 'LEARNER',
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"joinDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "courses" (
"id" TEXT NOT NULL,
"courseName" TEXT NOT NULL,
"instructorId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "courses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lessons" (
"id" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"lessonName" TEXT NOT NULL,
"learningGoal" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lessons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_videos" (
"id" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lesson_videos_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_vr_contents" (
"id" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lesson_vr_contents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_attachments" (
"id" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lesson_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "questions" (
"id" TEXT NOT NULL,
"lessonId" TEXT,
"question" TEXT NOT NULL,
"type" "QuestionType" NOT NULL DEFAULT 'MULTIPLE_CHOICE',
"options" JSONB,
"correctAnswer" TEXT NOT NULL,
"explanation" TEXT,
"points" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "questions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notices" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"writerId" TEXT NOT NULL,
"views" INTEGER NOT NULL DEFAULT 0,
"hasAttachment" BOOLEAN NOT NULL DEFAULT false,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notice_attachments" (
"id" TEXT NOT NULL,
"noticeId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notice_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "resources" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"filePath" TEXT,
"fileName" TEXT,
"fileSize" INTEGER,
"category" TEXT,
"views" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "resources_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "enrollments" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"completedAt" TIMESTAMP(3),
"enrolledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "enrollments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "certificates" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lessonId" TEXT,
"courseId" TEXT,
"verificationKey" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "certificates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "logs" (
"id" TEXT NOT NULL,
"userId" TEXT,
"action" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_email_idx" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_role_idx" ON "users"("role");
-- CreateIndex
CREATE INDEX "users_status_idx" ON "users"("status");
-- CreateIndex
CREATE INDEX "courses_instructorId_idx" ON "courses"("instructorId");
-- CreateIndex
CREATE INDEX "courses_createdById_idx" ON "courses"("createdById");
-- CreateIndex
CREATE INDEX "courses_createdAt_idx" ON "courses"("createdAt");
-- CreateIndex
CREATE INDEX "lessons_courseId_idx" ON "lessons"("courseId");
-- CreateIndex
CREATE INDEX "lessons_createdById_idx" ON "lessons"("createdById");
-- CreateIndex
CREATE INDEX "lessons_createdAt_idx" ON "lessons"("createdAt");
-- CreateIndex
CREATE INDEX "lesson_videos_lessonId_idx" ON "lesson_videos"("lessonId");
-- CreateIndex
CREATE INDEX "lesson_vr_contents_lessonId_idx" ON "lesson_vr_contents"("lessonId");
-- CreateIndex
CREATE INDEX "lesson_attachments_lessonId_idx" ON "lesson_attachments"("lessonId");
-- CreateIndex
CREATE INDEX "questions_lessonId_idx" ON "questions"("lessonId");
-- CreateIndex
CREATE INDEX "notices_writerId_idx" ON "notices"("writerId");
-- CreateIndex
CREATE INDEX "notices_date_idx" ON "notices"("date");
-- CreateIndex
CREATE UNIQUE INDEX "notice_attachments_noticeId_key" ON "notice_attachments"("noticeId");
-- CreateIndex
CREATE INDEX "resources_category_idx" ON "resources"("category");
-- CreateIndex
CREATE INDEX "resources_createdAt_idx" ON "resources"("createdAt");
-- CreateIndex
CREATE INDEX "enrollments_userId_idx" ON "enrollments"("userId");
-- CreateIndex
CREATE INDEX "enrollments_lessonId_idx" ON "enrollments"("lessonId");
-- CreateIndex
CREATE UNIQUE INDEX "enrollments_userId_lessonId_key" ON "enrollments"("userId", "lessonId");
-- CreateIndex
CREATE UNIQUE INDEX "certificates_verificationKey_key" ON "certificates"("verificationKey");
-- CreateIndex
CREATE INDEX "certificates_userId_idx" ON "certificates"("userId");
-- CreateIndex
CREATE INDEX "certificates_verificationKey_idx" ON "certificates"("verificationKey");
-- CreateIndex
CREATE INDEX "logs_userId_idx" ON "logs"("userId");
-- CreateIndex
CREATE INDEX "logs_action_idx" ON "logs"("action");
-- CreateIndex
CREATE INDEX "logs_createdAt_idx" ON "logs"("createdAt");
-- AddForeignKey
ALTER TABLE "courses" ADD CONSTRAINT "courses_instructorId_fkey" FOREIGN KEY ("instructorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "courses" ADD CONSTRAINT "courses_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lessons" ADD CONSTRAINT "lessons_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lessons" ADD CONSTRAINT "lessons_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_videos" ADD CONSTRAINT "lesson_videos_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_vr_contents" ADD CONSTRAINT "lesson_vr_contents_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_attachments" ADD CONSTRAINT "lesson_attachments_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "questions" ADD CONSTRAINT "questions_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notices" ADD CONSTRAINT "notices_writerId_fkey" FOREIGN KEY ("writerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notice_attachments" ADD CONSTRAINT "notice_attachments_noticeId_fkey" FOREIGN KEY ("noticeId") REFERENCES "notices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "certificates" ADD CONSTRAINT "certificates_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "logs" ADD CONSTRAINT "logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

313
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,313 @@
// 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")
}

205
prisma/seed.ts Normal file
View File

@@ -0,0 +1,205 @@
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<string, string>();
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<string, string>();
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();
});

View File

@@ -18,6 +18,7 @@ export default function NavBar() {
const userMenuRef = useRef<HTMLDivElement | null>(null);
const userButtonRef = useRef<HTMLButtonElement | null>(null);
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
const isAdminPage = pathname.startsWith('/admin');
useEffect(() => {
if (!isUserMenuOpen) return;
@@ -51,7 +52,7 @@ export default function NavBar() {
<MainLogoSvg width={46.703} height={36} />
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
</Link>
{!hideCenterNav && (
{!hideCenterNav && !isAdminPage && (
<nav className="flex h-full items-center">
{NAV_ITEMS.map((item) => {
return (
@@ -68,46 +69,59 @@ export default function NavBar() {
)}
</div>
<div className="relative flex items-center gap-2">
<Link href="/menu/courses" className="px-4 py-2 text-[16px] font-semibold text-white">
</Link>
<button
ref={userButtonRef}
type="button"
onClick={() => setIsUserMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isUserMenuOpen}
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
>
<ChevronDownSvg
width={16}
height={16}
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
/>
</button>
{isUserMenuOpen && (
<div
ref={userMenuRef}
role="menu"
aria-label="사용자 메뉴"
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
>
<Link
role="menuitem"
href="/menu/account"
className="block w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
onClick={() => setIsUserMenuOpen(false)}
>
{isAdminPage ? (
<>
<Link href="/menu/account" className="px-4 py-2 text-[16px] font-semibold text-white">
</Link>
<Link href="/login" className="px-4 py-2 text-[16px] font-semibold text-white">
</Link>
</>
) : (
<>
<Link href="/menu/courses" className="px-4 py-2 text-[16px] font-semibold text-white">
</Link>
<button
role="menuitem"
className="w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
ref={userButtonRef}
type="button"
onClick={() => setIsUserMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isUserMenuOpen}
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
>
<ChevronDownSvg
width={16}
height={16}
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
/>
</button>
</div>
{isUserMenuOpen && (
<div
ref={userMenuRef}
role="menu"
aria-label="사용자 메뉴"
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
>
<Link
role="menuitem"
href="/menu/account"
className="block w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
onClick={() => setIsUserMenuOpen(false)}
>
</Link>
<button
role="menuitem"
className="w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
>
</button>
</div>
)}
</>
)}
</div>
</div>

View File

@@ -0,0 +1,145 @@
'use client';
import { useState, useMemo } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
export default function AdminCertificatesPage() {
// TODO: 나중에 실제 데이터로 교체
const items: any[] = [];
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 10;
const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE);
const paginatedItems = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return items.slice(startIndex, endIndex);
}, [items, currentPage]);
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
/
</h1>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-8 flex flex-col">
{items.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
.
</p>
</div>
) : (
<>
{/* TODO: 테이블 또는 리스트를 여기에 추가 */}
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{items.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</>
)}
</div>
</div>
</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,369 @@
"use client";
import React, { useState, useRef, useEffect, useMemo } from "react";
import ModalCloseSvg from "@/app/svgs/closexsvg";
import DropdownIcon from "@/app/svgs/dropdownicon";
import { getInstructors, type UserRow } from "@/app/admin/id/mockData";
import { type Course } from "./mockData";
type Props = {
open: boolean;
onClose: () => void;
onSave?: (courseName: string, instructorName: string) => void;
onDelete?: () => void;
editingCourse?: Course | null;
};
export default function CourseRegistrationModal({ open, onClose, onSave, onDelete, editingCourse }: Props) {
const [courseName, setCourseName] = useState("");
const [instructorId, setInstructorId] = useState<string>("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
// 강사 목록 가져오기
// TODO: 나중에 DB에서 가져오도록 변경 시 async/await 사용
// 예: const [instructors, setInstructors] = useState<UserRow[]>([]);
// useEffect(() => { getInstructors().then(setInstructors); }, []);
const instructors = useMemo(() => getInstructors(), []);
// 선택된 강사 정보
const selectedInstructor = useMemo(() => {
return instructors.find(inst => inst.id === instructorId);
}, [instructors, instructorId]);
// 수정 모드일 때 기존 데이터 채우기
useEffect(() => {
if (open && editingCourse) {
setCourseName(editingCourse.courseName);
// 강사명으로 instructorId 찾기
const instructor = instructors.find(inst => inst.name === editingCourse.instructorName);
if (instructor) {
setInstructorId(instructor.id);
}
} else if (!open) {
setCourseName("");
setInstructorId("");
setIsDropdownOpen(false);
setErrors({});
setIsDeleteConfirmOpen(false);
}
}, [open, editingCourse, instructors]);
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isDropdownOpen]);
// 모달 클릭 시 이벤트 전파 방지
const handleModalClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
// 저장 버튼 클릭 핸들러
const handleSave = () => {
const nextErrors: Record<string, string> = {};
if (!courseName.trim()) {
nextErrors.courseName = "교육 과정명을 입력해 주세요.";
}
if (!instructorId || !selectedInstructor) {
nextErrors.instructor = "강사를 선택해 주세요.";
}
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
return;
}
if (onSave && selectedInstructor) {
onSave(courseName.trim(), selectedInstructor.name);
}
};
// 삭제 버튼 클릭 핸들러
const handleDeleteClick = () => {
setIsDeleteConfirmOpen(true);
};
// 삭제 확인 핸들러
const handleDeleteConfirm = () => {
if (onDelete) {
onDelete();
setIsDeleteConfirmOpen(false);
onClose();
}
};
// 삭제 취소 핸들러
const handleDeleteCancel = () => {
setIsDeleteConfirmOpen(false);
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
aria-hidden={!open}
onClick={onClose}
>
<div
className="absolute inset-0 bg-black/40"
aria-hidden="true"
/>
<div
ref={modalRef}
className="relative z-10 shadow-xl"
onClick={handleModalClick}
>
<div className="bg-white border border-[#dee1e6] rounded-[12px] w-full min-w-[480px] max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between gap-[10px] p-6">
<h2 className="text-[20px] font-bold leading-[1.5] text-[#333c47]">
{editingCourse ? "교육과정 수정" : "과목 등록"}
</h2>
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center cursor-pointer hover:opacity-80 shrink-0"
aria-label="닫기"
>
<ModalCloseSvg />
</button>
</div>
{/* Form Container */}
<div className="px-6 py-0">
<div className="flex flex-col gap-6">
{/* 교육 과정명 */}
<div className="flex flex-col gap-2">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
<span className="text-[#f64c4c]">*</span>
</label>
<input
type="text"
value={courseName}
onChange={(e) => {
setCourseName(e.target.value);
if (errors.courseName) {
setErrors((prev) => {
const next = { ...prev };
delete next.courseName;
return next;
});
}
}}
placeholder="교육 과정명을 입력해 주세요."
className={`h-[40px] px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none ${
errors.courseName
? "border-[#f64c4c] focus:shadow-[inset_0_0_0_1px_#333c47]"
: "border-[#dee1e6] focus:shadow-[inset_0_0_0_1px_#333c47]"
}`}
/>
{errors.courseName && (
<p className="text-[#f64c4c] text-[13px] leading-tight">{errors.courseName}</p>
)}
</div>
{/* 강사 */}
<div className="flex flex-col gap-2">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
<span className="text-[#f64c4c]">*</span>
</label>
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className={`w-full h-[40px] px-3 py-2 border rounded-[8px] bg-white flex items-center justify-between text-left focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47] cursor-pointer ${
errors.instructor
? "border-[#f64c4c]"
: "border-[#dee1e6]"
}`}
>
<span
className={`text-[16px] font-normal leading-[1.5] flex-1 ${
selectedInstructor ? "text-[#1b2027]" : "text-[#6c7682]"
}`}
>
{selectedInstructor?.name || "강사를 선택해 주세요."}
</span>
<DropdownIcon stroke="#8C95A1" className="shrink-0" />
</button>
{isDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-20 max-h-[200px] overflow-y-auto">
{instructors.length === 0 ? (
<div className="px-3 py-2 text-[16px] font-normal leading-[1.5] text-[#6c7682] text-center">
.
</div>
) : (
instructors.map((instructor, index) => (
<button
key={instructor.id}
type="button"
onClick={() => {
setInstructorId(instructor.id);
setIsDropdownOpen(false);
if (errors.instructor) {
setErrors((prev) => {
const next = { ...prev };
delete next.instructor;
return next;
});
}
}}
className={`w-full px-3 py-2 text-left text-[16px] font-normal leading-[1.5] hover:bg-[#f1f3f5] transition-colors cursor-pointer ${
instructorId === instructor.id
? "bg-[#ecf0ff] text-[#1f2b91] font-semibold"
: "text-[#1b2027]"
} ${
index === 0 ? "rounded-t-[8px]" : ""
} ${
index === instructors.length - 1 ? "rounded-b-[8px]" : ""
}`}
>
{instructor.name}
</button>
))
)}
</div>
)}
</div>
{errors.instructor && (
<p className="text-[#f64c4c] text-[13px] leading-tight">{errors.instructor}</p>
)}
</div>
{/* 과목 이미지 */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-pre">
</label>
<span className="text-[13px] font-normal leading-[1.4] text-[#8c95a1]">
30MB PNG, JPG
</span>
</div>
</div>
<div className="bg-gray-50 border border-[#dee1e6] border-dashed h-[192px] rounded-[8px] flex flex-col items-center justify-center gap-3 cursor-pointer hover:bg-gray-100 transition-colors px-0 py-4">
<div className="w-10 h-10 flex items-center justify-center shrink-0">
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 13.3333V26.6667M13.3333 20H26.6667"
stroke="#8C95A1"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="text-center">
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] whitespace-pre">
( )
<br aria-hidden="true" />
.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Actions Container */}
<div className="flex flex-col gap-8 h-[96px] items-center p-6">
<div className="flex items-center justify-center gap-3 w-full">
{editingCourse && (
<button
type="button"
onClick={handleDeleteClick}
className="h-[48px] px-4 rounded-[10px] bg-[#fef2f2] text-[16px] font-semibold leading-[1.5] text-[#f64c4c] w-[136px] hover:bg-[#fae6e6] transition-colors cursor-pointer"
>
</button>
)}
<button
type="button"
onClick={onClose}
className="h-[48px] px-4 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] w-[136px] hover:bg-[#e5e7eb] transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSave}
className="h-[48px] px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white w-[136px] hover:bg-[#1a2478] transition-colors cursor-pointer"
>
</button>
</div>
</div>
</div>
</div>
{/* 삭제 확인 모달 */}
{isDeleteConfirmOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleDeleteCancel}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[500px]">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
?
</h2>
<p className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
.
<br />
?
</p>
</div>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
onClick={handleDeleteCancel}
className="h-[40px] px-2 rounded-[8px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] w-[80px] hover:bg-[#e5e7eb] cursor-pointer transition-colors"
>
</button>
<button
type="button"
onClick={handleDeleteConfirm}
className="h-[40px] px-4 rounded-[8px] bg-[#fef2f2] text-[16px] font-semibold leading-[1.5] text-[#f64c4c] hover:bg-[#fae6e6] cursor-pointer transition-colors"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { getInstructors } from "@/app/admin/id/mockData";
export type Course = {
id: string;
courseName: string;
instructorName: string;
createdAt: string; // 생성일 (YYYY-MM-DD)
createdBy: string; // 등록자
hasLessons: boolean; // 강좌포함여부
};
// TODO: 나중에 DB에서 가져오도록 변경
export const MOCK_CURRENT_USER = "관리자"; // 현재 로그인한 사용자 이름
// 강사 목록 가져오기
const instructors = getInstructors();
const instructorNames = instructors.map(instructor => instructor.name);
// TODO: 이 부분도 나중에는 db에서 받아오도록 변경 예정
// 임시 데이터 - 강사명은 mockData.ts의 강사 데이터 활용
export const MOCK_COURSES: Course[] = [
{
id: "1",
courseName: "웹 개발 기초",
instructorName: instructorNames[0] || "최예준",
createdAt: "2024-01-15",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "2",
courseName: "React 실전 프로젝트",
instructorName: instructorNames[1] || "정시우",
createdAt: "2024-02-20",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "3",
courseName: "데이터베이스 설계",
instructorName: instructorNames[2] || "임건우",
createdAt: "2024-03-10",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "4",
courseName: "Node.js 백엔드 개발",
instructorName: instructorNames[3] || "송윤서",
createdAt: "2024-03-25",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "5",
courseName: "TypeScript 마스터",
instructorName: instructorNames[4] || "김민수",
createdAt: "2024-04-05",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "6",
courseName: "UI/UX 디자인 기초",
instructorName: instructorNames[5] || "정대현",
createdAt: "2024-04-18",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "7",
courseName: "모바일 앱 개발",
instructorName: instructorNames[0] || "최예준",
createdAt: "2024-05-02",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "8",
courseName: "클라우드 인프라",
instructorName: instructorNames[1] || "정시우",
createdAt: "2024-05-15",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "9",
courseName: "머신러닝 입문",
instructorName: instructorNames[2] || "임건우",
createdAt: "2024-06-01",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
{
id: "10",
courseName: "DevOps 실무",
instructorName: instructorNames[3] || "송윤서",
createdAt: "2024-06-20",
createdBy: MOCK_CURRENT_USER,
hasLessons: false,
},
];

View File

@@ -0,0 +1,302 @@
'use client';
import { useState, useMemo } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import CourseRegistrationModal from "./CourseRegistrationModal";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
import { MOCK_COURSES, MOCK_CURRENT_USER, type Course } from "./mockData";
export default function AdminCoursesPage() {
const [courses, setCourses] = useState<Course[]>(MOCK_COURSES);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [showToast, setShowToast] = useState(false);
const totalCount = useMemo(() => courses.length, [courses]);
const ITEMS_PER_PAGE = 10;
const sortedCourses = useMemo(() => {
return [...courses].sort((a, b) => {
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
return b.createdAt.localeCompare(a.createdAt);
});
}, [courses]);
const totalPages = Math.ceil(sortedCourses.length / ITEMS_PER_PAGE);
const paginatedCourses = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return sortedCourses.slice(startIndex, endIndex);
}, [sortedCourses, currentPage]);
const handleSaveCourse = (courseName: string, instructorName: string) => {
if (editingCourse) {
// 수정 모드
setCourses(prev => prev.map(course =>
course.id === editingCourse.id
? { ...course, courseName, instructorName }
: course
));
} else {
// 등록 모드
const newCourse: Course = {
id: String(Date.now()),
courseName,
instructorName,
createdAt: new Date().toISOString().split('T')[0],
createdBy: MOCK_CURRENT_USER,
hasLessons: false, // 기본값: 미포함
};
setCourses(prev => [...prev, newCourse]);
}
setIsModalOpen(false);
setEditingCourse(null);
};
const handleRowClick = (course: Course) => {
setEditingCourse(course);
setIsModalOpen(true);
};
const handleModalClose = () => {
setIsModalOpen(false);
setEditingCourse(null);
};
const handleRegisterClick = () => {
setEditingCourse(null);
setIsModalOpen(true);
};
const handleDeleteCourse = () => {
if (editingCourse) {
setCourses(prev => prev.filter(course => course.id !== editingCourse.id));
setEditingCourse(null);
setShowToast(true);
setTimeout(() => {
setShowToast(false);
}, 3000);
}
};
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
{/* 헤더 영역 (제목과 콘텐츠 사이) */}
<div className="pt-2 pb-4 flex items-center justify-between">
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47] whitespace-nowrap">
{totalCount}
</p>
<button
type="button"
onClick={handleRegisterClick}
className="h-[40px] px-4 rounded-[8px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
>
</button>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-2 flex flex-col">
{courses.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
.
<br />
.
</p>
</div>
) : (
<div className="rounded-[8px]">
<div className="w-full rounded-[8px] border border-[#dee1e6] overflow-visible">
<table className="min-w-full border-collapse">
<colgroup>
<col />
<col />
<col style={{ width: 140 }} />
<col />
<col style={{ width: 120 }} />
</colgroup>
<thead>
<tr className="h-12 bg-gray-50 text-left">
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="px-4 text-center text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
</tr>
</thead>
<tbody>
{paginatedCourses.map((course) => (
<tr
key={course.id}
className="h-12 cursor-pointer hover:bg-[#F5F7FF] transition-colors"
onClick={() => handleRowClick(course)}
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{course.courseName}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{course.instructorName}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{course.createdAt}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{course.createdBy}
</td>
<td className="border-t border-[#dee1e6] px-4 text-left text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{course.hasLessons ? (
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#ecf0ff]">
<span className="text-[13px] font-semibold leading-[1.4] text-[#384fbf] whitespace-nowrap">
</span>
</div>
) : (
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#f1f3f5]">
<span className="text-[13px] font-semibold leading-[1.4] text-[#4c5561] whitespace-nowrap">
</span>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{courses.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</div>
</div>
</main>
</div>
</div>
<CourseRegistrationModal
open={isModalOpen}
onClose={handleModalClose}
onSave={handleSaveCourse}
onDelete={handleDeleteCourse}
editingCourse={editingCourse}
/>
{/* 삭제 완료 토스트 */}
{showToast && (
<div className="fixed right-[60px] bottom-[60px] z-50">
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
type RoleType = 'learner' | 'instructor' | 'admin';
type AccountStatus = 'active' | 'inactive';
export type UserRow = {
id: string;
joinDate: string;
name: string;
email: string;
role: RoleType;
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" },
];
// 강사 목록 가져오기 함수 export
// TODO: 나중에 DB에서 가져오도록 변경 예정
// 예: export async function getInstructors(): Promise<UserRow[]> {
// 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');
}

503
src/app/admin/id/page.tsx Normal file
View File

@@ -0,0 +1,503 @@
'use client';
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";
type TabType = 'all' | 'learner' | 'instructor' | 'admin';
type RoleType = 'learner' | 'instructor' | 'admin';
type AccountStatus = 'active' | 'inactive';
const roleLabels: Record<RoleType, string> = {
learner: '학습자',
instructor: '강사',
admin: '관리자',
};
const statusLabels: Record<AccountStatus, string> = {
active: '활성화',
inactive: '비활성화',
};
export default function AdminIdPage() {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [users, setUsers] = useState<UserRow[]>(mockUsers);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const [isActivateModalOpen, setIsActivateModalOpen] = useState(false);
const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const dropdownRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const ITEMS_PER_PAGE = 10;
const filteredUsers = useMemo(() => {
return activeTab === 'all'
? users
: users.filter(user => user.role === activeTab);
}, [activeTab, users]);
const totalPages = Math.ceil(filteredUsers.length / ITEMS_PER_PAGE);
const paginatedUsers = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return filteredUsers.slice(startIndex, endIndex);
}, [filteredUsers, currentPage]);
// 탭 변경 시 첫 페이지로 리셋
useEffect(() => {
setCurrentPage(1);
}, [activeTab]);
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (openDropdownId) {
const dropdownElement = dropdownRefs.current[openDropdownId];
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
setOpenDropdownId(null);
}
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [openDropdownId]);
function openActivateModal(userId: string) {
setSelectedUserId(userId);
setIsActivateModalOpen(true);
}
function handleActivateConfirm() {
if (selectedUserId) {
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === selectedUserId
? { ...user, status: 'active' }
: user
)
);
setToastMessage('계정을 활성화했습니다.');
setShowToast(true);
setTimeout(() => {
setShowToast(false);
}, 3000);
}
setIsActivateModalOpen(false);
setSelectedUserId(null);
}
function handleActivateCancel() {
setIsActivateModalOpen(false);
setSelectedUserId(null);
}
function openDeactivateModal(userId: string) {
setSelectedUserId(userId);
setIsDeactivateModalOpen(true);
}
function handleDeactivateConfirm() {
if (selectedUserId) {
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === selectedUserId
? { ...user, status: 'inactive' }
: user
)
);
setToastMessage('계정을 비활성화했습니다.');
setShowToast(true);
setTimeout(() => {
setShowToast(false);
}, 3000);
}
setIsDeactivateModalOpen(false);
setSelectedUserId(null);
}
function handleDeactivateCancel() {
setIsDeactivateModalOpen(false);
setSelectedUserId(null);
}
function toggleAccountStatus(userId: string) {
const user = users.find(u => u.id === userId);
if (user && user.status === 'inactive') {
openActivateModal(userId);
} else if (user && user.status === 'active') {
openDeactivateModal(userId);
}
}
function toggleDropdown(userId: string) {
setOpenDropdownId(openDropdownId === userId ? null : userId);
}
function changeUserRole(userId: string, newRole: RoleType) {
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === userId
? { ...user, role: newRole }
: user
)
);
setOpenDropdownId(null);
}
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
{/* 탭 네비게이션 */}
<div>
<div className="flex items-center gap-8 border-b border-[#dee1e6]">
{[
{ id: 'all' as TabType, label: '전체' },
{ id: 'learner' as TabType, label: '학습자' },
{ id: 'instructor' as TabType, label: '강사' },
{ id: 'admin' as TabType, label: '관리자' },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={[
"pb-4 px-1 text-[16px] font-medium leading-[1.5] transition-colors relative cursor-pointer",
activeTab === tab.id
? "text-[#1f2b91] font-semibold"
: "text-[#6c7682]",
].join(" ")}
>
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#1f2b91]" />
)}
</button>
))}
</div>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-8 flex flex-col">
{filteredUsers.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
.
</p>
</div>
) : (
<>
<div className="rounded-[8px]">
<div className="w-full rounded-[8px] border border-[#dee1e6] overflow-visible">
<table className="min-w-full border-collapse">
<colgroup>
<col style={{ width: 140 }} />
<col />
<col />
<col style={{ width: 120 }} />
<col style={{ width: 120 }} />
<col style={{ width: 120 }} />
</colgroup>
<thead>
<tr className="h-12 bg-gray-50 text-left">
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]">()</th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="px-4 text-center text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
</tr>
</thead>
<tbody>
{paginatedUsers.map((user) => (
<tr
key={user.id}
className="h-12"
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{user.joinDate}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{user.name}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{user.email}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
<div className="relative flex items-center justify-between">
<span>{roleLabels[user.role]}</span>
<div
ref={(el) => { dropdownRefs.current[user.id] = el; }}
className="relative"
>
<button
type="button"
onClick={() => toggleDropdown(user.id)}
className="ml-2 flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity"
>
<DropdownIcon width={16} height={16} stroke="#8C95A1" />
</button>
{openDropdownId === user.id && (
<div className="absolute right-0 top-full mt-1 z-[100] min-w-[120px] bg-white border border-[#dee1e6] rounded-[4px] shadow-lg flex flex-col">
{(['learner', 'instructor', 'admin'] as RoleType[]).map((role) => (
<button
key={role}
type="button"
onClick={() => changeUserRole(user.id, role)}
className={[
"w-full px-4 py-2 text-left text-[14px] leading-[1.5] hover:bg-gray-50 transition-colors cursor-pointer",
user.role === role
? "text-[#1f2b91] font-semibold bg-[#ecf0ff]"
: "text-[#1b2027]",
role !== 'admin' && "border-b border-[#dee1e6]"
].join(" ")}
>
{roleLabels[role]}
</button>
))}
</div>
)}
</div>
</div>
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{user.status === 'active' ? (
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#ecf0ff]">
<span className="text-[13px] font-semibold leading-[1.4] text-[#384fbf] whitespace-nowrap">
{statusLabels[user.status]}
</span>
</div>
) : (
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#f1f3f5]">
<span className="text-[13px] font-semibold leading-[1.4] text-[#4c5561] whitespace-nowrap">
{statusLabels[user.status]}
</span>
</div>
)}
</td>
<td className="border-t border-[#dee1e6] px-4 text-center text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
<button
type="button"
onClick={() => toggleAccountStatus(user.id)}
className="text-[12px] text-blue-500 underline underline-offset-[3px] cursor-pointer whitespace-nowrap hover:opacity-80"
>
{user.status === 'active' ? '비활성화 처리' : '활성화 처리'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{filteredUsers.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</>
)}
</div>
</div>
</main>
</div>
</div>
{/* 활성화 확인 모달 */}
{isActivateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleActivateCancel}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-6 shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-8 items-end justify-end min-w-[500px]">
<div className="flex flex-col gap-4 items-start justify-center w-full">
<div className="flex gap-2 items-start w-full">
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
?
</h2>
</div>
</div>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
onClick={handleActivateCancel}
className="h-[40px] px-2 rounded-[8px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] w-[80px] cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
</button>
<button
type="button"
onClick={handleActivateConfirm}
className="h-[40px] px-4 rounded-[8px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white cursor-pointer hover:bg-[#1a2478] transition-colors"
>
</button>
</div>
</div>
</div>
)}
{/* 비활성화 확인 모달 */}
{isDeactivateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleDeactivateCancel}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[500px]">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
?
</h2>
<p className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
.
<br />
?
</p>
</div>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
onClick={handleDeactivateCancel}
className="h-[40px] px-2 rounded-[8px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] w-[80px] cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
</button>
<button
type="button"
onClick={handleDeactivateConfirm}
className="h-[40px] px-4 rounded-[8px] bg-red-50 text-[16px] font-semibold leading-[1.5] text-[#f64c4c] cursor-pointer hover:bg-red-100 transition-colors"
>
</button>
</div>
</div>
</div>
)}
{/* 활성화 완료 토스트 */}
{showToast && (
<div className="fixed right-[60px] bottom-[60px] z-50">
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
{toastMessage}
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,690 @@
'use client';
import { useState, useMemo, useRef, useEffect } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
import DropdownIcon from "@/app/svgs/dropdownicon";
import BackArrowSvg from "@/app/svgs/backarrow";
import { MOCK_COURSES, MOCK_CURRENT_USER } from "@/app/admin/courses/mockData";
import CloseXOSvg from "@/app/svgs/closexo";
type Lesson = {
id: string;
courseName: string; // 교육과정명
lessonName: string; // 강좌명
attachments: string; // 첨부파일 (예: "강좌영상 3개, VR콘텐츠 2개, 평가문제 1개")
questionCount: number; // 학습 평가 문제 수
createdBy: string; // 등록자
createdAt: string; // 등록일
};
export default function AdminLessonsPage() {
const [lessons, setLessons] = useState<Lesson[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isRegistrationMode, setIsRegistrationMode] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 등록 폼 상태
const [selectedCourse, setSelectedCourse] = useState<string>("");
const [lessonName, setLessonName] = useState("");
const [learningGoal, setLearningGoal] = useState("");
const [courseVideoCount, setCourseVideoCount] = useState(0);
const [courseVideoFiles, setCourseVideoFiles] = useState<string[]>([]);
const [vrContentCount, setVrContentCount] = useState(0);
const [vrContentFiles, setVrContentFiles] = useState<string[]>([]);
const [questionFileCount, setQuestionFileCount] = useState(0);
const totalCount = useMemo(() => lessons.length, [lessons]);
const ITEMS_PER_PAGE = 10;
const sortedLessons = useMemo(() => {
return [...lessons].sort((a, b) => {
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
return b.createdAt.localeCompare(a.createdAt);
});
}, [lessons]);
const totalPages = Math.ceil(sortedLessons.length / ITEMS_PER_PAGE);
const paginatedLessons = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return sortedLessons.slice(startIndex, endIndex);
}, [sortedLessons, currentPage]);
// 교육과정 옵션 - mockData에서 가져오기
const courseOptions = useMemo(() =>
MOCK_COURSES.map(course => ({
id: course.id,
name: course.courseName
}))
, []);
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isDropdownOpen]);
const handleBackClick = () => {
setIsRegistrationMode(false);
// 폼 초기화
setSelectedCourse("");
setLessonName("");
setLearningGoal("");
setCourseVideoCount(0);
setCourseVideoFiles([]);
setVrContentCount(0);
setVrContentFiles([]);
setQuestionFileCount(0);
};
const handleRegisterClick = () => {
setIsRegistrationMode(true);
};
const handleSaveClick = () => {
// 유효성 검사
if (!selectedCourse || !lessonName) {
// TODO: 에러 메시지 표시
return;
}
// 첨부파일 정보 문자열 생성
const attachmentParts: string[] = [];
if (courseVideoCount > 0) {
attachmentParts.push(`강좌영상 ${courseVideoCount}`);
}
if (vrContentCount > 0) {
attachmentParts.push(`VR콘텐츠 ${vrContentCount}`);
}
if (questionFileCount > 0) {
attachmentParts.push(`평가문제 ${questionFileCount}`);
}
const attachments = attachmentParts.length > 0
? attachmentParts.join(', ')
: '없음';
// 교육과정명 가져오기
const courseName = courseOptions.find(c => c.id === selectedCourse)?.name || '';
// 새 강좌 생성
const newLesson: Lesson = {
id: String(Date.now()),
courseName,
lessonName,
attachments,
questionCount: questionFileCount,
createdBy: MOCK_CURRENT_USER,
createdAt: new Date().toISOString().split('T')[0],
};
// 강좌 목록에 추가
setLessons(prev => [...prev, newLesson]);
// 등록 모드 종료 및 폼 초기화
setIsRegistrationMode(false);
setSelectedCourse("");
setLessonName("");
setLearningGoal("");
setCourseVideoCount(0);
setCourseVideoFiles([]);
setVrContentCount(0);
setVrContentFiles([]);
setQuestionFileCount(0);
};
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{isRegistrationMode ? (
/* 등록 모드 */
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="h-[100px] flex items-center">
<div className="flex items-center gap-[12px]">
<button
type="button"
onClick={handleBackClick}
className="flex items-center justify-center size-[32px] cursor-pointer hover:opacity-80"
aria-label="뒤로가기"
>
<BackArrowSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
</div>
{/* 폼 콘텐츠 */}
<div className="flex-1 overflow-y-auto pb-[80px] pt-[32px]">
<div className="flex flex-col gap-[40px]">
{/* 강좌 정보 */}
<div className="flex flex-col gap-[8px]">
<h2 className="text-[18px] font-bold leading-[1.5] text-[#1b2027]">
</h2>
<div className="flex flex-col gap-[24px]">
{/* 교육 과정 */}
<div className="flex flex-col gap-[8px] max-w-[480px]">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
</label>
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-full h-[40px] px-[12px] py-[8px] border border-[#dee1e6] rounded-[8px] bg-white flex items-center justify-between focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47] cursor-pointer"
>
<span className={`text-[16px] font-normal leading-[1.5] flex-1 text-left ${
selectedCourse ? 'text-[#1b2027]' : 'text-[#6c7682]'
}`}>
{selectedCourse
? courseOptions.find(c => c.id === selectedCourse)?.name
: '교육과정을 선택해 주세요.'}
</span>
<DropdownIcon stroke="#8C95A1" className="shrink-0" />
</button>
{isDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-20 max-h-[200px] overflow-y-auto">
{courseOptions.map((course, index) => (
<button
key={course.id}
type="button"
onClick={() => {
setSelectedCourse(course.id);
setIsDropdownOpen(false);
}}
className={`w-full px-3 py-2 text-left text-[16px] font-normal leading-[1.5] hover:bg-[#f1f3f5] transition-colors cursor-pointer ${
selectedCourse === course.id
? "bg-[#ecf0ff] text-[#1f2b91] font-semibold"
: "text-[#1b2027]"
} ${
index === 0 ? "rounded-t-[8px]" : ""
} ${
index === courseOptions.length - 1 ? "rounded-b-[8px]" : ""
}`}
>
{course.name}
</button>
))}
</div>
)}
</div>
</div>
{/* 강좌명 */}
<div className="flex flex-col gap-[8px]">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
</label>
<input
type="text"
value={lessonName}
onChange={(e) => setLessonName(e.target.value)}
placeholder="강좌명을 입력해 주세요."
className="h-[40px] px-[12px] py-[8px] border border-[#dee1e6] rounded-[8px] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47]"
/>
</div>
{/* 학습 목표 */}
<div className="flex flex-col gap-[8px]">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
</label>
<div className="relative">
<textarea
value={learningGoal}
onChange={(e) => {
if (e.target.value.length <= 1000) {
setLearningGoal(e.target.value);
}
}}
placeholder="내용을 입력해 주세요. (최대 1,000자)"
className="w-full h-[160px] px-[12px] py-[8px] border border-[#dee1e6] rounded-[8px] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47] resize-none"
/>
<div className="absolute bottom-[8px] right-[12px]">
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
{learningGoal.length}/1000
</p>
</div>
</div>
</div>
</div>
</div>
{/* 파일 첨부 */}
<div className="flex flex-col gap-[8px]">
<h2 className="text-[18px] font-bold leading-[1.5] text-[#1b2027]">
</h2>
<div className="flex flex-col gap-[24px]">
{/* 강좌 영상 */}
<div className="flex flex-col gap-[8px]">
<div className="flex items-center justify-between h-[32px]">
<div className="flex items-center gap-[8px]">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
({courseVideoCount}/10)
</label>
<span className="text-[13px] font-normal leading-[1.4] text-[#8c95a1]">
30MB
</span>
</div>
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
<span></span>
<input
type="file"
multiple
accept=".mp4,video/mp4"
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
const mp4Files: string[] = [];
Array.from(files).forEach((file) => {
// mp4 파일이고 30MB 이하인 파일만 필터링
if (file.name.toLowerCase().endsWith('.mp4') && file.size <= MAX_SIZE) {
mp4Files.push(file.name);
}
});
if (courseVideoCount + mp4Files.length <= 10) {
setCourseVideoFiles(prev => [...prev, ...mp4Files]);
setCourseVideoCount(prev => prev + mp4Files.length);
}
// input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = '';
}}
/>
</label>
</div>
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
{courseVideoFiles.length === 0 ? (
<div className="h-[64px] flex items-center justify-center">
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
.
</p>
</div>
) : (
<div className="flex flex-col py-[12px]">
{courseVideoFiles.map((fileName, index) => (
<div
key={index}
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
>
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
{fileName}
</p>
<button
type="button"
onClick={() => {
setCourseVideoFiles(prev => prev.filter((_, i) => i !== index));
setCourseVideoCount(prev => prev - 1);
}}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
aria-label="파일 삭제"
>
<CloseXOSvg />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* VR 콘텐츠 */}
<div className="flex flex-col gap-[8px]">
<div className="flex items-center justify-between h-[32px]">
<div className="flex items-center gap-[8px]">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
VR ({vrContentCount}/10)
</label>
<span className="text-[13px] font-normal leading-[1.4] text-[#8c95a1]">
30MB
</span>
</div>
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
<span></span>
<input
type="file"
multiple
accept=".zip,application/zip"
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (!files) return;
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
const zipFiles: string[] = [];
Array.from(files).forEach((file) => {
// zip 파일이고 30MB 이하인 파일만 필터링
if (file.name.toLowerCase().endsWith('.zip') && file.size <= MAX_SIZE) {
zipFiles.push(file.name);
}
});
if (vrContentCount + zipFiles.length <= 10) {
setVrContentFiles(prev => [...prev, ...zipFiles]);
setVrContentCount(prev => prev + zipFiles.length);
}
// input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = '';
}}
/>
</label>
</div>
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
{vrContentFiles.length === 0 ? (
<div className="h-[64px] flex items-center justify-center">
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
VR .
</p>
</div>
) : (
<div className="flex flex-col py-[12px]">
{vrContentFiles.map((fileName, index) => (
<div
key={index}
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
>
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
{fileName}
</p>
<button
type="button"
onClick={() => {
setVrContentFiles(prev => prev.filter((_, i) => i !== index));
setVrContentCount(prev => prev - 1);
}}
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
aria-label="파일 삭제"
>
<CloseXOSvg />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* 학습 평가 문제 */}
<div className="flex flex-col gap-[8px]">
<div className="flex items-center justify-between h-[32px]">
<div className="flex items-center gap-[8px]">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
</label>
<span className="text-[13px] font-normal leading-[1.4] text-[#8c95a1]">
CSV
</span>
</div>
<div className="flex items-center gap-[8px]">
<button
type="button"
className="h-[32px] px-[16px] py-[3px] border border-[#b1b8c0] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#b1b8c0] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer"
>
</button>
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
<span></span>
<input
type="file"
accept=".csv"
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// CSV 파일만 허용
if (file.name.toLowerCase().endsWith('.csv')) {
setQuestionFileCount(1);
}
}}
/>
</label>
</div>
</div>
<div className="h-[64px] border border-[#dee1e6] rounded-[8px] bg-gray-50 flex items-center justify-center">
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
.
</p>
</div>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex items-center justify-end gap-[12px]">
<button
type="button"
onClick={handleBackClick}
className="h-[48px] px-[8px] rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] min-w-[80px] hover:bg-[#e5e7eb] transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSaveClick}
className="h-[48px] px-[16px] rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white hover:bg-[#1a2478] transition-colors cursor-pointer"
>
</button>
</div>
</div>
</div>
</div>
) : (
/* 목록 모드 */
<>
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
{/* 헤더 영역 (제목과 콘텐츠 사이) */}
<div className="pt-2 pb-4 flex items-center justify-between">
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47] whitespace-nowrap">
{totalCount}
</p>
<button
type="button"
onClick={handleRegisterClick}
className="h-[40px] px-4 rounded-[8px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
>
</button>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-2 flex flex-col">
{lessons.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
<span className="block text-center">
.
<br />
.
</span>
</p>
</div>
) : (
<>
<div className="rounded-[8px]">
<div className="w-full rounded-[8px] border border-[#dee1e6] overflow-visible">
<table className="min-w-full border-collapse">
<colgroup>
<col />
<col />
<col />
<col style={{ width: 140 }} />
<col style={{ width: 120 }} />
<col style={{ width: 120 }} />
</colgroup>
<thead>
<tr className="h-12 bg-gray-50 text-left">
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> </th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
</tr>
</thead>
<tbody>
{paginatedLessons.map((lesson) => (
<tr
key={lesson.id}
className="h-12 hover:bg-[#F5F7FF] transition-colors"
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{lesson.courseName}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{lesson.lessonName}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027]">
{lesson.attachments}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{lesson.questionCount}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{lesson.createdBy}
</td>
<td className="border-t border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{lesson.createdAt}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{lessons.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</>
)}
</div>
</>
)}
</div>
</main>
</div>
</div>
</div>
);
}

145
src/app/admin/logs/page.tsx Normal file
View File

@@ -0,0 +1,145 @@
'use client';
import { useState, useMemo } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
export default function AdminLogsPage() {
// TODO: 나중에 실제 데이터로 교체
const items: any[] = [];
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 10;
const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE);
const paginatedItems = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return items.slice(startIndex, endIndex);
}, [items, currentPage]);
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
/
</h1>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-8 flex flex-col">
{items.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
.
</p>
</div>
) : (
<>
{/* TODO: 테이블 또는 리스트를 여기에 추가 */}
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{items.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</>
)}
</div>
</div>
</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
export type Notice = {
id: number;
title: string;
date: string; // 게시일
views: number; // 조회수
writer: string; // 작성자
content?: string[]; // 본문 내용 (상세 페이지용)
hasAttachment?: boolean; // 첨부파일 여부
};
// TODO: 나중에 DB에서 가져오도록 변경
export const MOCK_NOTICES: Notice[] = [
{
id: 2,
title: '공지사항 제목이 노출돼요',
date: '2025-09-10',
views: 1230,
writer: '문지호',
content: [
'사이트 이용 관련 주요 변경 사항을 안내드립니다.',
'변경되는 내용은 공지일자로부터 즉시 적용됩니다.',
],
},
{
id: 1,
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
date: '2025-06-28',
views: 594,
writer: '문지호',
hasAttachment: true,
content: [
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
],
},
];

View File

@@ -0,0 +1,413 @@
'use client';
import { useState, useMemo, useRef, ChangeEvent } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
import BackArrowSvg from "@/app/svgs/backarrow";
import { MOCK_NOTICES, type Notice } from "@/app/admin/notices/mockData";
export default function AdminNoticesPage() {
const [notices, setNotices] = useState<Notice[]>(MOCK_NOTICES);
const [currentPage, setCurrentPage] = useState(1);
const [isWritingMode, setIsWritingMode] = useState(false);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [attachedFile, setAttachedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const totalCount = useMemo(() => notices.length, [notices]);
const characterCount = useMemo(() => content.length, [content]);
const handleBack = () => {
setIsWritingMode(false);
setTitle('');
setContent('');
setAttachedFile(null);
};
const handleFileAttach = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 30 * 1024 * 1024) {
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
return;
}
setAttachedFile(file);
}
};
const handleSave = () => {
if (!title.trim()) {
alert('제목을 입력해주세요.');
return;
}
if (!content.trim()) {
alert('내용을 입력해주세요.');
return;
}
// 새 공지사항 추가
const newNotice: Notice = {
id: notices.length > 0 ? Math.max(...notices.map(n => n.id)) + 1 : 1,
title: title.trim(),
date: new Date().toISOString().split('T')[0],
views: 0,
writer: '관리자', // TODO: 실제 작성자 정보 사용
content: content.split('\n'),
hasAttachment: attachedFile !== null,
};
setNotices([newNotice, ...notices]);
handleBack();
};
const handleCancel = () => {
if (title.trim() || content.trim() || attachedFile) {
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) {
handleBack();
}
} else {
handleBack();
}
};
const ITEMS_PER_PAGE = 10;
const sortedNotices = useMemo(() => {
return [...notices].sort((a, b) => {
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
return b.date.localeCompare(a.date);
});
}, [notices]);
const totalPages = Math.ceil(sortedNotices.length / ITEMS_PER_PAGE);
const paginatedNotices = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return sortedNotices.slice(startIndex, endIndex);
}, [sortedNotices, currentPage]);
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{isWritingMode ? (
<>
{/* 작성 모드 헤더 */}
<div className="h-[100px] flex items-center">
<div className="flex gap-3 items-center">
<button
type="button"
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 cursor-pointer"
aria-label="뒤로가기"
>
<BackArrowSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
</div>
{/* 작성 폼 */}
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
<div className="flex flex-col gap-6 w-full">
{/* 제목 입력 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력해 주세요."
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
/>
</div>
{/* 내용 입력 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
</label>
<div className="relative w-full">
<textarea
value={content}
onChange={(e) => {
const newContent = e.target.value;
if (newContent.length <= 1000) {
setContent(newContent);
}
}}
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
/>
<div className="absolute bottom-3 right-3">
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
{characterCount}/1000
</p>
</div>
</div>
</div>
{/* 첨부 파일 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<div className="flex items-center justify-between h-8 w-full">
<div className="flex items-center gap-3 flex-1 min-w-0">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
{' '}
<span className="font-normal">
({attachedFile ? 1 : 0}/1)
</span>
</label>
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
30MB
</p>
</div>
<button
type="button"
onClick={handleFileAttach}
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0"
>
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
</span>
</button>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
className="hidden"
accept="*/*"
/>
</div>
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-center">
{attachedFile ? (
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
{attachedFile.name}
</p>
) : (
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
.
</p>
)}
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
<button
type="button"
onClick={handleCancel}
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSave}
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
>
</button>
</div>
</div>
</>
) : (
<>
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
{/* 헤더 영역 (제목과 콘텐츠 사이) */}
<div className="pt-2 pb-4 flex items-center justify-between">
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47] whitespace-nowrap">
{totalCount}
</p>
<button
type="button"
onClick={() => setIsWritingMode(true)}
className="h-[40px] px-4 rounded-[8px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
>
</button>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-2 flex flex-col">
{notices.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
.
<br />
.
</p>
</div>
) : (
<>
<div className="rounded-[8px]">
<div className="w-full rounded-[8px] border border-[#dee1e6] overflow-visible">
<table className="min-w-full border-collapse">
<colgroup>
<col style={{ width: 80 }} />
<col />
<col style={{ width: 140 }} />
<col style={{ width: 120 }} />
<col style={{ width: 120 }} />
</colgroup>
<thead>
<tr className="h-12 bg-gray-50 text-left">
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
</tr>
</thead>
<tbody>
{paginatedNotices.map((notice, index) => {
// 번호는 전체 목록에서의 순서 (정렬된 목록 기준)
const noticeNumber = sortedNotices.length - (currentPage - 1) * ITEMS_PER_PAGE - index;
return (
<tr
key={notice.id}
className="h-12 hover:bg-[#F5F7FF] transition-colors"
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
{noticeNumber}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027]">
{notice.title}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{notice.date}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{notice.views.toLocaleString()}
</td>
<td className="border-t border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{notice.writer}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{notices.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</>
)}
</div>
</>
)}
</div>
</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useState, useMemo } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
export default function AdminQuestionsPage() {
// TODO: 나중에 실제 데이터로 교체
const items: any[] = [];
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 10;
const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE);
const paginatedItems = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return items.slice(startIndex, endIndex);
}, [items, currentPage]);
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-8 flex flex-col">
{items.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
.
</p>
</div>
) : (
<>
{/* TODO: 테이블 또는 리스트를 여기에 추가 */}
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{items.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</>
)}
</div>
</div>
</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useState, useMemo } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
export default function AdminResourcesPage() {
// TODO: 나중에 실제 데이터로 교체
const items: any[] = [];
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 10;
const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE);
const paginatedItems = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
return items.slice(startIndex, endIndex);
}, [items, currentPage]);
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-8 flex flex-col">
{items.length === 0 ? (
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
.
</p>
</div>
) : (
<>
{/* TODO: 테이블 또는 리스트를 여기에 추가 */}
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{items.length > ITEMS_PER_PAGE && (() => {
// 페이지 번호를 10단위로 표시
const pageGroup = Math.floor((currentPage - 1) / 10);
const startPage = pageGroup * 10 + 1;
const endPage = Math.min(startPage + 9, totalPages);
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
return (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
);
})()}
</>
)}
</div>
</div>
</main>
</div>
</div>
</div>
);
}

211
src/app/api/README.md Normal file
View File

@@ -0,0 +1,211 @@
# API 엔드포인트 문서
이 문서는 데이터베이스에 데이터를 생성하는 API 엔드포인트를 설명합니다.
## 📋 API 목록
### 1. 사용자 API (`/api/users`)
#### POST - 사용자 생성
```bash
POST /api/users
Content-Type: application/json
{
"email": "user@example.com",
"password": "hashed_password",
"name": "홍길동",
"phone": "010-1234-5678",
"gender": "M",
"birthdate": "1990-01-01",
"role": "LEARNER", // 또는 "ADMIN"
"status": "ACTIVE" // 또는 "INACTIVE"
}
```
**응답:**
```json
{
"message": "사용자가 성공적으로 생성되었습니다.",
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "홍길동",
...
}
}
```
#### GET - 사용자 목록 조회
```bash
GET /api/users?role=LEARNER&status=ACTIVE&page=1&limit=10
```
**쿼리 파라미터:**
- `role`: 필터링할 역할 (LEARNER, ADMIN)
- `status`: 필터링할 상태 (ACTIVE, INACTIVE)
- `page`: 페이지 번호 (기본값: 1)
- `limit`: 페이지당 항목 수 (기본값: 10)
---
### 2. 교육과정 API (`/api/courses`)
#### POST - 교육과정 생성
```bash
POST /api/courses
Content-Type: application/json
{
"courseName": "웹 개발 기초",
"instructorId": "instructor_uuid",
"createdById": "admin_uuid" // 선택사항, 기본값: instructorId
}
```
**응답:**
```json
{
"message": "교육과정이 성공적으로 생성되었습니다.",
"course": {
"id": "uuid",
"courseName": "웹 개발 기초",
"instructor": { ... },
"createdBy": { ... }
}
}
```
#### GET - 교육과정 목록 조회
```bash
GET /api/courses?instructorId=uuid&page=1&limit=10
```
---
### 3. 강좌 API (`/api/lessons`)
#### POST - 강좌 생성
```bash
POST /api/lessons
Content-Type: application/json
{
"courseId": "course_uuid",
"lessonName": "HTML 기초",
"learningGoal": "HTML의 기본 문법을 이해하고 활용할 수 있다.",
"createdById": "admin_uuid" // 선택사항
}
```
**응답:**
```json
{
"message": "강좌가 성공적으로 생성되었습니다.",
"lesson": {
"id": "uuid",
"lessonName": "HTML 기초",
"course": { ... },
"createdBy": { ... }
}
}
```
#### GET - 강좌 목록 조회
```bash
GET /api/lessons?courseId=uuid&page=1&limit=10
```
---
### 4. 공지사항 API (`/api/notices`)
#### POST - 공지사항 생성
```bash
POST /api/notices
Content-Type: application/json
{
"title": "공지사항 제목",
"content": "공지사항 내용",
"writerId": "admin_uuid",
"hasAttachment": false // 선택사항
}
```
**응답:**
```json
{
"message": "공지사항이 성공적으로 생성되었습니다.",
"notice": {
"id": "uuid",
"title": "공지사항 제목",
"content": "공지사항 내용",
"writer": { ... }
}
}
```
#### GET - 공지사항 목록 조회
```bash
GET /api/notices?writerId=uuid&page=1&limit=10
```
---
## 🔧 사용 예시
### JavaScript/TypeScript (fetch)
```typescript
// 사용자 생성
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'hashed_password',
name: '홍길동',
role: 'LEARNER',
}),
});
const data = await response.json();
console.log(data);
```
### cURL
```bash
# 사용자 생성
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "hashed_password",
"name": "홍길동",
"role": "LEARNER"
}'
```
---
## ⚠️ 주의사항
1. **비밀번호 해시화**: 실제 프로덕션에서는 비밀번호를 해시화하여 저장해야 합니다.
2. **인증/인가**: 현재 API는 인증이 없습니다. 프로덕션에서는 JWT나 세션 기반 인증을 추가해야 합니다.
3. **에러 처리**: 모든 API는 적절한 에러 응답을 반환합니다.
4. **데이터 검증**: 필수 필드 검증이 포함되어 있습니다.
---
## 📝 다음 단계
- [ ] 인증 미들웨어 추가
- [ ] 비밀번호 해시화 로직 추가
- [ ] 파일 업로드 API 추가 (공지사항 첨부파일 등)
- [ ] 수정/삭제 API 추가
- [ ] 상세 조회 API 추가

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// 교육과정 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { courseName, instructorId, createdById } = body;
// 필수 필드 검증
if (!courseName || !instructorId) {
return NextResponse.json(
{ error: '교육과정명과 강사 ID는 필수입니다.' },
{ status: 400 }
);
}
// 강사 존재 확인
const instructor = await prisma.user.findUnique({
where: { id: instructorId },
});
if (!instructor) {
return NextResponse.json(
{ error: '강사를 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 교육과정 생성
const course = await prisma.course.create({
data: {
courseName,
instructorId,
createdById: createdById || instructorId,
},
include: {
instructor: {
select: {
id: true,
name: true,
email: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
},
});
return NextResponse.json(
{ message: '교육과정이 성공적으로 생성되었습니다.', course },
{ status: 201 }
);
} catch (error) {
console.error('교육과정 생성 오류:', error);
return NextResponse.json(
{ error: '교육과정 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 교육과정 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const instructorId = searchParams.get('instructorId');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (instructorId) where.instructorId = instructorId;
const [courses, total] = await Promise.all([
prisma.course.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
instructor: {
select: {
id: true,
name: true,
email: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
lessons: true,
},
},
},
}),
prisma.course.count({ where }),
]);
return NextResponse.json({
courses,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('교육과정 조회 오류:', error);
return NextResponse.json(
{ error: '교육과정 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

272
src/app/api/examples.ts Normal file
View File

@@ -0,0 +1,272 @@
/**
* API 사용 예제 모음
*
* 이 파일은 API를 사용하는 다양한 예제를 제공합니다.
* 실제 프로젝트에서 참고하여 사용하세요.
*/
// ============================================
// 1. 사용자 API 예제
// ============================================
/**
* 사용자 생성 예제
*/
export async function createUserExample() {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'newuser@example.com',
password: 'hashed_password_here', // 실제로는 bcrypt로 해시화
name: '홍길동',
phone: '010-1234-5678',
gender: 'M',
birthdate: '1990-01-01',
role: 'LEARNER',
status: 'ACTIVE',
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.user;
}
/**
* 사용자 목록 조회 예제 (필터링)
*/
export async function getUsersExample() {
const params = new URLSearchParams({
role: 'LEARNER',
status: 'ACTIVE',
page: '1',
limit: '10',
});
const response = await fetch(`/api/users?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 2. 교육과정 API 예제
// ============================================
/**
* 교육과정 생성 예제
*/
export async function createCourseExample(instructorId: string) {
const response = await fetch('/api/courses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseName: '웹 개발 기초',
instructorId: instructorId,
createdById: instructorId, // 선택사항
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.course;
}
/**
* 교육과정 목록 조회 예제
*/
export async function getCoursesExample(instructorId?: string) {
const params = new URLSearchParams();
if (instructorId) {
params.append('instructorId', instructorId);
}
params.append('page', '1');
params.append('limit', '10');
const response = await fetch(`/api/courses?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 3. 강좌 API 예제
// ============================================
/**
* 강좌 생성 예제
*/
export async function createLessonExample(courseId: string) {
const response = await fetch('/api/lessons', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseId: courseId,
lessonName: 'HTML 기초',
learningGoal: 'HTML의 기본 문법을 이해하고 활용할 수 있다.',
createdById: undefined, // 선택사항
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.lesson;
}
/**
* 강좌 목록 조회 예제
*/
export async function getLessonsExample(courseId?: string) {
const params = new URLSearchParams();
if (courseId) {
params.append('courseId', courseId);
}
params.append('page', '1');
params.append('limit', '10');
const response = await fetch(`/api/lessons?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 4. 공지사항 API 예제
// ============================================
/**
* 공지사항 생성 예제
*/
export async function createNoticeExample(writerId: string) {
const response = await fetch('/api/notices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '공지사항 제목',
content: '공지사항 내용입니다.\n여러 줄로 작성할 수 있습니다.',
writerId: writerId,
hasAttachment: false,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.notice;
}
/**
* 공지사항 목록 조회 예제
*/
export async function getNoticesExample(writerId?: string) {
const params = new URLSearchParams();
if (writerId) {
params.append('writerId', writerId);
}
params.append('page', '1');
params.append('limit', '10');
const response = await fetch(`/api/notices?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 5. 통합 예제 - 전체 워크플로우
// ============================================
/**
* 전체 워크플로우 예제
* 1. 관리자 사용자 생성
* 2. 교육과정 생성
* 3. 강좌 생성
* 4. 공지사항 생성
*/
export async function completeWorkflowExample() {
try {
// 1. 관리자 사용자 생성
const adminUser = await createUserExample();
console.log('관리자 생성:', adminUser);
// 2. 교육과정 생성
const course = await createCourseExample(adminUser.id);
console.log('교육과정 생성:', course);
// 3. 강좌 생성
const lesson = await createLessonExample(course.id);
console.log('강좌 생성:', lesson);
// 4. 공지사항 생성
const notice = await createNoticeExample(adminUser.id);
console.log('공지사항 생성:', notice);
return {
admin: adminUser,
course,
lesson,
notice,
};
} catch (error) {
console.error('워크플로우 실행 중 오류:', error);
throw error;
}
}
// ============================================
// 6. 에러 처리 예제
// ============================================
/**
* 에러 처리를 포함한 안전한 API 호출 예제
*/
export async function safeApiCall<T>(
apiCall: () => Promise<Response>
): Promise<{ data?: T; error?: string }> {
try {
const response = await apiCall();
if (!response.ok) {
const errorData = await response.json();
return { error: errorData.error || '알 수 없는 오류가 발생했습니다.' };
}
const data = await response.json();
return { data: data as T };
} catch (error) {
return {
error: error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.',
};
}
}
// 사용 예시:
// const result = await safeApiCall(() =>
// fetch('/api/users', { method: 'POST', ... })
// );
// if (result.error) {
// console.error(result.error);
// } else {
// console.log(result.data);
// }

View File

@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// 강좌 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { courseId, lessonName, learningGoal, createdById } = body;
// 필수 필드 검증
if (!courseId || !lessonName) {
return NextResponse.json(
{ error: '교육과정 ID와 강좌명은 필수입니다.' },
{ status: 400 }
);
}
// 교육과정 존재 확인
const course = await prisma.course.findUnique({
where: { id: courseId },
});
if (!course) {
return NextResponse.json(
{ error: '교육과정을 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 강좌 생성
const lesson = await prisma.lesson.create({
data: {
courseId,
lessonName,
learningGoal: learningGoal || null,
createdById: createdById || course.createdById,
},
include: {
course: {
select: {
id: true,
courseName: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
},
});
return NextResponse.json(
{ message: '강좌가 성공적으로 생성되었습니다.', lesson },
{ status: 201 }
);
} catch (error) {
console.error('강좌 생성 오류:', error);
return NextResponse.json(
{ error: '강좌 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 강좌 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const courseId = searchParams.get('courseId');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (courseId) where.courseId = courseId;
const [lessons, total] = await Promise.all([
prisma.lesson.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
course: {
select: {
id: true,
courseName: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
videos: true,
vrContents: true,
questions: true,
},
},
},
}),
prisma.lesson.count({ where }),
]);
return NextResponse.json({
lessons,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('강좌 조회 오류:', error);
return NextResponse.json(
{ error: '강좌 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// 공지사항 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title, content, writerId, hasAttachment } = body;
// 필수 필드 검증
if (!title || !content || !writerId) {
return NextResponse.json(
{ error: '제목, 내용, 작성자 ID는 필수입니다.' },
{ status: 400 }
);
}
// 작성자 존재 확인
const writer = await prisma.user.findUnique({
where: { id: writerId },
});
if (!writer) {
return NextResponse.json(
{ error: '작성자를 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 공지사항 생성
const notice = await prisma.notice.create({
data: {
title,
content,
writerId,
hasAttachment: hasAttachment || false,
},
include: {
writer: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return NextResponse.json(
{ message: '공지사항이 성공적으로 생성되었습니다.', notice },
{ status: 201 }
);
} catch (error) {
console.error('공지사항 생성 오류:', error);
return NextResponse.json(
{ error: '공지사항 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 공지사항 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const writerId = searchParams.get('writerId');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (writerId) where.writerId = writerId;
const [notices, total] = await Promise.all([
prisma.notice.findMany({
where,
skip,
take: limit,
orderBy: { date: 'desc' },
include: {
writer: {
select: {
id: true,
name: true,
},
},
},
}),
prisma.notice.count({ where }),
]);
return NextResponse.json({
notices,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('공지사항 조회 오류:', error);
return NextResponse.json(
{ error: '공지사항 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

115
src/app/api/users/route.ts Normal file
View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { UserRole, UserStatus } from '@prisma/client';
// 사용자 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, name, phone, gender, birthdate, role, status } = body;
// 필수 필드 검증
if (!email || !password || !name) {
return NextResponse.json(
{ error: '이메일, 비밀번호, 이름은 필수입니다.' },
{ status: 400 }
);
}
// 이메일 중복 확인
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: '이미 존재하는 이메일입니다.' },
{ status: 409 }
);
}
// 사용자 생성
const user = await prisma.user.create({
data: {
email,
password, // 실제로는 해시화된 비밀번호를 저장해야 합니다
name,
phone: phone || null,
gender: gender || null,
birthdate: birthdate ? new Date(birthdate) : null,
role: role || UserRole.LEARNER,
status: status || UserStatus.ACTIVE,
},
});
// 비밀번호 제외하고 반환
const { password: _, ...userWithoutPassword } = user;
return NextResponse.json(
{ message: '사용자가 성공적으로 생성되었습니다.', user: userWithoutPassword },
{ status: 201 }
);
} catch (error) {
console.error('사용자 생성 오류:', error);
return NextResponse.json(
{ error: '사용자 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 사용자 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const role = searchParams.get('role');
const status = searchParams.get('status');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (role) where.role = role;
if (status) where.status = status;
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
name: true,
phone: true,
gender: true,
birthdate: true,
role: true,
status: true,
joinDate: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.user.count({ where }),
]);
return NextResponse.json({
users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('사용자 조회 오류:', error);
return NextResponse.json(
{ error: '사용자 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,51 @@
'use client';
import Link from "next/link";
import { usePathname } from "next/navigation";
type NavItem = {
label: string;
href: string;
};
const ADMIN_SIDEBAR_ITEMS: NavItem[] = [
{ label: "권한 설정", href: "/admin/id" },
{ label: "교육과정 관리", href: "/admin/courses" },
{ label: "강좌 관리", href: "/admin/lessons" },
{ label: "문제 은행", href: "/admin/questions" },
{ label: "수료증 발급/검증키 관리", href: "/admin/certificates" },
{ label: "공지사항", href: "/admin/notices" },
{ label: "학습 자료실", href: "/admin/resources" },
{ label: "로그/접속 기록", href: "/admin/logs" },
];
export default function AdminSidebar() {
const pathname = usePathname();
return (
<aside className="w-[320px] border-r border-[#dee1e6] bg-white flex-shrink-0 h-full">
<nav className="p-4">
<div className="flex flex-col gap-1">
{ADMIN_SIDEBAR_ITEMS.map((item) => {
const isActive = pathname === item.href || (item.href !== "#" && pathname.startsWith(item.href));
return (
<Link
key={item.label}
href={item.href}
className={[
"flex h-12 items-center px-3 rounded-lg text-[16px] leading-[1.5] transition-colors",
isActive
? "bg-[rgba(236,240,255,0.5)] font-bold text-[#1f2b91]"
: "font-medium text-[#333c47] hover:bg-[rgba(0,0,0,0.02)]",
].join(" ")}
>
{item.label}
</Link>
);
})}
</div>
</nav>
</aside>
);
}

View File

@@ -1,40 +1,7 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import BackCircleSvg from '../../svgs/backcirclesvg';
type NoticeItem = {
id: number;
title: string;
date: string;
views: number;
writer: string;
content: string[];
};
const DATA: NoticeItem[] = [
{
id: 2,
title: '공지사항 제목이 노출돼요',
date: '2025-09-10',
views: 1230,
writer: '문지호',
content: [
'사이트 이용 관련 주요 변경 사항을 안내드립니다.',
'변경되는 내용은 공지일자로부터 즉시 적용됩니다.',
],
},
{
id: 1,
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
date: '2025-06-28',
views: 594,
writer: '문지호',
content: [
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
],
},
];
import { MOCK_NOTICES } from '../../admin/notices/mockData';
export default async function NoticeDetailPage({
params,
@@ -43,8 +10,8 @@ export default async function NoticeDetailPage({
}) {
const { id } = await params;
const numericId = Number(id);
const item = DATA.find((r) => r.id === numericId);
if (!item) return notFound();
const item = MOCK_NOTICES.find((r) => r.id === numericId);
if (!item || !item.content) return notFound();
return (
<div className="w-full bg-white">

View File

@@ -2,36 +2,13 @@
import { useRouter } from 'next/navigation';
import PaperClipSvg from '../svgs/paperclipsvg';
type NoticeRow = {
id: number;
title: string;
date: string;
views: number;
writer: string;
hasAttachment?: boolean;
};
const rows: NoticeRow[] = [
{
id: 2,
title: '공지사항 제목이 노출돼요',
date: '2025-09-10',
views: 1230,
writer: '문지호',
},
{
id: 1,
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
date: '2025-06-28',
views: 594,
writer: '문지호',
hasAttachment: true,
},
];
import { MOCK_NOTICES } from '../admin/notices/mockData';
export default function NoticesPage() {
const router = useRouter();
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
const rows = [...MOCK_NOTICES].sort((a, b) => b.date.localeCompare(a.date));
return (
<div className="w-full bg-white">
@@ -74,25 +51,28 @@ export default function NoticesPage() {
{/* 바디 */}
<div>
{rows.map((r) => (
<div
key={r.id}
role="button"
tabIndex={0}
onClick={() => router.push(`/notices/${r.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
router.push(`/notices/${r.id}`);
}
}}
className={[
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
].join(' ')}
>
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
{r.id}
</div>
{rows.map((r, index) => {
// 번호는 정렬된 목록에서의 순서
const noticeNumber = rows.length - index;
return (
<div
key={r.id}
role="button"
tabIndex={0}
onClick={() => router.push(`/notices/${r.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
router.push(`/notices/${r.id}`);
}
}}
className={[
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
].join(' ')}
>
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
{noticeNumber}
</div>
<div
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
title={r.title}
@@ -109,8 +89,9 @@ export default function NoticesPage() {
{r.views.toLocaleString()}
</div>
<div className="flex items-center px-4">{r.writer}</div>
</div>
))}
</div>
);
})}
</div>
</div>
</section>

View File

@@ -0,0 +1,41 @@
import React from "react";
type BackArrowProps = React.SVGProps<SVGSVGElement>;
export default function BackArrowSvg(props: BackArrowProps) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 4V4C22.628 4 28 9.372 28 16V16C28 22.628 22.628 28 16 28V28C9.372 28 4 22.628 4 16V16C4 9.372 9.372 4 16 4Z"
fill="#8C95A1"
stroke="#8C95A1"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.6667 16.0002H21.3334"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.6667 20L10.6667 16L14.6667 12"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

41
src/app/svgs/closexo.tsx Normal file
View File

@@ -0,0 +1,41 @@
import React from "react";
type CloseXOSvgProps = React.SVGProps<SVGSVGElement>;
export default function CloseXOSvg(props: CloseXOSvgProps) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 14V14C4.686 14 2 11.314 2 8V8C2 4.686 4.686 2 8 2V2C11.314 2 14 4.686 14 8V8C14 11.314 11.314 14 8 14Z"
fill="#6C7682"
stroke="#6C7682"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.88661 6.11328L6.11328 9.88661"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.88661 9.88661L6.11328 6.11328"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,35 @@
import React from "react";
export default function DropdownIcon({
width = 16,
height = 16,
className = '',
stroke = "#8C95A1",
...props
}: {
width?: number | string;
height?: number | string;
className?: string;
stroke?: string;
[key: string]: any;
}) {
return (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}
>
<path
d="M14 5L8 11L2 5"
stroke={stroke}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

14
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;