공지사항 삭제등록 등11

This commit is contained in:
2025-11-29 15:40:39 +09:00
parent 872a88866e
commit eb7871133d
10 changed files with 1663 additions and 412 deletions

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect, useRef } from "react";
import AdminSidebar from "@/app/components/AdminSidebar"; import AdminSidebar from "@/app/components/AdminSidebar";
import CourseRegistrationModal from "./CourseRegistrationModal"; import CourseRegistrationModal from "./CourseRegistrationModal";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg"; import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
@@ -31,6 +31,8 @@ export default function AdminCoursesPage() {
const [editingCourse, setEditingCourse] = useState<Course | null>(null); const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
const prevModalOpenRef = useRef(false);
const shouldRefreshRef = useRef(false);
// API에서 과목 리스트 가져오기 // API에서 과목 리스트 가져오기
useEffect(() => { useEffect(() => {
@@ -70,35 +72,9 @@ export default function AdminCoursesPage() {
}, [sortedCourses, currentPage]); }, [sortedCourses, currentPage]);
const handleSaveCourse = async (courseName: string, instructorName: string) => { const handleSaveCourse = async (courseName: string, instructorName: string) => {
if (editingCourse) { shouldRefreshRef.current = true; // 새로고침 플래그 설정
// 수정 모드 - TODO: API 호출로 변경 필요
setCourses(prev => prev.map(course =>
course.id === editingCourse.id
? { ...course, courseName, instructorName }
: course
));
} else {
// 등록 모드 - TODO: API 호출로 변경 필요
const newCourse: Course = {
id: String(Date.now()),
courseName,
instructorName,
createdAt: new Date().toISOString().split('T')[0],
createdBy: '', // API에서 받아오도록 변경 필요
hasLessons: false, // 기본값: 미포함
};
setCourses(prev => [...prev, newCourse]);
}
setIsModalOpen(false); setIsModalOpen(false);
setEditingCourse(null); setEditingCourse(null);
// 저장 후 리스트 새로고침
try {
const data = await getCourses();
setCourses(data);
} catch (error) {
console.error('과목 리스트 새로고침 오류:', error);
}
}; };
const handleRowClick = (course: Course) => { const handleRowClick = (course: Course) => {
@@ -117,24 +93,37 @@ export default function AdminCoursesPage() {
}; };
const handleDeleteCourse = async () => { const handleDeleteCourse = async () => {
if (editingCourse) { shouldRefreshRef.current = true; // 새로고침 플래그 설정
// TODO: API 호출로 삭제 처리 필요
setCourses(prev => prev.filter(course => course.id !== editingCourse.id));
setEditingCourse(null); setEditingCourse(null);
setShowToast(true); setShowToast(true);
setTimeout(() => { setTimeout(() => {
setShowToast(false); setShowToast(false);
}, 3000); }, 3000);
};
// 삭제 후 리스트 새로고침 // 모달이 닫힌 후 리스트 새로고침
useEffect(() => {
// 모달이 열렸다가 닫힐 때, 그리고 새로고침 플래그가 설정되어 있을 때만 새로고침
if (prevModalOpenRef.current && !isModalOpen && shouldRefreshRef.current) {
shouldRefreshRef.current = false; // 플래그 리셋
async function refreshList() {
try { try {
setIsLoading(true);
const data = await getCourses(); const data = await getCourses();
console.log('📋 [AdminCoursesPage] 새로고침된 데이터:', data);
console.log('📋 [AdminCoursesPage] 새로고침된 데이터 개수:', data.length);
setCourses(data); setCourses(data);
} catch (error) { } catch (error) {
console.error('과목 리스트 새로고침 오류:', error); console.error('과목 리스트 새로고침 오류:', error);
} finally {
setIsLoading(false);
} }
} }
}; refreshList();
}
prevModalOpenRef.current = isModalOpen;
}, [isModalOpen]);
return ( return (
<div className="min-h-screen flex flex-col bg-white"> <div className="min-h-screen flex flex-col bg-white">

View File

@@ -16,6 +16,9 @@ export default function LessonEditPage() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const videoFileInputRef = useRef<HTMLInputElement>(null);
const vrFileInputRef = useRef<HTMLInputElement>(null);
const csvFileInputRef = useRef<HTMLInputElement>(null);
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
@@ -184,11 +187,134 @@ export default function LessonEditPage() {
// 기존 평가 문제 파일 // 기존 평가 문제 파일
if (data.csvKey || data.csvUrl) { if (data.csvKey || data.csvUrl) {
const csvFileKey = data.csvKey || (data.csvUrl && data.csvUrl.startsWith('csv/') ? data.csvUrl.substring(4) : data.csvUrl);
setExistingQuestionFile({ setExistingQuestionFile({
fileName: data.csvFileName || data.csv_file_name || data.csvName || '평가문제.csv', fileName: data.csvFileName || data.csv_file_name || data.csvName || '평가문제.csv',
fileKey: data.csvKey, fileKey: csvFileKey,
}); });
setQuestionFileCount(1); 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) { } catch (err) {
console.error('강좌 로드 실패:', err); console.error('강좌 로드 실패:', err);
@@ -365,11 +491,17 @@ export default function LessonEditPage() {
const handleCsvFileUpload = async (file: File) => { const handleCsvFileUpload = async (file: File) => {
try { try {
// CSV 파일 파싱 // CSV 파일 파싱 (Promise로 감싸서 파일 읽기 완료 대기)
const parseCsvFile = (): Promise<string[][]> => {
return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const text = event.target?.result as string; const text = event.target?.result as string;
if (!text) return; if (!text) {
reject(new Error('파일을 읽을 수 없습니다.'));
return;
}
try { try {
// CSV 파싱 함수 // CSV 파싱 함수
@@ -419,10 +551,27 @@ export default function LessonEditPage() {
const parsed = parseCsv(text); const parsed = parseCsv(text);
if (parsed.length === 0) { if (parsed.length === 0) {
alert('CSV 파일이 비어있습니다.'); reject(new Error('CSV 파일이 비어있습니다.'));
return; 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 headers = parsed[0];
const rows = parsed.slice(1); const rows = parsed.slice(1);
@@ -430,17 +579,6 @@ export default function LessonEditPage() {
setCsvHeaders(headers); setCsvHeaders(headers);
setCsvRows(rows); setCsvRows(rows);
setCsvData(parsed); setCsvData(parsed);
} catch (parseError) {
console.error('CSV 파싱 오류:', parseError);
alert('CSV 파일을 읽는 중 오류가 발생했습니다.');
}
};
reader.onerror = () => {
alert('파일을 읽는 중 오류가 발생했습니다.');
};
reader.readAsText(file, 'UTF-8');
// 단일 파일 업로드 // 단일 파일 업로드
const uploadResponse = await apiService.uploadFile(file); const uploadResponse = await apiService.uploadFile(file);
@@ -465,22 +603,24 @@ export default function LessonEditPage() {
} }
} catch (error) { } catch (error) {
console.error('학습 평가 문제 업로드 실패:', error); console.error('학습 평가 문제 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); const errorMessage = error instanceof Error ? error.message : '파일 업로드에 실패했습니다. 다시 시도해주세요.';
alert(errorMessage);
} }
}; };
// 파일 교체 확인 모달 핸들러 // 파일 교체 확인 모달 핸들러
const handleFileReplaceConfirm = async () => { const handleFileReplaceConfirm = () => {
if (!pendingFiles.length || !pendingFileType) return; if (!pendingFileType) return;
setIsFileReplaceModalOpen(false); setIsFileReplaceModalOpen(false);
if (pendingFileType === 'video') { // 파일 선택 다이얼로그 열기
await handleVideoFileUpload(pendingFiles); if (pendingFileType === 'video' && videoFileInputRef.current) {
} else if (pendingFileType === 'vr') { videoFileInputRef.current.click();
await handleVrFileUpload(pendingFiles); } else if (pendingFileType === 'vr' && vrFileInputRef.current) {
} else if (pendingFileType === 'csv' && pendingFiles.length > 0) { vrFileInputRef.current.click();
await handleCsvFileUpload(pendingFiles[0]); } else if (pendingFileType === 'csv' && csvFileInputRef.current) {
csvFileInputRef.current.click();
} }
setPendingFiles([]); setPendingFiles([]);
@@ -634,9 +774,9 @@ export default function LessonEditPage() {
// 성공 시 토스트 표시 // 성공 시 토스트 표시
setShowToast(true); setShowToast(true);
// 토스트 표시 후 상세 페이지로 이동 // 토스트 표시 후 상세 페이지로 이동 (새로고침하여 최신 데이터 표시)
setTimeout(() => { setTimeout(() => {
router.push(`/admin/lessons/${params.id}`); router.push(`/admin/lessons/${params.id}?refresh=${Date.now()}`);
}, 1500); }, 1500);
} catch (error) { } catch (error) {
console.error('강좌 수정 실패:', error); console.error('강좌 수정 실패:', error);
@@ -858,9 +998,21 @@ export default function LessonEditPage() {
30MB 30MB
</span> </span>
</div> </div>
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"> <label
className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"
onClick={(e) => {
// 기존 파일이나 새로 첨부한 파일이 있으면 확인 모달 표시
if (existingVideoFiles.length > 0 || courseVideoFiles.length > 0 || courseVideoFileKeys.length > 0) {
e.preventDefault();
setPendingFiles([]);
setPendingFileType('video');
setIsFileReplaceModalOpen(true);
}
}}
>
<span></span> <span></span>
<input <input
ref={videoFileInputRef}
type="file" type="file"
multiple multiple
accept=".mp4,video/mp4" accept=".mp4,video/mp4"
@@ -916,16 +1068,8 @@ export default function LessonEditPage() {
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
// 새로 첨부한 파일이 있으면 확인 모달 표시 // 기존 파일 삭제 후 새 파일 업로드
if (courseVideoFiles.length > 0 || courseVideoFileKeys.length > 0) { setExistingVideoFiles([]);
setPendingFiles(validFiles);
setPendingFileType('video');
setIsFileReplaceModalOpen(true);
e.target.value = '';
return;
}
// 새로 첨부한 파일이 없으면 바로 업로드
handleVideoFileUpload(validFiles); handleVideoFileUpload(validFiles);
} }
e.target.value = ''; e.target.value = '';
@@ -948,7 +1092,7 @@ export default function LessonEditPage() {
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]" className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
> >
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]"> <p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
{file.fileName} () {file.fileName}
</p> </p>
<button <button
type="button" type="button"
@@ -1002,9 +1146,21 @@ export default function LessonEditPage() {
30MB 30MB
</span> </span>
</div> </div>
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"> <label
className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"
onClick={(e) => {
// 기존 파일이나 새로 첨부한 파일이 있으면 확인 모달 표시
if (existingVrFiles.length > 0 || vrContentFiles.length > 0 || vrContentFileKeys.length > 0) {
e.preventDefault();
setPendingFiles([]);
setPendingFileType('vr');
setIsFileReplaceModalOpen(true);
}
}}
>
<span></span> <span></span>
<input <input
ref={vrFileInputRef}
type="file" type="file"
multiple multiple
accept=".zip,application/zip" accept=".zip,application/zip"
@@ -1060,16 +1216,8 @@ export default function LessonEditPage() {
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
// 새로 첨부한 파일이 있으면 확인 모달 표시 // 기존 파일 삭제 후 새 파일 업로드
if (vrContentFiles.length > 0 || vrContentFileKeys.length > 0) { setExistingVrFiles([]);
setPendingFiles(validFiles);
setPendingFileType('vr');
setIsFileReplaceModalOpen(true);
e.target.value = '';
return;
}
// 새로 첨부한 파일이 없으면 바로 업로드
handleVrFileUpload(validFiles); handleVrFileUpload(validFiles);
} }
e.target.value = ''; e.target.value = '';
@@ -1092,7 +1240,7 @@ export default function LessonEditPage() {
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]" className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
> >
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]"> <p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
{file.fileName} () {file.fileName}
</p> </p>
<button <button
type="button" type="button"
@@ -1147,9 +1295,21 @@ export default function LessonEditPage() {
</span> </span>
</div> </div>
<div className="flex items-center gap-[8px]"> <div className="flex items-center gap-[8px]">
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"> <label
className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"
onClick={(e) => {
// 기존 파일이나 새로 첨부한 파일이 있으면 확인 모달 표시
if (questionFileObject || questionFileKey || existingQuestionFile) {
e.preventDefault();
setPendingFiles([]);
setPendingFileType('csv');
setIsFileReplaceModalOpen(true);
}
}}
>
<span></span> <span></span>
<input <input
ref={csvFileInputRef}
type="file" type="file"
accept=".csv" accept=".csv"
className="hidden" className="hidden"
@@ -1166,100 +1326,9 @@ export default function LessonEditPage() {
return; return;
} }
try { // 기존 파일 삭제 후 새 파일 업로드
// CSV 파일 파싱 setExistingQuestionFile(null);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
if (!text) 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) {
alert('CSV 파일이 비어있습니다.');
return;
}
// 첫 번째 줄을 헤더로 사용
const headers = parsed[0];
const rows = parsed.slice(1);
setCsvHeaders(headers);
setCsvRows(rows);
setCsvData(parsed);
} catch (parseError) {
console.error('CSV 파싱 오류:', parseError);
alert('CSV 파일을 읽는 중 오류가 발생했습니다.');
}
};
reader.onerror = () => {
alert('파일을 읽는 중 오류가 발생했습니다.');
};
reader.readAsText(file, 'UTF-8');
// 기존 파일이 있으면 확인 모달 표시
if (questionFileObject || questionFileKey) {
setPendingFiles([file]);
setPendingFileType('csv');
setIsFileReplaceModalOpen(true);
e.target.value = '';
return;
}
// 기존 파일이 없으면 바로 업로드
await handleCsvFileUpload(file); await handleCsvFileUpload(file);
} catch (error) {
console.error('학습 평가 문제 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
}
// input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = ''; e.target.value = '';
}} }}
/> />
@@ -1279,7 +1348,7 @@ export default function LessonEditPage() {
{(existingQuestionFile || questionFileObject) && ( {(existingQuestionFile || questionFileObject) && (
<div className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px] bg-white border-b border-[#dee1e6]"> <div className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px] bg-white border-b border-[#dee1e6]">
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]"> <p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
{existingQuestionFile ? `${existingQuestionFile.fileName} (기존)` : questionFileObject?.name} {existingQuestionFile ? existingQuestionFile.fileName : questionFileObject?.name}
</p> </p>
<button <button
type="button" type="button"

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import AdminSidebar from '@/app/components/AdminSidebar'; import AdminSidebar from '@/app/components/AdminSidebar';
import BackArrowSvg from '@/app/svgs/backarrow'; import BackArrowSvg from '@/app/svgs/backarrow';
import DownloadIcon from '@/app/svgs/downloadicon'; import DownloadIcon from '@/app/svgs/downloadicon';
@@ -42,11 +42,14 @@ type CourseDetail = {
videoFiles: VideoFile[]; videoFiles: VideoFile[];
vrFiles: VrFile[]; vrFiles: VrFile[];
quizData: QuizQuestion[]; quizData: QuizQuestion[];
csvKey?: string;
csvUrl?: string;
}; };
export default function AdminCourseDetailPage() { export default function AdminCourseDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [course, setCourse] = useState<CourseDetail | null>(null); const [course, setCourse] = useState<CourseDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -164,23 +167,99 @@ export default function AdminCourseDetailPage() {
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출 // fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
if (videoFileKeys.length > 0) { if (videoFileKeys.length > 0) {
// fileKey와 fileName 매핑 생성
const videoFileMap = new Map<string, string>();
if (data.videoFiles && Array.isArray(data.videoFiles)) {
data.videoFiles.forEach((vf: any) => {
const key = vf.fileKey || vf.key;
if (key && (vf.fileName || vf.name)) {
videoFileMap.set(key, vf.fileName || vf.name);
}
});
}
const videoFilePromises = videoFileKeys.map(async (fileKey, index) => { const videoFilePromises = videoFileKeys.map(async (fileKey, index) => {
try { try {
const fileUrl = await apiService.getFile(fileKey); // 먼저 data에서 파일명 정보 확인 (fileKey로 매핑된 파일명 우선)
let fileName = videoFileMap.get(fileKey)
|| data.videoFiles?.[index]?.fileName
|| data.videoFiles?.[index]?.name
|| data.videoFileName
|| `강좌영상_${index + 1}.mp4`;
// 파일 정보 가져오기 (크기, 파일명 등)
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net';
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const fileUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey)}`;
const response = await fetch(fileUrl, {
method: 'HEAD',
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
});
let fileSize = '0 KB';
if (response.ok) {
// Content-Disposition 헤더에서 파일명 추출 (data에 없을 경우에만)
if (!data.videoFiles?.[index]?.fileName && !data.videoFiles?.[index]?.name) {
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (fileNameMatch && fileNameMatch[1]) {
fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
}
}
}
// Content-Length 헤더에서 파일 크기 추출
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
const sizeInBytes = parseInt(contentLength, 10);
if (sizeInBytes > 0) {
if (sizeInBytes < 1024) {
fileSize = `${sizeInBytes} B`;
} else if (sizeInBytes < 1024 * 1024) {
fileSize = `${(sizeInBytes / 1024).toFixed(2)} KB`;
} else {
fileSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
}
// 실제 파일 URL 가져오기
const blobUrl = await apiService.getFile(fileKey);
return { return {
id: String(index + 1), id: String(index + 1),
fileName: data.videoFiles?.[index]?.fileName || data.videoFiles?.[index]?.name || `강좌영상_${index + 1}.mp4`, fileName: fileName,
fileSize: data.videoFiles?.[index]?.fileSize || '796.35 KB', fileSize: fileSize,
fileKey: fileKey, fileKey: fileKey,
url: fileUrl || undefined, url: blobUrl || undefined,
}; };
} else {
// HEAD 요청 실패 시 파일명은 data에서 가져온 값 사용
return {
id: String(index + 1),
fileName: fileName,
fileSize: fileSize,
fileKey: fileKey,
url: undefined,
};
}
} catch (err) { } catch (err) {
console.error(`비디오 파일 ${index + 1} 조회 실패:`, err); console.error(`비디오 파일 ${index + 1} 조회 실패:`, err);
// 조회 실패해도 fileKey는 저장 // 조회 실패해도 fileKey는 저장, 파일명은 data에서 가져온 값 사용
const fallbackFileName = data.videoFiles?.[index]?.fileName
|| data.videoFiles?.[index]?.name
|| data.videoFileName
|| `강좌영상_${index + 1}.mp4`;
return { return {
id: String(index + 1), id: String(index + 1),
fileName: data.videoFiles?.[index]?.fileName || data.videoFiles?.[index]?.name || `강좌영상_${index + 1}.mp4`, fileName: fallbackFileName,
fileSize: data.videoFiles?.[index]?.fileSize || '796.35 KB', fileSize: '0 KB',
fileKey: fileKey, fileKey: fileKey,
url: undefined, url: undefined,
}; };
@@ -189,29 +268,6 @@ export default function AdminCourseDetailPage() {
const resolvedVideoFiles = await Promise.all(videoFilePromises); const resolvedVideoFiles = await Promise.all(videoFilePromises);
videoFiles.push(...resolvedVideoFiles); videoFiles.push(...resolvedVideoFiles);
} else {
// fileKey 배열이 없으면 기존 방식대로 처리
if (data.videoUrl) {
videoFiles.push({
id: '1',
fileName: data.videoFileName || '강좌영상.mp4',
fileSize: '796.35 KB',
fileKey: data.videoKey,
url: data.videoUrl,
});
}
// 여러 비디오 파일이 있는 경우 처리 (기존 로직 유지)
if (data.videoFiles && Array.isArray(data.videoFiles)) {
data.videoFiles.forEach((vf: any, index: number) => {
videoFiles.push({
id: String(index + 1),
fileName: vf.fileName || vf.name || `강좌영상_${index + 1}.mp4`,
fileSize: vf.fileSize || '796.35 KB',
fileKey: vf.fileKey || vf.key,
url: vf.url || vf.videoUrl,
});
});
}
} }
// VR 파일 목록 구성 - fileKey 배열에서 파일 조회 // VR 파일 목록 구성 - fileKey 배열에서 파일 조회
@@ -255,23 +311,101 @@ export default function AdminCourseDetailPage() {
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출 // fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
if (vrFileKeys.length > 0) { if (vrFileKeys.length > 0) {
// fileKey와 fileName 매핑 생성
const vrFileMap = new Map<string, string>();
if (data.webglFiles && Array.isArray(data.webglFiles)) {
data.webglFiles.forEach((vf: any) => {
const key = vf.fileKey || vf.key;
if (key && (vf.fileName || vf.name)) {
vrFileMap.set(key, vf.fileName || vf.name);
}
});
}
const vrFilePromises = vrFileKeys.map(async (fileKey, index) => { const vrFilePromises = vrFileKeys.map(async (fileKey, index) => {
try { try {
const fileUrl = await apiService.getFile(fileKey); // 먼저 data에서 파일명 정보 확인 (fileKey로 매핑된 파일명 우선)
let fileName = vrFileMap.get(fileKey)
|| data.webglFiles?.[index]?.fileName
|| data.webglFiles?.[index]?.name
|| data.vrFileName
|| data.webglFileName
|| `VR_콘텐츠_${index + 1}.zip`;
// 파일 정보 가져오기 (크기, 파일명 등)
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net';
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const fileUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey)}`;
const response = await fetch(fileUrl, {
method: 'HEAD',
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
});
let fileSize = '0 KB';
if (response.ok) {
// Content-Disposition 헤더에서 파일명 추출 (data에 없을 경우에만)
if (!data.webglFiles?.[index]?.fileName && !data.webglFiles?.[index]?.name) {
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (fileNameMatch && fileNameMatch[1]) {
fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
}
}
}
// Content-Length 헤더에서 파일 크기 추출
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
const sizeInBytes = parseInt(contentLength, 10);
if (sizeInBytes > 0) {
if (sizeInBytes < 1024) {
fileSize = `${sizeInBytes} B`;
} else if (sizeInBytes < 1024 * 1024) {
fileSize = `${(sizeInBytes / 1024).toFixed(2)} KB`;
} else {
fileSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
}
// 실제 파일 URL 가져오기
const blobUrl = await apiService.getFile(fileKey);
return { return {
id: String(index + 1), id: String(index + 1),
fileName: data.webglFiles?.[index]?.fileName || data.webglFiles?.[index]?.name || data.vrFileName || data.webglFileName || `VR_콘텐츠_${index + 1}.zip`, fileName: fileName,
fileSize: data.webglFiles?.[index]?.fileSize || '796.35 KB', fileSize: fileSize,
fileKey: fileKey, fileKey: fileKey,
url: fileUrl || undefined, url: blobUrl || undefined,
}; };
} else {
// HEAD 요청 실패 시 파일명은 data에서 가져온 값 사용
return {
id: String(index + 1),
fileName: fileName,
fileSize: fileSize,
fileKey: fileKey,
url: undefined,
};
}
} catch (err) { } catch (err) {
console.error(`VR 파일 ${index + 1} 조회 실패:`, err); console.error(`VR 파일 ${index + 1} 조회 실패:`, err);
// 조회 실패해도 fileKey는 저장 // 조회 실패해도 fileKey는 저장, 파일명은 data에서 가져온 값 사용
const fallbackFileName = data.webglFiles?.[index]?.fileName
|| data.webglFiles?.[index]?.name
|| data.vrFileName
|| data.webglFileName
|| `VR_콘텐츠_${index + 1}.zip`;
return { return {
id: String(index + 1), id: String(index + 1),
fileName: data.webglFiles?.[index]?.fileName || data.webglFiles?.[index]?.name || data.vrFileName || data.webglFileName || `VR_콘텐츠_${index + 1}.zip`, fileName: fallbackFileName,
fileSize: data.webglFiles?.[index]?.fileSize || '796.35 KB', fileSize: '0 KB',
fileKey: fileKey, fileKey: fileKey,
url: undefined, url: undefined,
}; };
@@ -280,34 +414,153 @@ export default function AdminCourseDetailPage() {
const resolvedVrFiles = await Promise.all(vrFilePromises); const resolvedVrFiles = await Promise.all(vrFilePromises);
vrFiles.push(...resolvedVrFiles); vrFiles.push(...resolvedVrFiles);
} else if (data.webglUrl || data.vrUrl) {
// fileKey 배열이 없고 webglUrl이 있으면 기존 방식대로 처리
vrFiles.push({
id: '1',
fileName: data.vrFileName || data.webglFileName || 'VR_콘텐츠.zip',
fileSize: '796.35 KB',
fileKey: data.webglKey || data.vrKey,
url: data.webglUrl || data.vrUrl,
});
} }
// 퀴즈 데이터 구성 (CSV 파일이 있으면 파싱, 없으면 빈 배열) // 퀴즈 데이터 구성 (CSV 파일이 있으면 파싱, 없으면 빈 배열)
const quizData: QuizQuestion[] = []; const quizData: QuizQuestion[] = [];
if (data.csvKey || data.quizData) { if (data.csvKey || data.csvUrl || data.quizData) {
// 실제로는 CSV 파일을 파싱하거나 API에서 퀴즈 데이터를 가져와야 함 // quizData가 이미 배열로 제공되는 경우
// 여기서는 예시 데이터만 추가
if (data.quizData && Array.isArray(data.quizData)) { if (data.quizData && Array.isArray(data.quizData)) {
data.quizData.forEach((q: any, index: number) => { data.quizData.forEach((q: any, index: number) => {
quizData.push({ quizData.push({
id: String(q.id || index + 1), id: String(q.id || index + 1),
number: q.number || index + 1, number: q.number || index + 1,
question: q.question || '블라블라블라블라블라블라블라블라블라', question: q.question || '',
correctAnswer: q.correctAnswer || q.correct_answer || '{정답}', correctAnswer: q.correctAnswer || q.correct_answer || '',
wrongAnswer1: q.wrongAnswer1 || q.wrong_answer1 || '{오답1}', wrongAnswer1: q.wrongAnswer1 || q.wrong_answer1 || '',
wrongAnswer2: q.wrongAnswer2 || q.wrong_answer2 || '{오답2}', wrongAnswer2: q.wrongAnswer2 || q.wrong_answer2 || '',
wrongAnswer3: q.wrongAnswer3 || q.wrong_answer3 || '{오답3}', wrongAnswer3: q.wrongAnswer3 || q.wrong_answer3 || '',
}); });
}); });
} else {
// 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 rows = parsed.slice(1);
rows.forEach((row, index) => {
if (row.length >= 5) {
quizData.push({
id: String(index + 1),
number: index + 1,
question: row[0] || '',
correctAnswer: row[1] || '',
wrongAnswer1: row[2] || '',
wrongAnswer2: row[3] || '',
wrongAnswer3: row[4] || '',
});
}
});
}
} else if (!csvResponse.ok) {
console.error(`CSV 파일을 가져오는데 실패했습니다. (${csvResponse.status})`);
}
} catch (csvError) {
console.error('CSV 파일 파싱 실패:', csvError);
}
}
} }
} }
@@ -320,6 +573,8 @@ export default function AdminCourseDetailPage() {
videoFiles: videoFiles, videoFiles: videoFiles,
vrFiles: vrFiles, vrFiles: vrFiles,
quizData: quizData, quizData: quizData,
csvKey: data.csvKey,
csvUrl: data.csvUrl,
}; };
setCourse(courseDetail); setCourse(courseDetail);
@@ -332,7 +587,14 @@ export default function AdminCourseDetailPage() {
}; };
fetchCourse(); fetchCourse();
}, [params?.id]); }, [params?.id, searchParams]);
// refresh 쿼리 파라미터가 있으면 URL에서 제거
useEffect(() => {
if (searchParams.get('refresh')) {
router.replace(`/admin/lessons/${params.id}`);
}
}, [searchParams, params?.id, router]);
// 토스트 자동 닫기 // 토스트 자동 닫기
useEffect(() => { useEffect(() => {
@@ -451,6 +713,56 @@ export default function AdminCourseDetailPage() {
} }
}; };
const handleCsvDownload = async () => {
if (!course) return;
try {
let fileKey: string | null = null;
// csvUrl에서 fileKey 추출
if (course.csvUrl) {
// 전체 URL인 경우 fileKey 추출
if (course.csvUrl.startsWith('http://') || course.csvUrl.startsWith('https://')) {
// URL에서 /api/files/ 이후 부분을 fileKey로 사용
const filesIndex = course.csvUrl.indexOf('/api/files/');
if (filesIndex !== -1) {
const extractedKey = course.csvUrl.substring(filesIndex + '/api/files/'.length);
// URL 디코딩
fileKey = decodeURIComponent(extractedKey);
} else {
// URL에서 마지막 경로를 fileKey로 사용
const urlParts = course.csvUrl.split('/');
const lastPart = urlParts[urlParts.length - 1];
if (lastPart) {
fileKey = decodeURIComponent(lastPart);
}
}
} else if (course.csvUrl.startsWith('csv/')) {
// "csv/" 접두사 제거
fileKey = course.csvUrl.substring(4);
} else {
// 그 외의 경우 fileKey로 사용
fileKey = course.csvUrl;
}
} else if (course.csvKey) {
// csvKey가 있으면 fileKey로 사용
fileKey = course.csvKey;
}
if (!fileKey) {
alert('CSV 파일을 찾을 수 없습니다.');
return;
}
// CSV 파일 다운로드
const fileName = `${course.title || 'quiz'}_문제.csv`;
await handleDownload(fileKey, undefined, fileName);
} catch (err) {
console.error('CSV 파일 다운로드 실패:', err);
alert('CSV 파일 다운로드에 실패했습니다.');
}
};
const handleDeleteClick = () => { const handleDeleteClick = () => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}; };
@@ -668,6 +980,7 @@ export default function AdminCourseDetailPage() {
{course.quizData.length > 0 && ( {course.quizData.length > 0 && (
<button <button
type="button" type="button"
onClick={handleCsvDownload}
className="bg-white border border-[#8c95a1] h-[32px] rounded-[6px] px-4 flex items-center justify-center gap-[4px]" className="bg-white border border-[#8c95a1] h-[32px] rounded-[6px] px-4 flex items-center justify-center gap-[4px]"
> >
<DownloadIcon className="size-[16px] text-[#4c5561]" /> <DownloadIcon className="size-[16px] text-[#4c5561]" />
@@ -677,54 +990,58 @@ export default function AdminCourseDetailPage() {
</div> </div>
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50"> <div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
{course.quizData.length > 0 ? ( {course.quizData.length > 0 ? (
<div className="p-6"> <div className="flex flex-col">
<div className="bg-white border border-[#dee1e6] rounded-[8px] overflow-hidden"> <div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto">
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
{/* 테이블 헤더 */} {/* 테이블 헤더 */}
<div className="bg-[#f1f8ff] h-[48px] flex"> <div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip shrink-0 w-full sticky top-0 z-10">
<div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3"> <div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3 shrink-0">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></p> <p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></p>
</div> </div>
<div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></p> <p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></p>
</div> </div>
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></p> <p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></p>
</div> </div>
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">1</p> <p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">1</p>
</div> </div>
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">2</p> <p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">2</p>
</div> </div>
<div className="w-[140px] flex items-center px-2 py-3"> <div className="w-[140px] flex items-center px-2 py-3 shrink-0">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">3</p> <p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">3</p>
</div> </div>
</div> </div>
{/* 테이블 행 */} {/* 테이블 행 */}
{course.quizData.map((quiz) => ( {course.quizData.map((quiz) => (
<div key={quiz.id} className="border-t border-[#dee1e6] h-[48px] flex"> <div key={quiz.id} className="border-t border-[#dee1e6] h-[48px] flex relative shrink-0 w-full">
<div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3"> <div className="content-stretch flex h-[48px] items-start overflow-clip relative rounded-[inherit] w-full">
<div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3 shrink-0">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{quiz.number}</p> <p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{quiz.number}</p>
</div> </div>
<div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.question}</p> <p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.question}</p>
</div> </div>
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.correctAnswer}</p> <p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.correctAnswer}</p>
</div> </div>
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer1}</p> <p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer1}</p>
</div> </div>
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3"> <div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer2}</p> <p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer2}</p>
</div> </div>
<div className="w-[140px] flex items-center px-2 py-3"> <div className="w-[140px] flex items-center px-2 py-3 shrink-0">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer3}</p> <p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer3}</p>
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
</div> </div>
</div>
) : ( ) : (
<div className="h-[64px] flex items-center justify-center"> <div className="h-[64px] flex items-center justify-center">
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center"> <p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">

View File

@@ -63,20 +63,209 @@ export default function AdminLessonsPage() {
learningGoal?: string; learningGoal?: string;
}>({}); }>({});
// 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;
};
// CSV 파일에서 행 개수 가져오기
const getCsvRowCount = async (lecture: any): Promise<number> => {
if (!lecture.csvUrl && !lecture.csvKey) {
return 0;
}
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 (lecture.csvUrl) {
// 전체 URL인 경우 fileKey 추출
if (lecture.csvUrl.startsWith('http://') || lecture.csvUrl.startsWith('https://')) {
// URL에서 /api/files/ 이후 부분을 fileKey로 사용
const filesIndex = lecture.csvUrl.indexOf('/api/files/');
if (filesIndex !== -1) {
fileKey = lecture.csvUrl.substring(filesIndex + '/api/files/'.length);
// URL 디코딩
fileKey = decodeURIComponent(fileKey);
} else {
// URL에서 마지막 경로를 fileKey로 사용
const urlParts = lecture.csvUrl.split('/');
fileKey = urlParts[urlParts.length - 1];
fileKey = decodeURIComponent(fileKey);
}
} else if (lecture.csvUrl.startsWith('csv/')) {
// "csv/" 접두사 제거
fileKey = lecture.csvUrl.substring(4);
} else {
// 그 외의 경우 fileKey로 사용
fileKey = lecture.csvUrl;
}
} else if (lecture.csvKey) {
// csvKey가 있으면 fileKey로 사용
fileKey = lecture.csvKey;
}
if (!fileKey) {
return 0;
}
// /api/files/{fileKey} 형태로 요청
const csvUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey)}`;
const csvResponse = await fetch(csvUrl, {
method: 'GET',
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
});
if (csvResponse.ok) {
const csvText = await csvResponse.text();
const parsed = parseCsv(csvText);
// 헤더를 제외한 행 개수 반환
return parsed.length > 0 ? parsed.length - 1 : 0;
} else {
console.warn(`CSV 파일 다운로드 실패: ${csvResponse.status} - ${csvUrl}`);
return 0;
}
} catch (error) {
console.error('CSV 파일 파싱 실패:', error, lecture);
return 0;
}
};
// 교육과정명 매핑 함수 // 교육과정명 매핑 함수
const mapCourseNames = useCallback((lectures: any[]) => { const mapCourseNames = useCallback(async (lectures: any[]) => {
const fetchedLessons: Lesson[] = lectures.map((lecture: any) => { // 먼저 기본 정보로 강좌 목록 생성
const initialLessons: Lesson[] = lectures.map((lecture: any) => {
// 첨부파일 정보 구성 // 첨부파일 정보 구성
const attachmentParts: string[] = []; const attachmentParts: string[] = [];
// 강좌 영상 개수 계산
if (lecture.videoUrl) { if (lecture.videoUrl) {
if (Array.isArray(lecture.videoUrl)) {
const count = lecture.videoUrl.length;
if (count > 0) {
attachmentParts.push(`강좌영상 ${count}`);
}
} else if (typeof lecture.videoUrl === 'string') {
attachmentParts.push('강좌영상 1개'); attachmentParts.push('강좌영상 1개');
} }
} else if (lecture.videoKeys && Array.isArray(lecture.videoKeys)) {
const count = lecture.videoKeys.length;
if (count > 0) {
attachmentParts.push(`강좌영상 ${count}`);
}
} else if (lecture.videoFileKeys && Array.isArray(lecture.videoFileKeys)) {
const count = lecture.videoFileKeys.length;
if (count > 0) {
attachmentParts.push(`강좌영상 ${count}`);
}
} else if (lecture.videoFiles && Array.isArray(lecture.videoFiles)) {
const count = lecture.videoFiles.length;
if (count > 0) {
attachmentParts.push(`강좌영상 ${count}`);
}
} else if (lecture.videoKey) {
attachmentParts.push('강좌영상 1개');
}
// VR 콘텐츠 개수 계산
if (lecture.webglUrl) { if (lecture.webglUrl) {
if (Array.isArray(lecture.webglUrl)) {
const count = lecture.webglUrl.length;
if (count > 0) {
attachmentParts.push(`VR콘텐츠 ${count}`);
}
} else if (typeof lecture.webglUrl === 'string') {
attachmentParts.push('VR콘텐츠 1개'); attachmentParts.push('VR콘텐츠 1개');
} }
if (lecture.csvKey) { } else if (lecture.vrUrl) {
attachmentParts.push('평가문제 1개'); if (Array.isArray(lecture.vrUrl)) {
const count = lecture.vrUrl.length;
if (count > 0) {
attachmentParts.push(`VR콘텐츠 ${count}`);
} }
} else if (typeof lecture.vrUrl === 'string') {
attachmentParts.push('VR콘텐츠 1개');
}
} else if (lecture.webglKeys && Array.isArray(lecture.webglKeys)) {
const count = lecture.webglKeys.length;
if (count > 0) {
attachmentParts.push(`VR콘텐츠 ${count}`);
}
} else if (lecture.vrKeys && Array.isArray(lecture.vrKeys)) {
const count = lecture.vrKeys.length;
if (count > 0) {
attachmentParts.push(`VR콘텐츠 ${count}`);
}
} else if (lecture.webglFileKeys && Array.isArray(lecture.webglFileKeys)) {
const count = lecture.webglFileKeys.length;
if (count > 0) {
attachmentParts.push(`VR콘텐츠 ${count}`);
}
} else if (lecture.vrFileKeys && Array.isArray(lecture.vrFileKeys)) {
const count = lecture.vrFileKeys.length;
if (count > 0) {
attachmentParts.push(`VR콘텐츠 ${count}`);
}
} else if (lecture.webglFiles && Array.isArray(lecture.webglFiles)) {
const count = lecture.webglFiles.length;
if (count > 0) {
attachmentParts.push(`VR콘텐츠 ${count}`);
}
} else if (lecture.webglKey || lecture.vrKey) {
attachmentParts.push('VR콘텐츠 1개');
}
// 학습 평가 문제 개수는 나중에 업데이트
// csvUrl이 전체 URL이든 상대 경로든 모두 체크
if (lecture.csvUrl || lecture.csvKey) {
attachmentParts.push('평가문제 확인 중...');
}
const attachments = attachmentParts.length > 0 const attachments = attachmentParts.length > 0
? attachmentParts.join(', ') ? attachmentParts.join(', ')
: '없음'; : '없음';
@@ -99,14 +288,46 @@ export default function AdminLessonsPage() {
courseName, courseName,
lessonName: lecture.title || lecture.lessonName || '', lessonName: lecture.title || lecture.lessonName || '',
attachments, attachments,
questionCount: lecture.csvKey ? 1 : 0, questionCount: 0, // 초기값은 0, 나중에 업데이트
createdBy: lecture.createdBy || lecture.instructorName || '관리자', createdBy: lecture.createdBy || lecture.instructorName || '관리자',
createdAt: lecture.createdAt createdAt: lecture.createdAt
? new Date(lecture.createdAt).toISOString().split('T')[0] ? new Date(lecture.createdAt).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0], : new Date().toISOString().split('T')[0],
}; };
}); });
setLessons(fetchedLessons);
// 초기 목록 설정
setLessons(initialLessons);
// CSV 파일 행 개수 병렬로 가져오기
const csvRowCounts = await Promise.all(
lectures.map(lecture => getCsvRowCount(lecture))
);
// CSV 행 개수로 업데이트
const updatedLessons = initialLessons.map((lesson, index) => {
const lecture = lectures[index];
const rowCount = csvRowCounts[index];
// 첨부파일 정보 업데이트
let attachments = lesson.attachments;
if (lecture.csvUrl || lecture.csvKey) {
if (rowCount > 0) {
attachments = attachments.replace('평가문제 확인 중...', `평가문제 ${rowCount}`);
} else {
// CSV 파일이 있지만 다운로드 실패하거나 행이 없는 경우에도 표시
attachments = attachments.replace('평가문제 확인 중...', '평가문제 1개');
}
}
return {
...lesson,
attachments,
questionCount: rowCount,
};
});
setLessons(updatedLessons);
}, [courses]); }, [courses]);
// 교육과정 목록 가져오기 // 교육과정 목록 가져오기
@@ -123,32 +344,57 @@ export default function AdminLessonsPage() {
fetchCourses(); fetchCourses();
}, []); }, []);
// 강좌 리스트 조회 // 강좌 리스트 조회 함수
useEffect(() => { const fetchLectures = useCallback(async () => {
async function fetchLectures() {
try { try {
const response = await apiService.getLectures(); const response = await apiService.getLectures();
if (response.data && Array.isArray(response.data)) { if (response.data && Array.isArray(response.data)) {
// 원본 데이터 저장 // 원본 데이터 저장
rawLecturesRef.current = response.data; rawLecturesRef.current = response.data;
// 교육과정명 매핑 함수 호출 // 교육과정명 매핑 함수 호출
mapCourseNames(response.data); await mapCourseNames(response.data);
} }
} catch (error) { } catch (error) {
console.error('강좌 리스트 조회 오류:', error); console.error('강좌 리스트 조회 오류:', error);
setLessons([]); setLessons([]);
rawLecturesRef.current = []; rawLecturesRef.current = [];
} }
}
fetchLectures();
}, [mapCourseNames]); }, [mapCourseNames]);
// 강좌 리스트 조회
useEffect(() => {
fetchLectures();
}, [fetchLectures]);
// 페이지 포커스 및 가시성 변경 시 리스트 새로고침
useEffect(() => {
const handleFocus = () => {
fetchLectures();
};
const handleVisibilityChange = () => {
if (!document.hidden) {
fetchLectures();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [fetchLectures]);
// 교육과정 목록이 로드되면 강좌 리스트의 교육과정명 업데이트 // 교육과정 목록이 로드되면 강좌 리스트의 교육과정명 업데이트
useEffect(() => { useEffect(() => {
const updateLectures = async () => {
if (rawLecturesRef.current.length > 0) { if (rawLecturesRef.current.length > 0) {
mapCourseNames(rawLecturesRef.current); await mapCourseNames(rawLecturesRef.current);
} }
};
updateLectures();
}, [mapCourseNames]); }, [mapCourseNames]);
// 현재 사용자 정보 가져오기 // 현재 사용자 정보 가져오기
@@ -527,16 +773,20 @@ export default function AdminLessonsPage() {
const vrFileKeys = vrContentFileKeys; const vrFileKeys = vrContentFileKeys;
const csvFileKey = questionFileKey; const csvFileKey = questionFileKey;
// 강좌 영상 fileKey 배열 (모든 fileKey를 배열로 저장) // 강좌 영상 fileKey (단일 파일은 문자열, 다중 파일은 배열)
let videoUrl: string[] | undefined; let videoUrl: string | string[] | undefined;
if (videoFileKeys.length > 0) { if (videoFileKeys.length === 1) {
videoUrl = videoFileKeys; // 모든 fileKey를 배열로 저장 videoUrl = videoFileKeys[0]; // 단일 파일은 문자열로
} else if (videoFileKeys.length > 1) {
videoUrl = videoFileKeys; // 다중 파일은 배열로
} }
// VR 콘텐츠 fileKey 배열 (모든 fileKey를 배열로 저장) // VR 콘텐츠 fileKey (단일 파일은 문자열, 다중 파일은 배열)
let webglUrl: string[] | undefined; let webglUrl: string | string[] | undefined;
if (vrFileKeys.length > 0) { if (vrFileKeys.length === 1) {
webglUrl = vrFileKeys; // 모든 fileKey를 배열로 저장 webglUrl = vrFileKeys[0]; // 단일 파일은 문자열로
} else if (vrFileKeys.length > 1) {
webglUrl = vrFileKeys; // 다중 파일은 배열로
} }
// 학습 평가 문제 fileKey // 학습 평가 문제 fileKey
@@ -560,10 +810,10 @@ export default function AdminLessonsPage() {
}; };
// 선택적 필드 추가 // 선택적 필드 추가
if (videoUrl && videoUrl.length > 0) { if (videoUrl) {
requestBody.videoUrl = videoUrl; requestBody.videoUrl = videoUrl;
} }
if (webglUrl && webglUrl.length > 0) { if (webglUrl) {
requestBody.webglUrl = webglUrl; requestBody.webglUrl = webglUrl;
} }
if (csvKey) { if (csvKey) {

View File

@@ -0,0 +1,65 @@
'use client';
import React from 'react';
type NoticeCancelModalProps = {
open: boolean;
onClose: () => void;
onConfirm: () => void;
};
/**
* 공지사항 작성 취소 확인 모달
*/
export default function NoticeCancelModal({
open,
onClose,
onConfirm,
}: NoticeCancelModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end w-[400px]">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<div className="flex gap-[8px] items-start w-full">
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
.
</p>
</div>
<div className="flex gap-[8px] items-start w-full">
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
<p className="mb-0"> ?</p>
</div>
</div>
</div>
<div className="flex gap-[8px] items-center justify-end shrink-0">
<button
type="button"
onClick={onClose}
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
>
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
</p>
</button>
<button
type="button"
onClick={onConfirm}
className="bg-[#1f2b91] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#1a2478] cursor-pointer transition-colors"
>
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
</p>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import React from 'react';
type NoticeDeleteModalProps = {
open: boolean;
onClose: () => void;
onConfirm: () => void;
isDeleting?: boolean;
};
/**
* 공지사항 삭제 확인 모달
*/
export default function NoticeDeleteModal({
open,
onClose,
onConfirm,
isDeleting = false,
}: NoticeDeleteModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end w-[400px]">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<div className="flex gap-[8px] items-start w-full">
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
?
</p>
</div>
<div className="flex gap-[8px] items-start w-full">
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
<p className="mb-0"> .</p>
<p> ?</p>
</div>
</div>
</div>
<div className="flex gap-[8px] items-center justify-end shrink-0">
<button
type="button"
onClick={onClose}
disabled={isDeleting}
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
</p>
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
{isDeleting ? '삭제 중...' : '삭제'}
</p>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,441 @@
'use client';
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import AdminSidebar from "@/app/components/AdminSidebar";
import BackArrowSvg from "@/app/svgs/backarrow";
import CloseXOSvg from "@/app/svgs/closexo";
import apiService from "@/app/lib/apiService";
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
type Attachment = {
name: string;
size: string;
url?: string;
fileKey?: string;
};
export default function AdminNoticeEditPage() {
const params = useParams();
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [attachedFile, setAttachedFile] = useState<File | null>(null);
const [fileKey, setFileKey] = useState<string | null>(null);
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const characterCount = useMemo(() => content.length, [content]);
// 공지사항 데이터 로드
useEffect(() => {
async function fetchNotice() {
if (!params?.id) {
setIsLoadingData(false);
return;
}
try {
setIsLoadingData(true);
const response = await apiService.getNotice(params.id);
const data = response.data;
// 제목 설정
setTitle(data.title || '');
// 내용 설정 (배열이면 join, 문자열이면 그대로)
if (data.content) {
if (Array.isArray(data.content)) {
setContent(data.content.join('\n'));
} else if (typeof data.content === 'string') {
setContent(data.content);
} else {
setContent(String(data.content));
}
}
// 기존 첨부파일 정보 설정
if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) {
const att = data.attachments[0];
setExistingAttachment({
name: att.name || att.fileName || att.filename || '첨부파일',
size: att.size || att.fileSize || '',
url: att.url || att.downloadUrl,
fileKey: att.fileKey || att.key || att.fileId,
});
// 기존 파일이 있으면 fileKey도 설정
if (att.fileKey || att.key || att.fileId) {
setFileKey(att.fileKey || att.key || att.fileId);
}
} else if (data.attachment) {
// 단일 첨부파일인 경우
setExistingAttachment({
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
size: data.attachment.size || data.attachment.fileSize || '',
url: data.attachment.url || data.attachment.downloadUrl,
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
});
if (data.attachment.fileKey || data.attachment.key || data.attachment.fileId) {
setFileKey(data.attachment.fileKey || data.attachment.key || data.attachment.fileId);
}
}
} catch (error) {
console.error('공지사항 조회 오류:', error);
alert('공지사항을 불러오는 중 오류가 발생했습니다.');
router.push('/admin/notices');
} finally {
setIsLoadingData(false);
}
}
fetchNotice();
}, [params?.id, router]);
const handleBack = () => {
router.push(`/admin/notices/${params.id}`);
};
const handleFileAttach = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 30 * 1024 * 1024) {
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
return;
}
try {
setIsLoading(true);
// 단일 파일 업로드
const uploadResponse = await apiService.uploadFile(file);
// 응답에서 fileKey 추출
let extractedFileKey: string | null = null;
if (uploadResponse.data?.fileKey) {
extractedFileKey = uploadResponse.data.fileKey;
} else if (uploadResponse.data?.key) {
extractedFileKey = uploadResponse.data.key;
} else if (uploadResponse.data?.id) {
extractedFileKey = uploadResponse.data.id;
} else if (uploadResponse.data?.imageKey) {
extractedFileKey = uploadResponse.data.imageKey;
} else if (uploadResponse.data?.fileId) {
extractedFileKey = uploadResponse.data.fileId;
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
const result = uploadResponse.data.results[0];
if (result.ok && result.fileKey) {
extractedFileKey = result.fileKey;
}
}
if (extractedFileKey) {
setFileKey(extractedFileKey);
setAttachedFile(file);
// 새 파일을 업로드하면 기존 파일 정보 제거
setExistingAttachment(null);
} else {
throw new Error('파일 키를 받아오지 못했습니다.');
}
} catch (error) {
console.error('파일 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
setAttachedFile(null);
setFileKey(null);
} finally {
setIsLoading(false);
// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}
};
const handleFileRemove = () => {
setAttachedFile(null);
setExistingAttachment(null);
setFileKey(null);
// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSave = async () => {
if (!title.trim() || !content.trim()) {
setIsValidationModalOpen(true);
return;
}
if (!params?.id) {
alert('공지사항 ID를 찾을 수 없습니다.');
return;
}
try {
setIsLoading(true);
// 공지사항 수정 API 호출
const noticeData: any = {
title: title.trim(),
content: content.trim(),
};
// fileKey와 파일 정보가 있으면 attachments 배열로 포함
if (fileKey) {
if (attachedFile) {
// 새로 업로드한 파일
noticeData.attachments = [
{
fileKey: fileKey,
filename: attachedFile.name,
mimeType: attachedFile.type || 'application/octet-stream',
size: attachedFile.size,
},
];
} else if (existingAttachment && existingAttachment.fileKey) {
// 기존 파일 유지
noticeData.attachments = [
{
fileKey: existingAttachment.fileKey,
filename: existingAttachment.name,
},
];
}
}
await apiService.updateNotice(params.id, noticeData);
alert('공지사항이 수정되었습니다.');
router.push(`/admin/notices/${params.id}`);
} catch (error) {
console.error('공지사항 수정 실패:', error);
alert('공지사항 수정에 실패했습니다. 다시 시도해주세요.');
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
if (title.trim() || content.trim() || attachedFile || fileKey) {
setIsCancelModalOpen(true);
} else {
handleBack();
}
};
const handleCancelConfirm = () => {
setIsCancelModalOpen(false);
handleBack();
};
if (isLoadingData) {
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">
<div className="h-[100px] flex items-center justify-center px-[32px]">
<p className="text-[16px] font-medium text-[#333c47]"> ...</p>
</div>
</div>
</main>
</div>
</div>
</div>
);
}
return (
<>
<NoticeValidationModal
open={isValidationModalOpen}
onClose={() => setIsValidationModalOpen(false)}
/>
<NoticeCancelModal
open={isCancelModalOpen}
onClose={() => setIsCancelModalOpen(false)}
onConfirm={handleCancelConfirm}
/>
<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 gap-3 items-center">
<button
type="button"
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 cursor-pointer"
aria-label="뒤로가기"
>
<BackArrowSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
</div>
{/* 작성 폼 */}
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
<div className="flex flex-col gap-6 w-full">
{/* 제목 입력 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력해 주세요."
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
/>
</div>
{/* 내용 입력 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
</label>
<div className="relative w-full">
<textarea
value={content}
onChange={(e) => {
const newContent = e.target.value;
if (newContent.length <= 1000) {
setContent(newContent);
}
}}
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
/>
<div className="absolute bottom-3 right-3">
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
{characterCount}/1000
</p>
</div>
</div>
</div>
{/* 첨부 파일 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<div className="flex items-center justify-between h-8 w-full">
<div className="flex items-center gap-3 flex-1 min-w-0">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
{' '}
<span className="font-normal">
{(attachedFile || existingAttachment) ? 1 : 0}/1
</span>
</label>
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
30MB
</p>
</div>
<button
type="button"
onClick={handleFileAttach}
disabled={isLoading}
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
{isLoading ? '업로드 중...' : '첨부'}
</span>
</button>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
className="hidden"
accept="*/*"
/>
</div>
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-between px-4">
{attachedFile ? (
<>
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
{attachedFile.name}
</p>
<button
type="button"
onClick={handleFileRemove}
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
aria-label="파일 삭제"
>
<CloseXOSvg />
</button>
</>
) : existingAttachment ? (
<>
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
{existingAttachment.name}
</p>
<button
type="button"
onClick={handleFileRemove}
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
aria-label="파일 삭제"
>
<CloseXOSvg />
</button>
</>
) : (
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
.
</p>
)}
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
<button
type="button"
onClick={handleCancel}
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSave}
disabled={isLoading}
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '저장 중...' : '저장하기'}
</button>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
</>
);
}

View File

@@ -9,6 +9,7 @@ import DownloadIcon from '@/app/svgs/downloadicon';
import PaperClipSvg from '@/app/svgs/paperclipsvg'; import PaperClipSvg from '@/app/svgs/paperclipsvg';
import apiService from '@/app/lib/apiService'; import apiService from '@/app/lib/apiService';
import type { Notice } from '@/app/admin/notices/mockData'; import type { Notice } from '@/app/admin/notices/mockData';
import NoticeDeleteModal from '@/app/admin/notices/NoticeDeleteModal';
type Attachment = { type Attachment = {
name: string; name: string;
@@ -25,6 +26,8 @@ export default function AdminNoticeDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [showToast, setShowToast] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchNotice() { async function fetchNotice() {
@@ -57,7 +60,7 @@ export default function AdminNoticeDetailPage() {
// 첨부파일 정보 처리 // 첨부파일 정보 처리
if (data.attachments && Array.isArray(data.attachments)) { if (data.attachments && Array.isArray(data.attachments)) {
setAttachments(data.attachments.map((att: any) => ({ setAttachments(data.attachments.map((att: any) => ({
name: att.name || att.fileName || '', name: att.name || att.fileName || att.filename || '',
size: att.size || att.fileSize || '', size: att.size || att.fileSize || '',
url: att.url || att.downloadUrl, url: att.url || att.downloadUrl,
fileKey: att.fileKey || att.key || att.fileId, fileKey: att.fileKey || att.key || att.fileId,
@@ -65,7 +68,7 @@ export default function AdminNoticeDetailPage() {
} else if (transformedNotice.hasAttachment && data.attachment) { } else if (transformedNotice.hasAttachment && data.attachment) {
// 단일 첨부파일인 경우 // 단일 첨부파일인 경우
setAttachments([{ setAttachments([{
name: data.attachment.name || data.attachment.fileName || '첨부파일', name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
size: data.attachment.size || data.attachment.fileSize || '', size: data.attachment.size || data.attachment.fileSize || '',
url: data.attachment.url || data.attachment.downloadUrl, url: data.attachment.url || data.attachment.downloadUrl,
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId, fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
@@ -88,6 +91,16 @@ export default function AdminNoticeDetailPage() {
fetchNotice(); fetchNotice();
}, [params?.id]); }, [params?.id]);
// 토스트 자동 닫기
useEffect(() => {
if (showToast) {
const timer = setTimeout(() => {
setShowToast(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [showToast]);
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => { const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
if (url) { if (url) {
// URL이 있으면 직접 다운로드 // URL이 있으면 직접 다운로드
@@ -299,34 +312,12 @@ export default function AdminNoticeDetailPage() {
<div className="flex items-center justify-end gap-[12px]"> <div className="flex items-center justify-end gap-[12px]">
<button <button
type="button" type="button"
onClick={async () => { onClick={() => setIsDeleteModalOpen(true)}
if (!confirm('정말 삭제하시겠습니까?')) {
return;
}
if (!params?.id) {
alert('공지사항 ID를 찾을 수 없습니다.');
return;
}
try {
setIsDeleting(true);
await apiService.deleteNotice(params.id);
alert('공지사항이 삭제되었습니다.');
router.push('/admin/notices');
} catch (err) {
console.error('공지사항 삭제 오류:', err);
const errorMessage = err instanceof Error ? err.message : '공지사항 삭제에 실패했습니다.';
alert(errorMessage);
} finally {
setIsDeleting(false);
}
}}
disabled={isDeleting} disabled={isDeleting}
className="bg-[#FEF2F2] h-[48px] rounded-[10px] px-[8px] shrink-0 min-w-[80px] flex items-center justify-center hover:bg-[#FEE2E2] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="bg-[#FEF2F2] h-[48px] rounded-[10px] px-[8px] shrink-0 min-w-[80px] flex items-center justify-center hover:bg-[#FEE2E2] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<span className="text-[16px] font-semibold leading-[1.5] text-[#F64C4C] text-center"> <span className="text-[16px] font-semibold leading-[1.5] text-[#F64C4C] text-center">
{isDeleting ? '삭제 중...' : '삭제'}
</span> </span>
</button> </button>
<button <button
@@ -344,6 +335,55 @@ export default function AdminNoticeDetailPage() {
</main> </main>
</div> </div>
</div> </div>
{/* 삭제 확인 모달 */}
<NoticeDeleteModal
open={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={async () => {
if (!params?.id) {
alert('공지사항 ID를 찾을 수 없습니다.');
setIsDeleteModalOpen(false);
return;
}
try {
setIsDeleting(true);
await apiService.deleteNotice(params.id);
setIsDeleteModalOpen(false);
setShowToast(true);
// 토스트 표시 후 목록 페이지로 이동
setTimeout(() => {
router.push('/admin/notices');
}, 1500);
} catch (err) {
console.error('공지사항 삭제 오류:', err);
const errorMessage = err instanceof Error ? err.message : '공지사항 삭제에 실패했습니다.';
alert(errorMessage);
setIsDeleteModalOpen(false);
} finally {
setIsDeleting(false);
}
}}
isDeleting={isDeleting}
/>
{/* 삭제 완료 토스트 */}
{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>
); );
} }

View File

@@ -8,6 +8,7 @@ import BackArrowSvg from "@/app/svgs/backarrow";
import { type Notice } from "@/app/admin/notices/mockData"; import { type Notice } from "@/app/admin/notices/mockData";
import apiService from "@/app/lib/apiService"; import apiService from "@/app/lib/apiService";
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal"; import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
export default function AdminNoticesPage() { export default function AdminNoticesPage() {
const router = useRouter(); const router = useRouter();
@@ -20,6 +21,7 @@ export default function AdminNoticesPage() {
const [fileKey, setFileKey] = useState<string | null>(null); const [fileKey, setFileKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false); const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 날짜를 yyyy-mm-dd 형식으로 포맷팅 // 날짜를 yyyy-mm-dd 형식으로 포맷팅
@@ -223,14 +225,17 @@ export default function AdminNoticesPage() {
const handleCancel = () => { const handleCancel = () => {
if (title.trim() || content.trim() || attachedFile || fileKey) { if (title.trim() || content.trim() || attachedFile || fileKey) {
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) { setIsCancelModalOpen(true);
handleBack();
}
} else { } else {
handleBack(); handleBack();
} }
}; };
const handleCancelConfirm = () => {
setIsCancelModalOpen(false);
handleBack();
};
const ITEMS_PER_PAGE = 10; const ITEMS_PER_PAGE = 10;
const sortedNotices = useMemo(() => { const sortedNotices = useMemo(() => {
return [...notices].sort((a, b) => { return [...notices].sort((a, b) => {
@@ -252,6 +257,11 @@ export default function AdminNoticesPage() {
open={isValidationModalOpen} open={isValidationModalOpen}
onClose={() => setIsValidationModalOpen(false)} onClose={() => setIsValidationModalOpen(false)}
/> />
<NoticeCancelModal
open={isCancelModalOpen}
onClose={() => setIsCancelModalOpen(false)}
onConfirm={handleCancelConfirm}
/>
<div className="min-h-screen flex flex-col bg-white"> <div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */} {/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center"> <div className="flex flex-1 min-h-0 justify-center">
@@ -357,13 +367,13 @@ export default function AdminNoticesPage() {
accept="*/*" accept="*/*"
/> />
</div> </div>
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-center"> <div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center px-4">
{attachedFile ? ( {attachedFile ? (
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]"> <p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
{attachedFile.name} {attachedFile.name}
</p> </p>
) : ( ) : (
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center"> <p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
. .
</p> </p>
)} )}

View File

@@ -679,7 +679,7 @@ class ApiService {
} }
/** /**
* 파일 다운로드 (이미지 URL 가져오기) * 파일 다운로드
* @param fileKey 파일 키 * @param fileKey 파일 키
* @returns 파일 URL (Blob URL), 파일이 없으면 null 반환 * @returns 파일 URL (Blob URL), 파일이 없으면 null 반환
*/ */