'use client'; import { useState, useRef, useEffect, useMemo } from "react"; import { useParams, useRouter } from "next/navigation"; import AdminSidebar from "@/app/components/AdminSidebar"; import DropdownIcon from "@/app/svgs/dropdownicon"; import BackArrowSvg from "@/app/svgs/backarrow"; import { getCourses, type Course } from "@/app/admin/courses/mockData"; import CloseXOSvg from "@/app/svgs/closexo"; import apiService from "@/app/lib/apiService"; import NoChangesModal from "@/app/admin/notices/NoChangesModal"; export default function LessonEditPage() { const params = useParams(); const router = useRouter(); const [loading, setLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); const videoFileInputRef = useRef(null); const vrFileInputRef = useRef(null); const csvFileInputRef = useRef(null); const [courses, setCourses] = useState([]); // 폼 상태 const [selectedCourse, setSelectedCourse] = useState(""); const [lessonName, setLessonName] = useState(""); const [learningGoal, setLearningGoal] = useState(""); const [courseVideoCount, setCourseVideoCount] = useState(0); const [courseVideoFiles, setCourseVideoFiles] = useState([]); const [courseVideoFileObjects, setCourseVideoFileObjects] = useState([]); const [courseVideoFileKeys, setCourseVideoFileKeys] = useState([]); const [existingVideoFiles, setExistingVideoFiles] = useState>([]); const [vrContentCount, setVrContentCount] = useState(0); const [vrContentFiles, setVrContentFiles] = useState([]); const [vrContentFileObjects, setVrContentFileObjects] = useState([]); const [vrContentFileKeys, setVrContentFileKeys] = useState([]); const [existingVrFiles, setExistingVrFiles] = useState>([]); const [questionFileCount, setQuestionFileCount] = useState(0); const [questionFileObject, setQuestionFileObject] = useState(null); const [questionFileKey, setQuestionFileKey] = useState(null); const [existingQuestionFile, setExistingQuestionFile] = useState<{fileName: string, fileKey?: string} | null>(null); const [csvData, setCsvData] = useState([]); const [csvHeaders, setCsvHeaders] = useState([]); const [csvRows, setCsvRows] = useState([]); // 파일 교체 확인 모달 상태 const [isFileReplaceModalOpen, setIsFileReplaceModalOpen] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const [pendingFileType, setPendingFileType] = useState<'video' | 'vr' | 'csv' | null>(null); // 변경사항 없음 모달 상태 const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false); // 원본 데이터 저장 (변경사항 비교용) const [originalData, setOriginalData] = useState<{ title?: string; objective?: string; videoUrl?: string; webglUrl?: string; csvUrl?: string; } | null>(null); // 에러 상태 const [errors, setErrors] = useState<{ selectedCourse?: string; lessonName?: string; learningGoal?: string; }>({}); // 교육과정 옵션 const courseOptions = useMemo(() => { return courses.map(course => ({ id: course.id, name: course.courseName, })); }, [courses]); // 교육과정 목록 로드 useEffect(() => { const loadCourses = async () => { try { const data = await getCourses(); setCourses(data); } catch (error) { console.error('교육과정 목록 로드 오류:', error); } }; loadCourses(); }, []); // 강좌 데이터 로드 useEffect(() => { const loadLecture = async () => { if (!params?.id) return; try { setLoading(true); // sessionStorage에서 저장된 강좌 데이터 가져오기 let data: any = null; const storedData = typeof window !== 'undefined' ? sessionStorage.getItem('selectedLecture') : null; if (storedData) { try { data = JSON.parse(storedData); } catch (e) { console.error('저장된 데이터 파싱 실패:', e); } } // sessionStorage에 데이터가 없으면 API에서 직접 가져오기 if (!data) { try { const response = await apiService.getLecture(params.id as string); data = response.data; } catch (err) { console.error('강좌 조회 실패:', err); // getLecture 실패 시 getLectures로 재시도 try { const listResponse = await apiService.getLectures(); const lectures = Array.isArray(listResponse.data) ? listResponse.data : listResponse.data?.items || listResponse.data?.lectures || listResponse.data?.data || []; data = lectures.find((l: any) => String(l.id || l.lectureId) === params.id); } catch (listErr) { console.error('강좌 리스트 조회 실패:', listErr); } } } if (!data) { throw new Error('강좌를 찾을 수 없습니다.'); } // 원본 데이터 저장 const original = { title: data.title || data.lectureName || '', objective: data.objective || data.goal || '', videoUrl: data.videoUrl || undefined, webglUrl: data.webglUrl || data.vrUrl || undefined, csvUrl: data.csvUrl || (data.csvKey ? `csv/${data.csvKey}` : undefined), }; setOriginalData(original); // 폼에 데이터 채우기 if (data.subjectId || data.subject_id) { setSelectedCourse(String(data.subjectId || data.subject_id)); } setLessonName(original.title); setLearningGoal(original.objective); // 기존 비디오 파일 if (data.videoUrl || data.videoKey) { const videoFiles = []; if (data.videoUrl) { videoFiles.push({ fileName: data.videoFileName || '강좌영상.mp4', fileKey: data.videoKey, url: data.videoUrl, }); } if (data.videoFiles && Array.isArray(data.videoFiles)) { data.videoFiles.forEach((vf: any) => { videoFiles.push({ fileName: vf.fileName || vf.name || '강좌영상.mp4', fileKey: vf.fileKey || vf.key, url: vf.url || vf.videoUrl, }); }); } setExistingVideoFiles(videoFiles); setCourseVideoCount(videoFiles.length); } // 기존 VR 파일 if (data.webglUrl || data.vrUrl || data.webglKey || data.vrKey) { const vrFiles = []; if (data.webglUrl || data.vrUrl) { vrFiles.push({ fileName: data.vrFileName || data.webglFileName || 'VR_콘텐츠.zip', fileKey: data.webglKey || data.vrKey, url: data.webglUrl || data.vrUrl, }); } setExistingVrFiles(vrFiles); setVrContentCount(vrFiles.length); } // 기존 평가 문제 파일 if (data.csvKey || data.csvUrl) { const csvFileKey = data.csvKey || (data.csvUrl && data.csvUrl.startsWith('csv/') ? data.csvUrl.substring(4) : data.csvUrl); setExistingQuestionFile({ fileName: data.csvFileName || data.csv_file_name || data.csvName || '평가문제.csv', fileKey: csvFileKey, }); setQuestionFileCount(1); // CSV 파일 다운로드 및 파싱 if (data.csvUrl || data.csvKey) { try { const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net'; const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; let fileKey: string | null = null; // csvUrl에서 fileKey 추출 if (data.csvUrl) { // 전체 URL인 경우 fileKey 추출 if (data.csvUrl.startsWith('http://') || data.csvUrl.startsWith('https://')) { // URL에서 /api/files/ 이후 부분을 fileKey로 사용 const filesIndex = data.csvUrl.indexOf('/api/files/'); if (filesIndex !== -1) { const extractedKey = data.csvUrl.substring(filesIndex + '/api/files/'.length); // URL 디코딩 fileKey = decodeURIComponent(extractedKey); } else { // URL에서 마지막 경로를 fileKey로 사용 const urlParts = data.csvUrl.split('/'); const lastPart = urlParts[urlParts.length - 1]; if (lastPart) { fileKey = decodeURIComponent(lastPart); } } } else if (data.csvUrl.startsWith('csv/')) { // "csv/" 접두사 제거 fileKey = data.csvUrl.substring(4); } else { // 그 외의 경우 fileKey로 사용 fileKey = data.csvUrl; } } else if (data.csvKey) { // csvKey가 있으면 fileKey로 사용 fileKey = data.csvKey; } if (!fileKey) { return; // fileKey가 없으면 종료 } // /api/files/{fileKey} 형태로 요청 const csvUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey as string)}`; const csvResponse = await fetch(csvUrl, { method: 'GET', headers: { ...(token && { Authorization: `Bearer ${token}` }), }, }); // 404 에러는 파일이 없는 것으로 간주하고 조용히 처리 if (csvResponse.status === 404) { console.warn('CSV 파일을 찾을 수 없습니다:', data.csvUrl || data.csvKey); } else if (csvResponse.ok) { const csvText = await csvResponse.text(); // CSV 파싱 함수 const parseCsv = (csvText: string): string[][] => { const lines: string[][] = []; let currentLine: string[] = []; let currentField = ''; let inQuotes = false; for (let i = 0; i < csvText.length; i++) { const char = csvText[i]; const nextChar = csvText[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { currentField += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { currentLine.push(currentField.trim()); currentField = ''; } else if ((char === '\n' || char === '\r') && !inQuotes) { if (char === '\r' && nextChar === '\n') { i++; } if (currentField || currentLine.length > 0) { currentLine.push(currentField.trim()); lines.push(currentLine); currentLine = []; currentField = ''; } } else { currentField += char; } } if (currentField || currentLine.length > 0) { currentLine.push(currentField.trim()); lines.push(currentLine); } return lines; }; const parsed = parseCsv(csvText); if (parsed.length > 0) { const headers = parsed[0]; const rows = parsed.slice(1); setCsvHeaders(headers); setCsvRows(rows); setCsvData(parsed); } } else if (!csvResponse.ok) { console.error(`CSV 파일을 가져오는데 실패했습니다. (${csvResponse.status})`); } } catch (csvError) { console.error('CSV 파일 파싱 실패:', csvError); // CSV 파싱 실패해도 계속 진행 } } } } catch (err) { console.error('강좌 로드 실패:', err); // 기본값 설정 (에러 발생 시에도 폼이 작동하도록) const defaultOriginal = { title: '', objective: '', videoUrl: undefined, webglUrl: undefined, csvUrl: undefined, }; setOriginalData(defaultOriginal); alert('강좌를 불러오는데 실패했습니다. 페이지를 새로고침하거나 다시 시도해주세요.'); // 에러 발생 시에도 페이지는 유지 (사용자가 수동으로 데이터 입력 가능) } finally { setLoading(false); } }; loadLecture(); }, [params?.id, router]); // 외부 클릭 시 드롭다운 닫기 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 = () => { router.push(`/admin/lessons/${params.id}`); }; // 파일 업로드 함수 const handleVideoFileUpload = async (validFiles: File[]) => { try { let fileKeys: string[] = []; // 파일이 1개인 경우 uploadFile 사용 if (validFiles.length === 1) { const uploadResponse = await apiService.uploadFile(validFiles[0]); // 응답에서 fileKey 추출 const fileKey = uploadResponse.data?.fileKey || uploadResponse.data?.key || uploadResponse.data?.id || uploadResponse.data?.fileId; if (fileKey) { fileKeys = [fileKey]; } else { throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.'); } } else { // 파일이 2개 이상인 경우 uploadFiles 사용 const uploadResponse = await apiService.uploadFiles(validFiles); // 응답에서 fileKey 배열 추출 if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) { uploadResponse.data.results.forEach((result: any) => { if (result.ok && result.fileKey) { fileKeys.push(result.fileKey); } else if (result.fileKey) { fileKeys.push(result.fileKey); } }); } else if (Array.isArray(uploadResponse.data)) { // 응답이 배열인 경우 fileKeys = uploadResponse.data.map((item: any) => item.fileKey || item.key || item.id || item.fileId ).filter(Boolean); } else if (uploadResponse.data?.fileKeys && Array.isArray(uploadResponse.data.fileKeys)) { fileKeys = uploadResponse.data.fileKeys; } } if (fileKeys.length > 0) { // 새로 첨부한 파일로 교체 (기존 새로 첨부한 파일은 삭제, 서버에 저장된 기존 파일은 유지) setCourseVideoFiles(validFiles.map(f => f.name)); setCourseVideoFileObjects(validFiles); setCourseVideoFileKeys(fileKeys); setCourseVideoCount(existingVideoFiles.length + fileKeys.length); } else { throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.'); } } catch (error) { console.error('강좌 영상 업로드 실패:', error); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); } }; const handleVrFileUpload = async (validFiles: File[]) => { try { let fileKeys: string[] = []; // 파일이 1개인 경우 uploadFile 사용 if (validFiles.length === 1) { const uploadResponse = await apiService.uploadFile(validFiles[0]); // 응답에서 fileKey 추출 const fileKey = uploadResponse.data?.fileKey || uploadResponse.data?.key || uploadResponse.data?.id || uploadResponse.data?.fileId; if (fileKey) { fileKeys = [fileKey]; } else { throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.'); } } else { // 파일이 2개 이상인 경우 uploadFiles 사용 const uploadResponse = await apiService.uploadFiles(validFiles); // 응답에서 fileKey 배열 추출 if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) { uploadResponse.data.results.forEach((result: any) => { if (result.ok && result.fileKey) { fileKeys.push(result.fileKey); } else if (result.fileKey) { fileKeys.push(result.fileKey); } }); } else if (Array.isArray(uploadResponse.data)) { // 응답이 배열인 경우 fileKeys = uploadResponse.data.map((item: any) => item.fileKey || item.key || item.id || item.fileId ).filter(Boolean); } else if (uploadResponse.data?.fileKeys && Array.isArray(uploadResponse.data.fileKeys)) { fileKeys = uploadResponse.data.fileKeys; } } if (fileKeys.length > 0) { // 새로 첨부한 파일로 교체 (기존 새로 첨부한 파일은 삭제, 서버에 저장된 기존 파일은 유지) setVrContentFiles(validFiles.map(f => f.name)); setVrContentFileObjects(validFiles); setVrContentFileKeys(fileKeys); setVrContentCount(existingVrFiles.length + fileKeys.length); } else { throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.'); } } catch (error) { console.error('VR 콘텐츠 업로드 실패:', error); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); } }; const handleCsvFileUpload = async (file: File) => { try { // CSV 파일 파싱 (Promise로 감싸서 파일 읽기 완료 대기) const parseCsvFile = (): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { const text = event.target?.result as string; if (!text) { reject(new Error('파일을 읽을 수 없습니다.')); return; } try { // CSV 파싱 함수 const parseCsv = (csvText: string): string[][] => { const lines: string[][] = []; let currentLine: string[] = []; let currentField = ''; let inQuotes = false; for (let i = 0; i < csvText.length; i++) { const char = csvText[i]; const nextChar = csvText[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { currentField += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { currentLine.push(currentField.trim()); currentField = ''; } else if ((char === '\n' || char === '\r') && !inQuotes) { if (char === '\r' && nextChar === '\n') { i++; } if (currentField || currentLine.length > 0) { currentLine.push(currentField.trim()); lines.push(currentLine); currentLine = []; currentField = ''; } } else { currentField += char; } } if (currentField || currentLine.length > 0) { currentLine.push(currentField.trim()); lines.push(currentLine); } return lines; }; const parsed = parseCsv(text); if (parsed.length === 0) { reject(new Error('CSV 파일이 비어있습니다.')); return; } resolve(parsed); } catch (parseError) { reject(new Error('CSV 파일을 읽는 중 오류가 발생했습니다.')); } }; reader.onerror = () => { reject(new Error('파일을 읽는 중 오류가 발생했습니다.')); }; reader.readAsText(file, 'UTF-8'); }); }; // CSV 파일 파싱 const parsed = await parseCsvFile(); // 첫 번째 줄을 헤더로 사용 const headers = parsed[0]; const rows = parsed.slice(1); setCsvHeaders(headers); setCsvRows(rows); setCsvData(parsed); // 단일 파일 업로드 const uploadResponse = await apiService.uploadFile(file); // 응답에서 fileKey 추출 const fileKey = uploadResponse.data?.fileKey || uploadResponse.data?.key || uploadResponse.data?.id || uploadResponse.data?.fileId || uploadResponse.data?.imageKey || (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) || null; if (fileKey) { // 이전 파일 삭제하고 새 파일로 교체 setQuestionFileObject(file); setQuestionFileKey(fileKey); setQuestionFileCount(1); setExistingQuestionFile(null); } else { throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.'); } } catch (error) { console.error('학습 평가 문제 업로드 실패:', error); const errorMessage = error instanceof Error ? error.message : '파일 업로드에 실패했습니다. 다시 시도해주세요.'; alert(errorMessage); } }; // 파일 교체 확인 모달 핸들러 const handleFileReplaceConfirm = () => { if (!pendingFileType) return; setIsFileReplaceModalOpen(false); // 파일 선택 다이얼로그 열기 if (pendingFileType === 'video' && videoFileInputRef.current) { videoFileInputRef.current.click(); } else if (pendingFileType === 'vr' && vrFileInputRef.current) { vrFileInputRef.current.click(); } else if (pendingFileType === 'csv' && csvFileInputRef.current) { csvFileInputRef.current.click(); } setPendingFiles([]); setPendingFileType(null); }; const handleFileReplaceCancel = () => { setIsFileReplaceModalOpen(false); setPendingFiles([]); setPendingFileType(null); }; const handleSaveClick = async () => { // 유효성 검사 const newErrors: { selectedCourse?: string; lessonName?: string; learningGoal?: string; } = {}; if (!selectedCourse) { newErrors.selectedCourse = "교육과정을 선택해 주세요."; } if (!lessonName.trim()) { newErrors.lessonName = "강좌명을 입력해 주세요."; } if (!learningGoal.trim()) { newErrors.learningGoal = "학습 목표를 입력해 주세요."; } setErrors(newErrors); if (Object.keys(newErrors).length > 0) { return; } if (isSaving) return; setIsSaving(true); try { if (!originalData) { alert('데이터를 불러오는 중입니다. 잠시 후 다시 시도해주세요.'); return; } // 이미 업로드된 fileKey 배열 사용 let videoUrl: string | undefined; let webglUrl: string | undefined; let csvUrl: string | undefined; // 강좌 영상 fileKey (새로 업로드된 파일이 있는 경우) if (courseVideoFileKeys.length > 0) { videoUrl = courseVideoFileKeys[0]; // 첫 번째 fileKey 사용 // TODO: API가 배열을 받는 경우 courseVideoFileKeys 배열 전체를 저장해야 함 } else if (existingVideoFiles.length > 0 && existingVideoFiles[0].url) { // 기존 파일 URL 유지 videoUrl = existingVideoFiles[0].url; } else if (existingVideoFiles.length > 0 && existingVideoFiles[0].fileKey) { // 기존 파일 fileKey 사용 videoUrl = existingVideoFiles[0].fileKey; } // VR 콘텐츠 fileKey (새로 업로드된 파일이 있는 경우) if (vrContentFileKeys.length > 0) { webglUrl = vrContentFileKeys[0]; // 첫 번째 fileKey 사용 // TODO: API가 배열을 받는 경우 vrContentFileKeys 배열 전체를 저장해야 함 } else if (existingVrFiles.length > 0 && existingVrFiles[0].url) { // 기존 파일 URL 유지 webglUrl = existingVrFiles[0].url; } else if (existingVrFiles.length > 0 && existingVrFiles[0].fileKey) { // 기존 파일 fileKey 사용 webglUrl = existingVrFiles[0].fileKey; } // 학습 평가 문제 fileKey (새로 업로드된 파일이 있는 경우) if (questionFileKey) { csvUrl = questionFileKey; } else if (existingQuestionFile?.fileKey) { // 기존 파일 fileKey 사용 csvUrl = existingQuestionFile.fileKey; } else if (originalData.csvUrl) { // 원본 데이터의 csvUrl 유지 csvUrl = originalData.csvUrl; } // 현재 값들 const currentTitle = lessonName.trim(); const currentObjective = learningGoal.trim(); // 변경된 항목만 포함하는 request body 생성 const requestBody: { title?: string; objective?: string; videoUrl?: string; webglUrl?: string; csvUrl?: string; } = {}; // 변경된 항목만 추가 if (currentTitle !== originalData.title) { requestBody.title = currentTitle; } if (currentObjective !== originalData.objective) { requestBody.objective = currentObjective; } // videoUrl 변경사항 체크 (원본에 있었는데 삭제된 경우도 포함) const originalVideoUrl = originalData.videoUrl; if (videoUrl !== originalVideoUrl) { // 원본에 있었는데 현재 없는 경우 (삭제된 경우) 빈 문자열로 처리 if (originalVideoUrl && !videoUrl) { requestBody.videoUrl = ''; } else if (videoUrl) { requestBody.videoUrl = videoUrl; } } // webglUrl 변경사항 체크 const originalWebglUrl = originalData.webglUrl; if (webglUrl !== originalWebglUrl) { // 원본에 있었는데 현재 없는 경우 (삭제된 경우) 빈 문자열로 처리 if (originalWebglUrl && !webglUrl) { requestBody.webglUrl = ''; } else if (webglUrl) { requestBody.webglUrl = webglUrl; } } // csvUrl 변경사항 체크 const originalCsvUrl = originalData.csvUrl; if (csvUrl !== originalCsvUrl) { // 원본에 있었는데 현재 없는 경우 (삭제된 경우) 빈 문자열로 처리 if (originalCsvUrl && !csvUrl) { requestBody.csvUrl = ''; } else if (csvUrl) { requestBody.csvUrl = csvUrl; } } // 변경사항이 없으면 알림 if (Object.keys(requestBody).length === 0) { setIsNoChangesModalOpen(true); setIsSaving(false); return; } // 강좌 수정 API 호출 (PATCH /lectures/{id}) await apiService.updateLecture(params.id as string, requestBody); // 성공 시 강좌 리스트로 이동 (토스트는 리스트 페이지에서 표시) router.push('/admin/lessons?updated=true'); } catch (error) { console.error('강좌 수정 실패:', error); const errorMessage = error instanceof Error ? error.message : '강좌 수정 중 오류가 발생했습니다.'; alert(errorMessage); } finally { setIsSaving(false); } }; if (loading) { return (

강좌 수정

로딩 중...

); } return (
{/* 헤더 */}

강좌 수정

{/* 폼 콘텐츠 */}
{/* 강좌 정보 */}

강좌 정보

{/* 교육 과정 */}
{isDropdownOpen && (
{courseOptions.map((course, index) => ( ))}
)}
{errors.selectedCourse && (

{errors.selectedCourse}

)}
{/* 강좌명 */}
{ setLessonName(e.target.value); if (errors.lessonName) { setErrors(prev => ({ ...prev, lessonName: undefined })); } }} placeholder="강좌명을 입력해 주세요." 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 && (

{errors.lessonName}

)}
{/* 학습 목표 */}