교육과정, 강의등록 완료1
This commit is contained in:
@@ -1,13 +0,0 @@
|
|||||||
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"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,7 @@ import DropdownIcon from "@/app/svgs/dropdownicon";
|
|||||||
import CloseXOSvg from "@/app/svgs/closexo";
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
import { type UserRow } from "@/app/admin/id/mockData";
|
import { type UserRow } from "@/app/admin/id/mockData";
|
||||||
import { type Course } from "./mockData";
|
import { type Course } from "./mockData";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,6 +26,8 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isImageDeleted, setIsImageDeleted] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -138,12 +141,47 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
return instructors.find(inst => inst.id === instructorId);
|
return instructors.find(inst => inst.id === instructorId);
|
||||||
}, [instructors, instructorId]);
|
}, [instructors, instructorId]);
|
||||||
|
|
||||||
|
// previewUrl 변경 시 이전 Blob URL 정리
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
// 수정 모드일 때 기존 데이터 채우기
|
// 수정 모드일 때 기존 데이터 채우기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && editingCourse) {
|
if (open && editingCourse) {
|
||||||
setCourseName(editingCourse.courseName);
|
setCourseName(editingCourse.courseName);
|
||||||
// 수정 모드일 때 강사 목록 자동 로드
|
// 수정 모드일 때 강사 목록 자동 로드
|
||||||
loadInstructors();
|
loadInstructors();
|
||||||
|
|
||||||
|
// 수정 모드일 때 이미지 로드
|
||||||
|
if (editingCourse.imageKey) {
|
||||||
|
setIsImageDeleted(false); // 초기화
|
||||||
|
setSelectedImage(null); // 새 이미지 선택 초기화
|
||||||
|
const loadImage = async () => {
|
||||||
|
try {
|
||||||
|
const imageUrl = await apiService.getFile(editingCourse.imageKey!);
|
||||||
|
// 이미지가 있으면 previewUrl 설정, 없으면 null
|
||||||
|
if (imageUrl) {
|
||||||
|
setPreviewUrl(imageUrl);
|
||||||
|
} else {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이미지 로드 오류:', error);
|
||||||
|
// 이미지 로드 실패 시 null로 설정
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadImage();
|
||||||
|
} else {
|
||||||
|
setIsImageDeleted(false); // 초기화
|
||||||
|
setSelectedImage(null); // 새 이미지 선택 초기화
|
||||||
|
setPreviewUrl(null); // 이미지가 없으면 명시적으로 null 설정
|
||||||
|
}
|
||||||
} else if (!open) {
|
} else if (!open) {
|
||||||
setCourseName("");
|
setCourseName("");
|
||||||
setInstructorId("");
|
setInstructorId("");
|
||||||
@@ -153,6 +191,7 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
setSelectedImage(null);
|
setSelectedImage(null);
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setIsImageDeleted(false);
|
||||||
setInstructors([]); // 모달 닫을 때 강사 목록 초기화
|
setInstructors([]); // 모달 닫을 때 강사 목록 초기화
|
||||||
}
|
}
|
||||||
}, [open, editingCourse]);
|
}, [open, editingCourse]);
|
||||||
@@ -224,106 +263,24 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 토큰 가져오기
|
|
||||||
const token = localStorage.getItem('token') || document.cookie
|
|
||||||
.split('; ')
|
|
||||||
.find(row => row.startsWith('token='))
|
|
||||||
?.split('=')[1];
|
|
||||||
|
|
||||||
// API base URL 설정
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
|
||||||
? process.env.NEXT_PUBLIC_API_BASE_URL
|
|
||||||
: 'https://hrdi.coconutmeet.net';
|
|
||||||
|
|
||||||
let imageKey: string | null = null;
|
let imageKey: string | null = null;
|
||||||
|
|
||||||
// 이미지가 있으면 먼저 업로드하여 imageKey 받기
|
// 새 이미지가 선택된 경우 업로드
|
||||||
if (selectedImage) {
|
if (selectedImage) {
|
||||||
try {
|
try {
|
||||||
// 이미지 업로드 API 호출 - 일반적인 엔드포인트 경로 시도
|
const uploadResponse = await apiService.uploadFile(selectedImage);
|
||||||
const possibleEndpoints = [
|
|
||||||
`${baseUrl}/admin/files/upload`,
|
|
||||||
`${baseUrl}/admin/files`,
|
|
||||||
`${baseUrl}/admin/upload`,
|
|
||||||
`${baseUrl}/admin/images/upload`,
|
|
||||||
`${baseUrl}/admin/images`,
|
|
||||||
`${baseUrl}/files/upload`,
|
|
||||||
`${baseUrl}/files`,
|
|
||||||
`${baseUrl}/upload`,
|
|
||||||
`${baseUrl}/api/files/upload`,
|
|
||||||
`${baseUrl}/api/upload`,
|
|
||||||
`${baseUrl}/api/files`,
|
|
||||||
];
|
|
||||||
|
|
||||||
let uploadResponse: Response | null = null;
|
// 응답에서 imageKey 추출
|
||||||
let lastError: Error | null = null;
|
// apiService.uploadFile은 ApiResponse<T> 형태로 반환하므로 uploadResponse.data가 실제 응답 데이터
|
||||||
let uploadSuccess = false;
|
if (uploadResponse.data) {
|
||||||
let lastStatusCode: number | null = null;
|
// 다양한 가능한 응답 구조 확인
|
||||||
|
imageKey = uploadResponse.data.imageKey
|
||||||
// 여러 엔드포인트를 시도
|
|| uploadResponse.data.key
|
||||||
for (const uploadUrl of possibleEndpoints) {
|
|| uploadResponse.data.id
|
||||||
try {
|
|| uploadResponse.data.fileKey
|
||||||
// 각 시도마다 새로운 FormData 생성 (FormData는 한 번만 사용 가능)
|
|| uploadResponse.data.fileId
|
||||||
const formData = new FormData();
|
|| (uploadResponse.data.data && (uploadResponse.data.data.imageKey || uploadResponse.data.data.key))
|
||||||
formData.append('file', selectedImage);
|
|| null;
|
||||||
// 일부 API는 'image' 필드명을 사용할 수 있음
|
|
||||||
formData.append('image', selectedImage);
|
|
||||||
|
|
||||||
uploadResponse = await fetch(uploadUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
// Content-Type은 FormData 사용 시 자동으로 설정되므로 명시하지 않음
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
lastStatusCode = uploadResponse.status;
|
|
||||||
|
|
||||||
if (uploadResponse.ok) {
|
|
||||||
const uploadData = await uploadResponse.json();
|
|
||||||
// 응답에서 imageKey 추출 (실제 응답 구조에 맞게 수정 필요)
|
|
||||||
imageKey = uploadData.imageKey || uploadData.key || uploadData.id || uploadData.fileKey || uploadData.fileId || uploadData.data?.key || uploadData.data?.imageKey || null;
|
|
||||||
uploadSuccess = true;
|
|
||||||
break; // 성공하면 루프 종료
|
|
||||||
} else if (uploadResponse.status !== 404) {
|
|
||||||
// 404가 아닌 다른 에러면 해당 엔드포인트가 맞을 수 있으므로 에러 정보 저장
|
|
||||||
try {
|
|
||||||
const errorData = await uploadResponse.json();
|
|
||||||
lastError = new Error(
|
|
||||||
errorData.message || errorData.error || `이미지 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}`
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
lastError = new Error(`이미지 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
|
||||||
}
|
|
||||||
// 401, 403 같은 인증/권한 오류는 더 이상 시도하지 않음
|
|
||||||
if (uploadResponse.status === 401 || uploadResponse.status === 403) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (fetchError) {
|
|
||||||
// 네트워크 에러 등은 무시하고 다음 엔드포인트 시도
|
|
||||||
if (!lastError) {
|
|
||||||
lastError = fetchError instanceof Error ? fetchError : new Error('네트워크 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uploadSuccess) {
|
|
||||||
// 모든 엔드포인트가 404를 반환한 경우와 다른 오류를 구분
|
|
||||||
if (lastStatusCode === 404 || !lastStatusCode) {
|
|
||||||
console.warn('이미지 업로드 엔드포인트를 찾을 수 없습니다. 이미지 없이 계속 진행합니다.');
|
|
||||||
} else {
|
|
||||||
const errorMessage = lastError?.message || '이미지 업로드에 실패했습니다.';
|
|
||||||
console.error('이미지 업로드 실패:', errorMessage);
|
|
||||||
}
|
|
||||||
// 이미지 업로드 실패 시 사용자에게 알림 (경고 수준)
|
|
||||||
setErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
image: '이미지 업로드에 실패했습니다. 이미지 없이 계속 진행됩니다.',
|
|
||||||
}));
|
|
||||||
// 이미지 업로드 실패해도 계속 진행 (선택사항)
|
|
||||||
}
|
}
|
||||||
} catch (uploadError) {
|
} catch (uploadError) {
|
||||||
const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.';
|
const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.';
|
||||||
@@ -336,20 +293,67 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /subjects API 호출
|
const token = localStorage.getItem('token') || document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||||
|
? process.env.NEXT_PUBLIC_API_BASE_URL
|
||||||
|
: 'https://hrdi.coconutmeet.net';
|
||||||
|
|
||||||
const requestBody: {
|
const requestBody: {
|
||||||
title: string;
|
title: string;
|
||||||
instructor: string;
|
instructor: string;
|
||||||
imageKey?: string | null;
|
imageKey?: string;
|
||||||
} = {
|
} = {
|
||||||
title: courseName.trim(),
|
title: courseName.trim(),
|
||||||
instructor: selectedInstructor.name,
|
instructor: selectedInstructor.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// imageKey 처리: 수정 모드에서는 항상 명시적으로 설정
|
||||||
|
if (editingCourse && editingCourse.id) {
|
||||||
|
// 수정 모드: 이미지가 삭제된 경우 "null" 문자열, 새 이미지가 있으면 값, 기존 이미지 유지 시 기존 값, 없으면 "null"
|
||||||
|
if (isImageDeleted) {
|
||||||
|
// 이미지가 삭제된 경우 무조건 "null" 문자열로 설정
|
||||||
|
requestBody.imageKey = "null";
|
||||||
|
} else if (imageKey) {
|
||||||
|
// 새 이미지가 업로드된 경우
|
||||||
|
requestBody.imageKey = imageKey;
|
||||||
|
} else if (editingCourse.imageKey) {
|
||||||
|
// 기존 이미지를 유지하는 경우
|
||||||
|
requestBody.imageKey = editingCourse.imageKey;
|
||||||
|
} else {
|
||||||
|
// 이미지가 없는 경우 "null" 문자열로 명시적으로 설정
|
||||||
|
requestBody.imageKey = "null";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 등록 모드: 새 이미지가 있으면 값, 없으면 undefined (선택사항)
|
||||||
if (imageKey) {
|
if (imageKey) {
|
||||||
requestBody.imageKey = imageKey;
|
requestBody.imageKey = imageKey;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정 모드인지 등록 모드인지 확인
|
||||||
|
if (editingCourse && editingCourse.id) {
|
||||||
|
// 수정 모드: PUT /subjects/{id}
|
||||||
|
try {
|
||||||
|
await apiService.updateSubject(editingCourse.id, requestBody);
|
||||||
|
|
||||||
|
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
||||||
|
if (onSave && selectedInstructor) {
|
||||||
|
onSave(courseName.trim(), selectedInstructor.name);
|
||||||
|
}
|
||||||
|
onClose(); // 모달 닫기
|
||||||
|
} catch (updateError) {
|
||||||
|
const errorMessage = updateError instanceof Error ? updateError.message : '과목 수정 중 오류가 발생했습니다.';
|
||||||
|
console.error('과목 수정 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 등록 모드: POST /subjects
|
||||||
const response = await fetch(`${baseUrl}/subjects`, {
|
const response = await fetch(`${baseUrl}/subjects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -377,13 +381,29 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성공 시 onSave 콜백 호출
|
// 응답에서 id 추출하여 저장
|
||||||
|
try {
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
// 응답에서 id 추출 (다양한 가능한 필드명 확인)
|
||||||
|
const subjectId = responseData.id
|
||||||
|
|| responseData.data?.id
|
||||||
|
|| responseData.subjectId
|
||||||
|
|| responseData.data?.subjectId
|
||||||
|
|| null;
|
||||||
|
} catch (parseError) {
|
||||||
|
// 응답 파싱 실패 시 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
||||||
if (onSave && selectedInstructor) {
|
if (onSave && selectedInstructor) {
|
||||||
onSave(courseName.trim(), selectedInstructor.name);
|
onSave(courseName.trim(), selectedInstructor.name);
|
||||||
}
|
}
|
||||||
|
onClose(); // 모달 닫기
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
console.error('과목 등록 오류:', errorMessage);
|
console.error('과목 저장 오류:', errorMessage);
|
||||||
setErrors({ submit: errorMessage });
|
setErrors({ submit: errorMessage });
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -396,11 +416,37 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 삭제 확인 핸들러
|
// 삭제 확인 핸들러
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!editingCourse || !editingCourse.id) {
|
||||||
|
console.error('삭제할 교육과정 정보가 없습니다.');
|
||||||
|
setErrors({ submit: '삭제할 교육과정 정보가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeleting) return; // 이미 삭제 중이면 중복 호출 방지
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.submit;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.deleteSubject(editingCourse.id);
|
||||||
|
|
||||||
|
// 성공 시 모달 닫기 및 콜백 호출
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
if (onDelete) {
|
if (onDelete) {
|
||||||
onDelete();
|
onDelete();
|
||||||
setIsDeleteConfirmOpen(false);
|
}
|
||||||
onClose();
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '교육과정 삭제 중 오류가 발생했습니다.';
|
||||||
|
console.error('교육과정 삭제 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -434,12 +480,18 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedImage(file);
|
setSelectedImage(file);
|
||||||
|
setIsImageDeleted(false); // 새 이미지 선택 시 삭제 상태 해제
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next.image;
|
delete next.image;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 기존 previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// 미리보기 URL 생성
|
// 미리보기 URL 생성
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
@@ -491,7 +543,12 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
const handleRemoveImage = (e: React.MouseEvent) => {
|
const handleRemoveImage = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedImage(null);
|
setSelectedImage(null);
|
||||||
|
// previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
|
setIsImageDeleted(true); // 이미지 삭제 상태 설정
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
@@ -800,6 +857,9 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
<br />
|
<br />
|
||||||
정말 삭제하시겠습니까?
|
정말 삭제하시겠습니까?
|
||||||
</p>
|
</p>
|
||||||
|
{errors.submit && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center justify-end">
|
<div className="flex gap-2 items-center justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -812,9 +872,10 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
className="h-[40px] px-4 rounded-[8px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] hover:bg-[#fae6e6] cursor-pointer transition-colors"
|
disabled={isDeleting}
|
||||||
|
className="h-[40px] px-4 rounded-[8px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] hover:bg-[#fae6e6] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
삭제하기
|
{isDeleting ? '삭제 중...' : '삭제하기'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,39 +7,71 @@ export type Course = {
|
|||||||
createdAt: string; // 생성일 (YYYY-MM-DD)
|
createdAt: string; // 생성일 (YYYY-MM-DD)
|
||||||
createdBy: string; // 등록자
|
createdBy: string; // 등록자
|
||||||
hasLessons: boolean; // 강좌포함여부
|
hasLessons: boolean; // 강좌포함여부
|
||||||
|
imageKey?: string; // 이미지 키
|
||||||
};
|
};
|
||||||
|
|
||||||
// 과목 리스트 조회 API
|
// 과목 리스트 조회 API
|
||||||
export async function getCourses(): Promise<Course[]> {
|
export async function getCourses(): Promise<Course[]> {
|
||||||
try {
|
try {
|
||||||
const response = await apiService.getSubjects();
|
// 교육과정과 강좌 리스트를 동시에 가져오기
|
||||||
const data = response.data;
|
const [subjectsResponse, lecturesResponse] = await Promise.all([
|
||||||
|
apiService.getSubjects(),
|
||||||
|
apiService.getLectures().catch(() => ({ data: [] })), // 강좌 리스트 조회 실패 시 빈 배열 반환
|
||||||
|
]);
|
||||||
|
|
||||||
|
const subjectsData = subjectsResponse.data;
|
||||||
|
const lecturesData = lecturesResponse.data || [];
|
||||||
|
|
||||||
// 디버깅: API 응답 구조 확인
|
// 디버깅: API 응답 구조 확인
|
||||||
console.log('🔍 [getCourses] API 원본 응답:', data);
|
console.log('🔍 [getCourses] API 원본 응답:', subjectsData);
|
||||||
console.log('🔍 [getCourses] 응답 타입:', Array.isArray(data) ? '배열' : typeof data);
|
console.log('🔍 [getCourses] 응답 타입:', Array.isArray(subjectsData) ? '배열' : typeof subjectsData);
|
||||||
|
console.log('🔍 [getCourses] 강좌 리스트:', lecturesData);
|
||||||
|
|
||||||
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
let coursesArray: any[] = [];
|
let coursesArray: any[] = [];
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(subjectsData)) {
|
||||||
coursesArray = data;
|
coursesArray = subjectsData;
|
||||||
} else if (data && typeof data === 'object') {
|
} else if (subjectsData && typeof subjectsData === 'object') {
|
||||||
// 더 많은 가능한 필드명 확인
|
// 더 많은 가능한 필드명 확인
|
||||||
coursesArray = data.items || data.courses || data.data || data.list || data.subjects || data.subjectList || [];
|
coursesArray = subjectsData.items || subjectsData.courses || subjectsData.data || subjectsData.list || subjectsData.subjects || subjectsData.subjectList || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 강좌 리스트가 배열이 아닌 경우 처리
|
||||||
|
let lecturesArray: any[] = [];
|
||||||
|
if (Array.isArray(lecturesData)) {
|
||||||
|
lecturesArray = lecturesData;
|
||||||
|
} else if (lecturesData && typeof lecturesData === 'object') {
|
||||||
|
lecturesArray = lecturesData.items || lecturesData.lectures || lecturesData.data || lecturesData.list || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔍 [getCourses] 변환 전 배열:', coursesArray);
|
console.log('🔍 [getCourses] 변환 전 배열:', coursesArray);
|
||||||
console.log('🔍 [getCourses] 배열 길이:', coursesArray.length);
|
console.log('🔍 [getCourses] 배열 길이:', coursesArray.length);
|
||||||
|
console.log('🔍 [getCourses] 강좌 배열 길이:', lecturesArray.length);
|
||||||
|
|
||||||
|
// 각 교육과정에 대해 강좌가 있는지 확인하기 위한 Set 생성
|
||||||
|
const courseIdsWithLessons = new Set<string>();
|
||||||
|
lecturesArray.forEach((lecture: any) => {
|
||||||
|
const subjectId = String(lecture.subjectId || lecture.subject_id || '');
|
||||||
|
if (subjectId) {
|
||||||
|
courseIdsWithLessons.add(subjectId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔍 [getCourses] 강좌가 있는 교육과정 ID:', Array.from(courseIdsWithLessons));
|
||||||
|
|
||||||
// API 응답 데이터를 Course 형식으로 변환
|
// API 응답 데이터를 Course 형식으로 변환
|
||||||
const transformedCourses: Course[] = coursesArray.map((item: any) => {
|
const transformedCourses: Course[] = coursesArray.map((item: any) => {
|
||||||
|
const courseId = String(item.id || item.subjectId || item.subject_id || '');
|
||||||
|
const hasLessons = courseIdsWithLessons.has(courseId);
|
||||||
|
|
||||||
const transformed = {
|
const transformed = {
|
||||||
id: String(item.id || item.subjectId || item.subject_id || ''),
|
id: courseId,
|
||||||
courseName: item.courseName || item.name || item.subjectName || item.subject_name || item.title || '',
|
courseName: item.courseName || item.name || item.subjectName || item.subject_name || item.title || '',
|
||||||
instructorName: item.instructorName || item.instructor || item.instructor_name || item.teacherName || '',
|
instructorName: item.instructorName || item.instructor || item.instructor_name || item.teacherName || '',
|
||||||
createdAt: item.createdAt || item.createdDate || item.created_date || item.createdAt || '',
|
createdAt: item.createdAt || item.createdDate || item.created_date || item.createdAt || '',
|
||||||
createdBy: item.createdBy || item.creator || item.created_by || item.creatorName || '',
|
createdBy: item.createdBy || item.creator || item.created_by || item.creatorName || '',
|
||||||
hasLessons: item.hasLessons !== undefined ? item.hasLessons : (item.has_lessons !== undefined ? item.has_lessons : false),
|
hasLessons: hasLessons,
|
||||||
|
imageKey: item.imageKey || item.image_key || item.fileKey || item.file_key || undefined,
|
||||||
};
|
};
|
||||||
console.log('🔍 [getCourses] 변환된 항목:', transformed);
|
console.log('🔍 [getCourses] 변환된 항목:', transformed);
|
||||||
return transformed;
|
return transformed;
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ import CourseRegistrationModal from "./CourseRegistrationModal";
|
|||||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
import { getCourses, type Course } from "./mockData";
|
import { getCourses, type Course } from "./mockData";
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 변환하는 함수
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
// 이미 yyyy-mm-dd 형식이거나 파싱 실패 시 원본 반환
|
||||||
|
if (dateString.includes('T')) {
|
||||||
|
return dateString.split('T')[0];
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminCoursesPage() {
|
export default function AdminCoursesPage() {
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -199,7 +217,7 @@ export default function AdminCoursesPage() {
|
|||||||
{course.instructorName}
|
{course.instructorName}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
{course.createdAt}
|
{formatDate(course.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-t border-[#dee1e6] px-4 text-left text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
<td className="border-t border-[#dee1e6] px-4 text-left text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
{course.hasLessons ? (
|
{course.hasLessons ? (
|
||||||
|
|||||||
320
src/app/admin/lessons/[id]/page.tsx
Normal file
320
src/app/admin/lessons/[id]/page.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import AdminSidebar from '@/app/components/AdminSidebar';
|
||||||
|
import BackArrowSvg from '@/app/svgs/backarrow';
|
||||||
|
import apiService from '@/app/lib/apiService';
|
||||||
|
|
||||||
|
type Lesson = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
duration: string; // "12:46" 형식
|
||||||
|
state: "제출완료" | "제출대기";
|
||||||
|
action: "복습하기" | "이어서 수강하기" | "수강하기";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CourseDetail = {
|
||||||
|
id: string;
|
||||||
|
status: "수강 중" | "수강 예정" | "수강 완료";
|
||||||
|
title: string;
|
||||||
|
goal: string;
|
||||||
|
method: string;
|
||||||
|
summary: string; // VOD · 총 n강 · n시간 n분
|
||||||
|
submitSummary: string; // 학습 제출 n/n
|
||||||
|
thumbnail: string;
|
||||||
|
lessons: Lesson[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminCourseDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [course, setCourse] = useState<CourseDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCourse = async () => {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// sessionStorage에서 저장된 강좌 데이터 가져오기
|
||||||
|
let data: any = null;
|
||||||
|
const storedData = typeof window !== 'undefined' ? sessionStorage.getItem('selectedLecture') : null;
|
||||||
|
|
||||||
|
if (storedData) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(storedData);
|
||||||
|
// 사용 후 삭제
|
||||||
|
sessionStorage.removeItem('selectedLecture');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('저장된 데이터 파싱 실패:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionStorage에 데이터가 없으면 강좌 리스트에서 찾기
|
||||||
|
if (!data) {
|
||||||
|
try {
|
||||||
|
const response = await apiService.getLectures();
|
||||||
|
const lectures = Array.isArray(response.data)
|
||||||
|
? response.data
|
||||||
|
: response.data?.items || response.data?.lectures || response.data?.data || [];
|
||||||
|
|
||||||
|
data = lectures.find((l: any) => String(l.id || l.lectureId) === params.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('강좌 리스트 조회 실패:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('강좌를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 정보 구성
|
||||||
|
const attachmentParts: string[] = [];
|
||||||
|
if (data.videoUrl) {
|
||||||
|
attachmentParts.push('강좌영상 1개');
|
||||||
|
}
|
||||||
|
if (data.webglUrl) {
|
||||||
|
attachmentParts.push('VR콘텐츠 1개');
|
||||||
|
}
|
||||||
|
if (data.csvKey) {
|
||||||
|
attachmentParts.push('평가문제 1개');
|
||||||
|
}
|
||||||
|
const attachments = attachmentParts.length > 0
|
||||||
|
? attachmentParts.join(', ')
|
||||||
|
: '없음';
|
||||||
|
|
||||||
|
// 썸네일 이미지 가져오기
|
||||||
|
let thumbnail = '/imgs/talk.png';
|
||||||
|
if (data.imageKey) {
|
||||||
|
try {
|
||||||
|
const imageUrl = await apiService.getFile(data.imageKey);
|
||||||
|
if (imageUrl) {
|
||||||
|
thumbnail = imageUrl;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('이미지 로드 실패:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 구조에 맞게 데이터 매핑
|
||||||
|
const courseDetail: CourseDetail = {
|
||||||
|
id: String(data.id || params.id),
|
||||||
|
status: "수강 예정", // 관리자 페이지에서는 기본값
|
||||||
|
title: data.title || data.lectureName || '',
|
||||||
|
goal: data.objective || data.goal || '',
|
||||||
|
method: data.method || '',
|
||||||
|
summary: `VOD · 총 1강`,
|
||||||
|
submitSummary: '',
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
lessons: [
|
||||||
|
{
|
||||||
|
id: String(data.id || params.id),
|
||||||
|
title: data.title || data.lectureName || '',
|
||||||
|
duration: '00:00',
|
||||||
|
state: "제출대기",
|
||||||
|
action: "수강하기",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
setCourse(courseDetail);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('강좌 조회 실패:', err);
|
||||||
|
setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCourse();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
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>
|
||||||
|
<section className="px-8 pb-20">
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<p className="text-[16px] text-[#8c95a1]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !course) {
|
||||||
|
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>
|
||||||
|
<section className="px-8 pb-20">
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/lessons')}
|
||||||
|
className="px-4 py-2 rounded-[6px] bg-[#1f2b91] text-white text-[14px] font-medium"
|
||||||
|
>
|
||||||
|
강좌 목록으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex items-center gap-[12px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/lessons')}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 콘텐츠 */}
|
||||||
|
<section className="pb-20">
|
||||||
|
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6">
|
||||||
|
{/* 상단 소개 카드 */}
|
||||||
|
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
|
||||||
|
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
|
||||||
|
<Image
|
||||||
|
src={course.thumbnail}
|
||||||
|
alt={course.title}
|
||||||
|
fill
|
||||||
|
sizes="292px"
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex h-[27px] items-center gap-2">
|
||||||
|
<span className="h-[20px] rounded-[4px] bg-[#e5f5ec] px-1.5 text-[13px] font-semibold leading-[1.4] text-[#0c9d61]">
|
||||||
|
{course.status}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{course.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||||
|
<span className="font-medium">학습 목표:</span> {course.goal}
|
||||||
|
</p>
|
||||||
|
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||||
|
<span className="font-medium">학습 방법:</span> {course.method}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
|
||||||
|
<span>{course.summary}</span>
|
||||||
|
{course.submitSummary && <span>{course.submitSummary}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차시 리스트 */}
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
{course.lessons.map((l) => {
|
||||||
|
const isSubmitted = l.state === "제출완료";
|
||||||
|
const submitBtnStyle =
|
||||||
|
l.state === "제출완료"
|
||||||
|
? "border border-transparent text-[#384fbf]"
|
||||||
|
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||||
|
const rightBtnStyle =
|
||||||
|
l.action === "이어서 수강하기"
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: l.action === "수강하기"
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-3">
|
||||||
|
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||||
|
"bg-white",
|
||||||
|
submitBtnStyle,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||||
|
rightBtnStyle,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{l.action}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useRef, useEffect } from "react";
|
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import AdminSidebar from "@/app/components/AdminSidebar";
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
import DropdownIcon from "@/app/svgs/dropdownicon";
|
import DropdownIcon from "@/app/svgs/dropdownicon";
|
||||||
import BackArrowSvg from "@/app/svgs/backarrow";
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
import { getCourses, type Course } from "@/app/admin/courses/mockData";
|
import { getCourses, type Course } from "@/app/admin/courses/mockData";
|
||||||
import CloseXOSvg from "@/app/svgs/closexo";
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
type Lesson = {
|
type Lesson = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +21,7 @@ type Lesson = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminLessonsPage() {
|
export default function AdminLessonsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isRegistrationMode, setIsRegistrationMode] = useState(false);
|
const [isRegistrationMode, setIsRegistrationMode] = useState(false);
|
||||||
@@ -26,6 +29,8 @@ export default function AdminLessonsPage() {
|
|||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [currentUser, setCurrentUser] = useState<string>("관리자");
|
const [currentUser, setCurrentUser] = useState<string>("관리자");
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const rawLecturesRef = useRef<any[]>([]); // 원본 강좌 데이터 저장
|
||||||
|
|
||||||
// 등록 폼 상태
|
// 등록 폼 상태
|
||||||
const [selectedCourse, setSelectedCourse] = useState<string>("");
|
const [selectedCourse, setSelectedCourse] = useState<string>("");
|
||||||
@@ -33,9 +38,65 @@ export default function AdminLessonsPage() {
|
|||||||
const [learningGoal, setLearningGoal] = useState("");
|
const [learningGoal, setLearningGoal] = useState("");
|
||||||
const [courseVideoCount, setCourseVideoCount] = useState(0);
|
const [courseVideoCount, setCourseVideoCount] = useState(0);
|
||||||
const [courseVideoFiles, setCourseVideoFiles] = useState<string[]>([]);
|
const [courseVideoFiles, setCourseVideoFiles] = useState<string[]>([]);
|
||||||
|
const [courseVideoFileObjects, setCourseVideoFileObjects] = useState<File[]>([]);
|
||||||
const [vrContentCount, setVrContentCount] = useState(0);
|
const [vrContentCount, setVrContentCount] = useState(0);
|
||||||
const [vrContentFiles, setVrContentFiles] = useState<string[]>([]);
|
const [vrContentFiles, setVrContentFiles] = useState<string[]>([]);
|
||||||
|
const [vrContentFileObjects, setVrContentFileObjects] = useState<File[]>([]);
|
||||||
const [questionFileCount, setQuestionFileCount] = useState(0);
|
const [questionFileCount, setQuestionFileCount] = useState(0);
|
||||||
|
const [questionFileObject, setQuestionFileObject] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
selectedCourse?: string;
|
||||||
|
lessonName?: string;
|
||||||
|
learningGoal?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// 교육과정명 매핑 함수
|
||||||
|
const mapCourseNames = useCallback((lectures: any[]) => {
|
||||||
|
const fetchedLessons: Lesson[] = lectures.map((lecture: any) => {
|
||||||
|
// 첨부파일 정보 구성
|
||||||
|
const attachmentParts: string[] = [];
|
||||||
|
if (lecture.videoUrl) {
|
||||||
|
attachmentParts.push('강좌영상 1개');
|
||||||
|
}
|
||||||
|
if (lecture.webglUrl) {
|
||||||
|
attachmentParts.push('VR콘텐츠 1개');
|
||||||
|
}
|
||||||
|
if (lecture.csvKey) {
|
||||||
|
attachmentParts.push('평가문제 1개');
|
||||||
|
}
|
||||||
|
const attachments = attachmentParts.length > 0
|
||||||
|
? attachmentParts.join(', ')
|
||||||
|
: '없음';
|
||||||
|
|
||||||
|
// subjectId로 교육과정명 찾기
|
||||||
|
const subjectId = lecture.subjectId || lecture.subject_id;
|
||||||
|
let courseName = '';
|
||||||
|
if (subjectId && courses.length > 0) {
|
||||||
|
const foundCourse = courses.find(course => course.id === String(subjectId));
|
||||||
|
courseName = foundCourse?.courseName || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 교육과정명을 찾지 못한 경우 fallback
|
||||||
|
if (!courseName) {
|
||||||
|
courseName = lecture.subjectName || lecture.courseName || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(lecture.id || lecture.lectureId || ''),
|
||||||
|
courseName,
|
||||||
|
lessonName: lecture.title || lecture.lessonName || '',
|
||||||
|
attachments,
|
||||||
|
questionCount: lecture.csvKey ? 1 : 0,
|
||||||
|
createdBy: lecture.createdBy || lecture.instructorName || '관리자',
|
||||||
|
createdAt: lecture.createdAt
|
||||||
|
? new Date(lecture.createdAt).toISOString().split('T')[0]
|
||||||
|
: new Date().toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setLessons(fetchedLessons);
|
||||||
|
}, [courses]);
|
||||||
|
|
||||||
// 교육과정 목록 가져오기
|
// 교육과정 목록 가져오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,6 +112,34 @@ export default function AdminLessonsPage() {
|
|||||||
fetchCourses();
|
fetchCourses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 강좌 리스트 조회
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLectures() {
|
||||||
|
try {
|
||||||
|
const response = await apiService.getLectures();
|
||||||
|
if (response.data && Array.isArray(response.data)) {
|
||||||
|
// 원본 데이터 저장
|
||||||
|
rawLecturesRef.current = response.data;
|
||||||
|
// 교육과정명 매핑 함수 호출
|
||||||
|
mapCourseNames(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('강좌 리스트 조회 오류:', error);
|
||||||
|
setLessons([]);
|
||||||
|
rawLecturesRef.current = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLectures();
|
||||||
|
}, [mapCourseNames]);
|
||||||
|
|
||||||
|
// 교육과정 목록이 로드되면 강좌 리스트의 교육과정명 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawLecturesRef.current.length > 0) {
|
||||||
|
mapCourseNames(rawLecturesRef.current);
|
||||||
|
}
|
||||||
|
}, [mapCourseNames]);
|
||||||
|
|
||||||
// 현재 사용자 정보 가져오기
|
// 현재 사용자 정보 가져오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchCurrentUser() {
|
async function fetchCurrentUser() {
|
||||||
@@ -144,22 +233,149 @@ export default function AdminLessonsPage() {
|
|||||||
setLearningGoal("");
|
setLearningGoal("");
|
||||||
setCourseVideoCount(0);
|
setCourseVideoCount(0);
|
||||||
setCourseVideoFiles([]);
|
setCourseVideoFiles([]);
|
||||||
|
setCourseVideoFileObjects([]);
|
||||||
setVrContentCount(0);
|
setVrContentCount(0);
|
||||||
setVrContentFiles([]);
|
setVrContentFiles([]);
|
||||||
|
setVrContentFileObjects([]);
|
||||||
setQuestionFileCount(0);
|
setQuestionFileCount(0);
|
||||||
|
setQuestionFileObject(null);
|
||||||
|
setErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegisterClick = () => {
|
const handleRegisterClick = () => {
|
||||||
setIsRegistrationMode(true);
|
setIsRegistrationMode(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveClick = () => {
|
const handleSaveClick = async () => {
|
||||||
// 유효성 검사
|
// 유효성 검사
|
||||||
if (!selectedCourse || !lessonName) {
|
const newErrors: {
|
||||||
// TODO: 에러 메시지 표시
|
selectedCourse?: string;
|
||||||
|
lessonName?: string;
|
||||||
|
learningGoal?: string;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (!selectedCourse) {
|
||||||
|
newErrors.selectedCourse = "교육과정을 선택해 주세요.";
|
||||||
|
}
|
||||||
|
if (!lessonName.trim()) {
|
||||||
|
newErrors.lessonName = "강좌명을 입력해 주세요.";
|
||||||
|
}
|
||||||
|
if (!learningGoal.trim()) {
|
||||||
|
newErrors.learningGoal = "내용을 입력해 주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러가 있으면 표시하고 중단
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 에러 초기화
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 파일 업로드 및 키 추출
|
||||||
|
let videoUrl: string | undefined;
|
||||||
|
let webglUrl: string | undefined;
|
||||||
|
let csvKey: string | undefined;
|
||||||
|
|
||||||
|
// 강좌 영상 업로드 (첫 번째 파일만 사용)
|
||||||
|
if (courseVideoFileObjects.length > 0) {
|
||||||
|
try {
|
||||||
|
const uploadResponse = await apiService.uploadFile(courseVideoFileObjects[0]);
|
||||||
|
if (uploadResponse.data) {
|
||||||
|
const fileKey = uploadResponse.data.key
|
||||||
|
|| uploadResponse.data.fileKey
|
||||||
|
|| uploadResponse.data.id
|
||||||
|
|| uploadResponse.data.imageKey
|
||||||
|
|| uploadResponse.data.fileId
|
||||||
|
|| (uploadResponse.data.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey))
|
||||||
|
|| null;
|
||||||
|
if (fileKey) {
|
||||||
|
videoUrl = fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('강좌 영상 업로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VR 콘텐츠 업로드 (첫 번째 파일만 사용)
|
||||||
|
if (vrContentFileObjects.length > 0) {
|
||||||
|
try {
|
||||||
|
const uploadResponse = await apiService.uploadFile(vrContentFileObjects[0]);
|
||||||
|
if (uploadResponse.data) {
|
||||||
|
const fileKey = uploadResponse.data.key
|
||||||
|
|| uploadResponse.data.fileKey
|
||||||
|
|| uploadResponse.data.id
|
||||||
|
|| uploadResponse.data.imageKey
|
||||||
|
|| uploadResponse.data.fileId
|
||||||
|
|| (uploadResponse.data.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey))
|
||||||
|
|| null;
|
||||||
|
if (fileKey) {
|
||||||
|
webglUrl = fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('VR 콘텐츠 업로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 학습 평가 문제 업로드
|
||||||
|
if (questionFileObject) {
|
||||||
|
try {
|
||||||
|
const uploadResponse = await apiService.uploadFile(questionFileObject);
|
||||||
|
if (uploadResponse.data) {
|
||||||
|
const fileKey = uploadResponse.data.key
|
||||||
|
|| uploadResponse.data.fileKey
|
||||||
|
|| uploadResponse.data.id
|
||||||
|
|| uploadResponse.data.imageKey
|
||||||
|
|| uploadResponse.data.fileId
|
||||||
|
|| (uploadResponse.data.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey))
|
||||||
|
|| null;
|
||||||
|
if (fileKey) {
|
||||||
|
csvKey = fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 평가 문제 업로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 요청 body 구성
|
||||||
|
const requestBody: {
|
||||||
|
subjectId: number;
|
||||||
|
title: string;
|
||||||
|
objective: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
webglUrl?: string;
|
||||||
|
csvKey?: string;
|
||||||
|
} = {
|
||||||
|
subjectId: Number(selectedCourse),
|
||||||
|
title: lessonName.trim(),
|
||||||
|
objective: learningGoal.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택적 필드 추가
|
||||||
|
if (videoUrl) {
|
||||||
|
requestBody.videoUrl = videoUrl;
|
||||||
|
}
|
||||||
|
if (webglUrl) {
|
||||||
|
requestBody.webglUrl = webglUrl;
|
||||||
|
}
|
||||||
|
if (csvKey) {
|
||||||
|
requestBody.csvKey = csvKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 강좌 등록 API 호출
|
||||||
|
const response = await apiService.createLecture(requestBody);
|
||||||
|
|
||||||
|
// 응답에서 id 추출 및 저장
|
||||||
|
const lectureId = response.data?.id;
|
||||||
|
if (!lectureId) {
|
||||||
|
throw new Error('강좌 등록 응답에서 ID를 받지 못했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
// 첨부파일 정보 문자열 생성
|
// 첨부파일 정보 문자열 생성
|
||||||
const attachmentParts: string[] = [];
|
const attachmentParts: string[] = [];
|
||||||
if (courseVideoCount > 0) {
|
if (courseVideoCount > 0) {
|
||||||
@@ -178,9 +394,9 @@ export default function AdminLessonsPage() {
|
|||||||
// 교육과정명 가져오기
|
// 교육과정명 가져오기
|
||||||
const courseName = courseOptions.find(c => c.id === selectedCourse)?.name || '';
|
const courseName = courseOptions.find(c => c.id === selectedCourse)?.name || '';
|
||||||
|
|
||||||
// 새 강좌 생성
|
// 새 강좌 생성 (API 응답의 id 사용)
|
||||||
const newLesson: Lesson = {
|
const newLesson: Lesson = {
|
||||||
id: String(Date.now()),
|
id: String(lectureId),
|
||||||
courseName,
|
courseName,
|
||||||
lessonName,
|
lessonName,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -199,11 +415,33 @@ export default function AdminLessonsPage() {
|
|||||||
setLearningGoal("");
|
setLearningGoal("");
|
||||||
setCourseVideoCount(0);
|
setCourseVideoCount(0);
|
||||||
setCourseVideoFiles([]);
|
setCourseVideoFiles([]);
|
||||||
|
setCourseVideoFileObjects([]);
|
||||||
setVrContentCount(0);
|
setVrContentCount(0);
|
||||||
setVrContentFiles([]);
|
setVrContentFiles([]);
|
||||||
|
setVrContentFileObjects([]);
|
||||||
setQuestionFileCount(0);
|
setQuestionFileCount(0);
|
||||||
|
setQuestionFileObject(null);
|
||||||
|
|
||||||
|
// 토스트 팝업 표시
|
||||||
|
setShowToast(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('강좌 등록 실패:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '강좌 등록 중 오류가 발생했습니다.';
|
||||||
|
alert(errorMessage);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (showToast) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000); // 3초 후 자동 닫기
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-white">
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
{/* 메인 레이아웃 */}
|
{/* 메인 레이아웃 */}
|
||||||
@@ -254,7 +492,9 @@ export default function AdminLessonsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
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"
|
className={`w-full h-[40px] px-[12px] py-[8px] border rounded-[8px] bg-white flex items-center justify-between focus:outline-none focus:shadow-[inset_0_0_0_1px_#333c47] cursor-pointer ${
|
||||||
|
errors.selectedCourse ? 'border-[#e63946]' : 'border-[#dee1e6]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`text-[16px] font-normal leading-[1.5] flex-1 text-left ${
|
<span className={`text-[16px] font-normal leading-[1.5] flex-1 text-left ${
|
||||||
selectedCourse ? 'text-[#1b2027]' : 'text-[#6c7682]'
|
selectedCourse ? 'text-[#1b2027]' : 'text-[#6c7682]'
|
||||||
@@ -266,7 +506,7 @@ export default function AdminLessonsPage() {
|
|||||||
<DropdownIcon stroke="#8C95A1" className="shrink-0" />
|
<DropdownIcon stroke="#8C95A1" className="shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
{isDropdownOpen && (
|
{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">
|
<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 dropdown-scroll">
|
||||||
{courseOptions.map((course, index) => (
|
{courseOptions.map((course, index) => (
|
||||||
<button
|
<button
|
||||||
key={course.id}
|
key={course.id}
|
||||||
@@ -274,6 +514,10 @@ export default function AdminLessonsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCourse(course.id);
|
setSelectedCourse(course.id);
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
|
// 에러 초기화
|
||||||
|
if (errors.selectedCourse) {
|
||||||
|
setErrors(prev => ({ ...prev, selectedCourse: undefined }));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2 text-left text-[16px] font-normal leading-[1.5] hover:bg-[#f1f3f5] transition-colors cursor-pointer ${
|
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
|
selectedCourse === course.id
|
||||||
@@ -291,6 +535,11 @@ export default function AdminLessonsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{errors.selectedCourse && (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#e63946]">
|
||||||
|
{errors.selectedCourse}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 강좌명 */}
|
{/* 강좌명 */}
|
||||||
@@ -301,10 +550,23 @@ export default function AdminLessonsPage() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={lessonName}
|
value={lessonName}
|
||||||
onChange={(e) => setLessonName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setLessonName(e.target.value);
|
||||||
|
// 에러 초기화
|
||||||
|
if (errors.lessonName) {
|
||||||
|
setErrors(prev => ({ ...prev, lessonName: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="강좌명을 입력해 주세요."
|
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]"
|
className={`h-[40px] px-[12px] py-[8px] border 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] ${
|
||||||
|
errors.lessonName ? 'border-[#e63946]' : 'border-[#dee1e6]'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.lessonName && (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#e63946]">
|
||||||
|
{errors.lessonName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 학습 목표 */}
|
{/* 학습 목표 */}
|
||||||
@@ -318,10 +580,16 @@ export default function AdminLessonsPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.value.length <= 1000) {
|
if (e.target.value.length <= 1000) {
|
||||||
setLearningGoal(e.target.value);
|
setLearningGoal(e.target.value);
|
||||||
|
// 에러 초기화
|
||||||
|
if (errors.learningGoal) {
|
||||||
|
setErrors(prev => ({ ...prev, learningGoal: undefined }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="내용을 입력해 주세요. (최대 1,000자)"
|
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"
|
className={`w-full h-[160px] px-[12px] py-[8px] border 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 ${
|
||||||
|
errors.learningGoal ? 'border-[#e63946]' : 'border-[#dee1e6]'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-[8px] right-[12px]">
|
<div className="absolute bottom-[8px] right-[12px]">
|
||||||
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
||||||
@@ -329,6 +597,11 @@ export default function AdminLessonsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.learningGoal && (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#e63946]">
|
||||||
|
{errors.learningGoal}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,22 +630,50 @@ export default function AdminLessonsPage() {
|
|||||||
multiple
|
multiple
|
||||||
accept=".mp4,video/mp4"
|
accept=".mp4,video/mp4"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
|
|
||||||
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
|
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
|
||||||
const mp4Files: string[] = [];
|
const validFiles: File[] = [];
|
||||||
|
const oversizedFiles: string[] = [];
|
||||||
|
|
||||||
Array.from(files).forEach((file) => {
|
Array.from(files).forEach((file) => {
|
||||||
// mp4 파일이고 30MB 이하인 파일만 필터링
|
// mp4 파일인지 확인
|
||||||
if (file.name.toLowerCase().endsWith('.mp4') && file.size <= MAX_SIZE) {
|
if (!file.name.toLowerCase().endsWith('.mp4')) {
|
||||||
mp4Files.push(file.name);
|
return;
|
||||||
|
}
|
||||||
|
// 각 파일이 30MB 미만인지 검사
|
||||||
|
if (file.size < MAX_SIZE) {
|
||||||
|
validFiles.push(file);
|
||||||
|
} else {
|
||||||
|
oversizedFiles.push(file.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (courseVideoCount + mp4Files.length <= 10) {
|
// 30MB 이상인 파일이 있으면 알림
|
||||||
setCourseVideoFiles(prev => [...prev, ...mp4Files]);
|
if (oversizedFiles.length > 0) {
|
||||||
setCourseVideoCount(prev => prev + mp4Files.length);
|
alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 개수 제한 확인
|
||||||
|
if (courseVideoCount + validFiles.length > 10) {
|
||||||
|
alert('강좌 영상은 최대 10개까지 첨부할 수 있습니다.');
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
// 다중 파일 업로드
|
||||||
|
await apiService.uploadFiles(validFiles);
|
||||||
|
setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
|
||||||
|
setCourseVideoFileObjects(prev => [...prev, ...validFiles]);
|
||||||
|
setCourseVideoCount(prev => prev + validFiles.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('강좌 영상 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -401,6 +702,7 @@ export default function AdminLessonsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCourseVideoFiles(prev => prev.filter((_, i) => i !== index));
|
setCourseVideoFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
setCourseVideoFileObjects(prev => prev.filter((_, i) => i !== index));
|
||||||
setCourseVideoCount(prev => prev - 1);
|
setCourseVideoCount(prev => prev - 1);
|
||||||
}}
|
}}
|
||||||
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
||||||
@@ -433,22 +735,50 @@ export default function AdminLessonsPage() {
|
|||||||
multiple
|
multiple
|
||||||
accept=".zip,application/zip"
|
accept=".zip,application/zip"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
|
|
||||||
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
|
const MAX_SIZE = 30 * 1024 * 1024; // 30MB
|
||||||
const zipFiles: string[] = [];
|
const validFiles: File[] = [];
|
||||||
|
const oversizedFiles: string[] = [];
|
||||||
|
|
||||||
Array.from(files).forEach((file) => {
|
Array.from(files).forEach((file) => {
|
||||||
// zip 파일이고 30MB 이하인 파일만 필터링
|
// zip 파일인지 확인
|
||||||
if (file.name.toLowerCase().endsWith('.zip') && file.size <= MAX_SIZE) {
|
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||||
zipFiles.push(file.name);
|
return;
|
||||||
|
}
|
||||||
|
// 각 파일이 30MB 미만인지 검사
|
||||||
|
if (file.size < MAX_SIZE) {
|
||||||
|
validFiles.push(file);
|
||||||
|
} else {
|
||||||
|
oversizedFiles.push(file.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (vrContentCount + zipFiles.length <= 10) {
|
// 30MB 이상인 파일이 있으면 알림
|
||||||
setVrContentFiles(prev => [...prev, ...zipFiles]);
|
if (oversizedFiles.length > 0) {
|
||||||
setVrContentCount(prev => prev + zipFiles.length);
|
alert(`다음 파일은 30MB 미만이어야 합니다:\n${oversizedFiles.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 개수 제한 확인
|
||||||
|
if (vrContentCount + validFiles.length > 10) {
|
||||||
|
alert('VR 콘텐츠는 최대 10개까지 첨부할 수 있습니다.');
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
// 다중 파일 업로드
|
||||||
|
await apiService.uploadFiles(validFiles);
|
||||||
|
setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
|
||||||
|
setVrContentFileObjects(prev => [...prev, ...validFiles]);
|
||||||
|
setVrContentCount(prev => prev + validFiles.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('VR 콘텐츠 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -477,6 +807,7 @@ export default function AdminLessonsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVrContentFiles(prev => prev.filter((_, i) => i !== index));
|
setVrContentFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
setVrContentFileObjects(prev => prev.filter((_, i) => i !== index));
|
||||||
setVrContentCount(prev => prev - 1);
|
setVrContentCount(prev => prev - 1);
|
||||||
}}
|
}}
|
||||||
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
||||||
@@ -515,14 +846,22 @@ export default function AdminLessonsPage() {
|
|||||||
type="file"
|
type="file"
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
// CSV 파일만 허용
|
// CSV 파일만 허용
|
||||||
if (file.name.toLowerCase().endsWith('.csv')) {
|
if (file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
try {
|
||||||
|
// 단일 파일 업로드
|
||||||
|
await apiService.uploadFile(file);
|
||||||
|
setQuestionFileObject(file);
|
||||||
setQuestionFileCount(1);
|
setQuestionFileCount(1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 평가 문제 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -618,10 +957,23 @@ export default function AdminLessonsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedLessons.map((lesson) => (
|
{paginatedLessons.map((lesson) => {
|
||||||
|
// 원본 강좌 데이터 찾기
|
||||||
|
const rawLecture = rawLecturesRef.current.find(
|
||||||
|
(l: any) => String(l.id || l.lectureId) === lesson.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={lesson.id}
|
key={lesson.id}
|
||||||
className="h-12 hover:bg-[#F5F7FF] transition-colors"
|
onClick={() => {
|
||||||
|
// 원본 데이터를 sessionStorage에 저장
|
||||||
|
if (rawLecture) {
|
||||||
|
sessionStorage.setItem('selectedLecture', JSON.stringify(rawLecture));
|
||||||
|
}
|
||||||
|
router.push(`/admin/lessons/${lesson.id}`);
|
||||||
|
}}
|
||||||
|
className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
{lesson.courseName}
|
{lesson.courseName}
|
||||||
@@ -642,7 +994,8 @@ export default function AdminLessonsPage() {
|
|||||||
{lesson.createdAt}
|
{lesson.createdAt}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -740,6 +1093,23 @@ export default function AdminLessonsPage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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">
|
||||||
|
강좌가 등록되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import AdminSidebar from "@/app/components/AdminSidebar";
|
|||||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
import BackArrowSvg from "@/app/svgs/backarrow";
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
import { MOCK_NOTICES, type Notice } from "@/app/admin/notices/mockData";
|
import { MOCK_NOTICES, type Notice } from "@/app/admin/notices/mockData";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
export default function AdminNoticesPage() {
|
export default function AdminNoticesPage() {
|
||||||
const [notices, setNotices] = useState<Notice[]>(MOCK_NOTICES);
|
const [notices, setNotices] = useState<Notice[]>(MOCK_NOTICES);
|
||||||
@@ -30,14 +31,21 @@ export default function AdminNoticesPage() {
|
|||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
if (file.size > 30 * 1024 * 1024) {
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
// 단일 파일 업로드
|
||||||
|
await apiService.uploadFile(file);
|
||||||
setAttachedFile(file);
|
setAttachedFile(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
212
src/app/course-list/[id]/page.tsx
Normal file
212
src/app/course-list/[id]/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import apiService from '../../lib/apiService';
|
||||||
|
|
||||||
|
type Lesson = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
duration: string; // "12:46" 형식
|
||||||
|
state: "제출완료" | "제출대기";
|
||||||
|
action: "복습하기" | "이어서 수강하기" | "수강하기";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CourseDetail = {
|
||||||
|
id: string;
|
||||||
|
status: "수강 중" | "수강 예정" | "수강 완료";
|
||||||
|
title: string;
|
||||||
|
goal: string;
|
||||||
|
method: string;
|
||||||
|
summary: string; // VOD · 총 n강 · n시간 n분
|
||||||
|
submitSummary: string; // 학습 제출 n/n
|
||||||
|
thumbnail: string;
|
||||||
|
lessons: Lesson[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [course, setCourse] = useState<CourseDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCourse = async () => {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await apiService.getLecture(params.id as string);
|
||||||
|
|
||||||
|
// API 응답 데이터를 CourseDetail 타입으로 변환
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 구조에 맞게 데이터 매핑
|
||||||
|
// 실제 API 응답 구조에 따라 조정 필요
|
||||||
|
const courseDetail: CourseDetail = {
|
||||||
|
id: String(data.id || params.id),
|
||||||
|
status: data.status || "수강 예정",
|
||||||
|
title: data.title || data.lectureName || '',
|
||||||
|
goal: data.objective || data.goal || '',
|
||||||
|
method: data.method || '',
|
||||||
|
summary: data.summary || `VOD · 총 ${data.lessons?.length || 0}강`,
|
||||||
|
submitSummary: data.submitSummary || '',
|
||||||
|
thumbnail: data.thumbnail || data.imageKey || data.imageUrl || '/imgs/talk.png',
|
||||||
|
lessons: (data.lessons || []).map((lesson: any, index: number) => ({
|
||||||
|
id: String(lesson.id || lesson.lessonId || index + 1),
|
||||||
|
title: `${index + 1}. ${lesson.title || lesson.lessonName || ''}`,
|
||||||
|
duration: lesson.duration || '00:00',
|
||||||
|
state: lesson.isCompleted ? "제출완료" : "제출대기",
|
||||||
|
action: lesson.isCompleted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기"),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
setCourse(courseDetail);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('강좌 조회 실패:', err);
|
||||||
|
setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCourse();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="flex w-full flex-col">
|
||||||
|
<div className="flex h-[100px] items-center px-8">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
|
</div>
|
||||||
|
<section className="px-8 pb-20">
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<p className="text-[16px] text-[#8c95a1]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !course) {
|
||||||
|
return (
|
||||||
|
<main className="flex w-full flex-col">
|
||||||
|
<div className="flex h-[100px] items-center px-8">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
|
</div>
|
||||||
|
<section className="px-8 pb-20">
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/course-list')}
|
||||||
|
className="px-4 py-2 rounded-[6px] bg-primary text-white text-[14px] font-medium"
|
||||||
|
>
|
||||||
|
강좌 목록으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex w-full flex-col">
|
||||||
|
<div className="flex h-[100px] items-center px-8">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="px-8 pb-20">
|
||||||
|
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6">
|
||||||
|
{/* 상단 소개 카드 */}
|
||||||
|
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
|
||||||
|
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
|
||||||
|
<Image
|
||||||
|
src={course.thumbnail}
|
||||||
|
alt={course.title}
|
||||||
|
fill
|
||||||
|
sizes="292px"
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex h-[27px] items-center gap-2">
|
||||||
|
<span className="h-[20px] rounded-[4px] bg-[#e5f5ec] px-1.5 text-[13px] font-semibold leading-[1.4] text-[#0c9d61]">
|
||||||
|
{course.status}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{course.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||||
|
<span className="font-medium">학습 목표:</span> {course.goal}
|
||||||
|
</p>
|
||||||
|
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||||
|
<span className="font-medium">학습 방법:</span> {course.method}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
|
||||||
|
<span>{course.summary}</span>
|
||||||
|
{course.submitSummary && <span>{course.submitSummary}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차시 리스트 */}
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
{course.lessons.map((l) => {
|
||||||
|
const isSubmitted = l.state === "제출완료";
|
||||||
|
const submitBtnStyle =
|
||||||
|
l.state === "제출완료"
|
||||||
|
? "border border-transparent text-[#384fbf]"
|
||||||
|
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||||
|
const rightBtnStyle =
|
||||||
|
l.action === "이어서 수강하기"
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: l.action === "수강하기"
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-3">
|
||||||
|
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||||
|
"bg-white",
|
||||||
|
submitBtnStyle,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||||
|
rightBtnStyle,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{l.action}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
|
|
||||||
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
|
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
|
||||||
@@ -27,6 +28,7 @@ type Course = { id: string; title: string; image: string; inProgress?: boolean }
|
|||||||
export default function CourseListPage() {
|
export default function CourseListPage() {
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const base: Omit<Course, 'id'>[] = [
|
const base: Omit<Course, 'id'>[] = [
|
||||||
{ title: '원자로 운전 및 계통', image: imgThumbA, inProgress: true },
|
{ title: '원자로 운전 및 계통', image: imgThumbA, inProgress: true },
|
||||||
@@ -75,7 +77,8 @@ export default function CourseListPage() {
|
|||||||
{pagedCourses.map((c) => (
|
{pagedCourses.map((c) => (
|
||||||
<article
|
<article
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white"
|
onClick={() => router.push(`/course-list/${c.id}`)}
|
||||||
|
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
{/* 섬네일 */}
|
{/* 섬네일 */}
|
||||||
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px]">
|
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px]">
|
||||||
|
|||||||
@@ -61,3 +61,26 @@ button {
|
|||||||
button:hover {
|
button:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 드롭다운 스크롤바 스타일 - 배경 없애기 */
|
||||||
|
.dropdown-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ class ApiService {
|
|||||||
/**
|
/**
|
||||||
* 기본 헤더 생성
|
* 기본 헤더 생성
|
||||||
*/
|
*/
|
||||||
private getDefaultHeaders(): Record<string, string> {
|
private getDefaultHeaders(isFormData: boolean = false): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
// FormData인 경우 Content-Type을 설정하지 않음 (브라우저가 자동으로 설정)
|
||||||
|
if (!isFormData) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -98,16 +101,20 @@ class ApiService {
|
|||||||
|
|
||||||
const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
|
const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
|
||||||
|
|
||||||
|
// FormData 여부 확인
|
||||||
|
const isFormData = body instanceof FormData;
|
||||||
|
|
||||||
const requestOptions: RequestInit = {
|
const requestOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
...this.getDefaultHeaders(),
|
...this.getDefaultHeaders(isFormData),
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body && method !== 'GET') {
|
if (body && method !== 'GET') {
|
||||||
requestOptions.body = JSON.stringify(body);
|
// FormData인 경우 그대로 사용, 아닌 경우 JSON으로 변환
|
||||||
|
requestOptions.body = isFormData ? body : JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -277,11 +284,12 @@ class ApiService {
|
|||||||
* 과목 수정
|
* 과목 수정
|
||||||
*/
|
*/
|
||||||
async updateSubject(subjectId: string, subjectData: {
|
async updateSubject(subjectId: string, subjectData: {
|
||||||
courseName: string;
|
title: string;
|
||||||
instructorName: string;
|
instructor: string;
|
||||||
|
imageKey?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return this.request(`/subjects/${subjectId}`, {
|
return this.request(`/subjects/${subjectId}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
body: subjectData,
|
body: subjectData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -347,12 +355,109 @@ class ApiService {
|
|||||||
return this.request('/lessons');
|
return this.request('/lessons');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌 리스트 조회
|
||||||
|
*/
|
||||||
|
async getLectures() {
|
||||||
|
return this.request('/lectures', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강좌 조회
|
||||||
|
*/
|
||||||
|
async getLecture(id: string | number) {
|
||||||
|
return this.request(`/lectures/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌(lecture) 생성
|
||||||
|
*/
|
||||||
|
async createLecture(lectureData: {
|
||||||
|
subjectId: number;
|
||||||
|
title: string;
|
||||||
|
objective: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
webglUrl?: string;
|
||||||
|
csvKey?: string;
|
||||||
|
}) {
|
||||||
|
return this.request('/lectures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: lectureData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 리소스 조회
|
* 리소스 조회
|
||||||
*/
|
*/
|
||||||
async getResources() {
|
async getResources() {
|
||||||
return this.request('/resources');
|
return this.request('/resources');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 파일 업로드 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 파일 업로드
|
||||||
|
* @param file 업로드할 파일 (File 객체 또는 Blob)
|
||||||
|
*/
|
||||||
|
async uploadFile(file: File | Blob) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return this.request('/uploads-api/file', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 파일 업로드
|
||||||
|
* @param files 업로드할 파일 배열 (File[] 또는 Blob[])
|
||||||
|
*/
|
||||||
|
async uploadFiles(files: (File | Blob)[]) {
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.request('/uploads-api/files', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 다운로드 (이미지 URL 가져오기)
|
||||||
|
* @param fileKey 파일 키
|
||||||
|
* @returns 파일 URL (Blob URL), 파일이 없으면 null 반환
|
||||||
|
*/
|
||||||
|
async getFile(fileKey: string): Promise<string | null> {
|
||||||
|
const url = `${this.baseURL}/api/files/${fileKey}`;
|
||||||
|
const token = this.getToken();
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 에러는 이미지가 없는 것으로 간주하고 null 반환
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`파일을 가져오는데 실패했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 파일이므로 Blob으로 변환하여 URL 생성
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 API 서비스 인스턴스 생성
|
// 기본 API 서비스 인스턴스 생성
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
|||||||
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
||||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||||
import LoginErrorModal from "./LoginErrorModal";
|
import LoginErrorModal from "./LoginErrorModal";
|
||||||
import LoginOption from "@/app/login/LoginOption";
|
import LoginOption from "@/app/login/loginoption";
|
||||||
import apiService from "@/app/lib/apiService";
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|||||||
Reference in New Issue
Block a user