Compare commits
8 Commits
84486ba7f9
...
6a546b6fcd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a546b6fcd | |||
| 24f17b1dd1 | |||
| 33d738f7d0 | |||
| f1515cdd72 | |||
| dbac109f5b | |||
| c94316f8ce | |||
| e768f267d3 | |||
| aca6fa93ea |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
565
API_USAGE.md
Normal file
565
API_USAGE.md
Normal 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
937
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -7,10 +7,24 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check",
|
"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": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0"
|
||||||
},
|
},
|
||||||
@@ -21,6 +35,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
prisma.config.ts
Normal file
13
prisma.config.ts
Normal 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
162
prisma/README.md
Normal 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)
|
||||||
|
|
||||||
311
prisma/migrations/20251121074818_new/migration.sql
Normal file
311
prisma/migrations/20251121074818_new/migration.sql
Normal 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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
313
prisma/schema.prisma
Normal 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
205
prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export default function NavBar() {
|
|||||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
|
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
|
||||||
|
const isAdminPage = pathname.startsWith('/admin');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserMenuOpen) return;
|
if (!isUserMenuOpen) return;
|
||||||
@@ -51,7 +52,7 @@ export default function NavBar() {
|
|||||||
<MainLogoSvg width={46.703} height={36} />
|
<MainLogoSvg width={46.703} height={36} />
|
||||||
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
|
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
|
||||||
</Link>
|
</Link>
|
||||||
{!hideCenterNav && (
|
{!hideCenterNav && !isAdminPage && (
|
||||||
<nav className="flex h-full items-center">
|
<nav className="flex h-full items-center">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +69,17 @@ export default function NavBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
|
{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 href="/menu/courses" className="px-4 py-2 text-[16px] font-semibold text-white">
|
||||||
내 강좌실
|
내 강좌실
|
||||||
</Link>
|
</Link>
|
||||||
@@ -109,6 +121,8 @@ export default function NavBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
145
src/app/admin/certificates/page.tsx
Normal file
145
src/app/admin/certificates/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
369
src/app/admin/courses/CourseRegistrationModal.tsx
Normal file
369
src/app/admin/courses/CourseRegistrationModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
103
src/app/admin/courses/mockData.ts
Normal file
103
src/app/admin/courses/mockData.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
302
src/app/admin/courses/page.tsx
Normal file
302
src/app/admin/courses/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/admin/id/mockData.ts
Normal file
57
src/app/admin/id/mockData.ts
Normal 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
503
src/app/admin/id/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
690
src/app/admin/lessons/page.tsx
Normal file
690
src/app/admin/lessons/page.tsx
Normal 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
145
src/app/admin/logs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
37
src/app/admin/notices/mockData.ts
Normal file
37
src/app/admin/notices/mockData.ts
Normal 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: [
|
||||||
|
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
|
||||||
|
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
413
src/app/admin/notices/page.tsx
Normal file
413
src/app/admin/notices/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
145
src/app/admin/questions/page.tsx
Normal file
145
src/app/admin/questions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
145
src/app/admin/resources/page.tsx
Normal file
145
src/app/admin/resources/page.tsx
Normal 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
211
src/app/api/README.md
Normal 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 추가
|
||||||
|
|
||||||
126
src/app/api/courses/route.ts
Normal file
126
src/app/api/courses/route.ts
Normal 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
272
src/app/api/examples.ts
Normal 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);
|
||||||
|
// }
|
||||||
|
|
||||||
127
src/app/api/lessons/route.ts
Normal file
127
src/app/api/lessons/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
109
src/app/api/notices/route.ts
Normal file
109
src/app/api/notices/route.ts
Normal 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
115
src/app/api/users/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
51
src/app/components/AdminSidebar.tsx
Normal file
51
src/app/components/AdminSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,40 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
|
import { MOCK_NOTICES } from '../../admin/notices/mockData';
|
||||||
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: [
|
|
||||||
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
|
|
||||||
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function NoticeDetailPage({
|
export default async function NoticeDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -43,8 +10,8 @@ export default async function NoticeDetailPage({
|
|||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
const item = DATA.find((r) => r.id === numericId);
|
const item = MOCK_NOTICES.find((r) => r.id === numericId);
|
||||||
if (!item) return notFound();
|
if (!item || !item.content) return notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
|
|||||||
@@ -2,37 +2,14 @@
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import PaperClipSvg from '../svgs/paperclipsvg';
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||||
|
import { MOCK_NOTICES } from '../admin/notices/mockData';
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NoticesPage() {
|
export default function NoticesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
const rows = [...MOCK_NOTICES].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@@ -74,7 +51,10 @@ export default function NoticesPage() {
|
|||||||
|
|
||||||
{/* 바디 */}
|
{/* 바디 */}
|
||||||
<div>
|
<div>
|
||||||
{rows.map((r) => (
|
{rows.map((r, index) => {
|
||||||
|
// 번호는 정렬된 목록에서의 순서
|
||||||
|
const noticeNumber = rows.length - index;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -91,7 +71,7 @@ export default function NoticesPage() {
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
||||||
{r.id}
|
{noticeNumber}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
@@ -110,7 +90,8 @@ export default function NoticesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4">{r.writer}</div>
|
<div className="flex items-center px-4">{r.writer}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
41
src/app/svgs/backarrow.tsx
Normal file
41
src/app/svgs/backarrow.tsx
Normal 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
41
src/app/svgs/closexo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/app/svgs/dropdownicon.tsx
Normal file
35
src/app/svgs/dropdownicon.tsx
Normal 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
14
src/lib/prisma.ts
Normal 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;
|
||||||
|
|
||||||
Reference in New Issue
Block a user