This commit is contained in:
2025-11-29 14:27:01 +09:00
parent 91d14fdabf
commit 872a88866e
5 changed files with 761 additions and 219 deletions

View File

@@ -42,8 +42,8 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
setIsLoadingInstructors(true); setIsLoadingInstructors(true);
try { try {
// 외부 API 호출 // 외부 API 호출 - type이 'ADMIN'인 사용자만 조회
const response = await apiService.getUsersCompact(); const response = await apiService.getUsersCompact({ type: 'ADMIN', limit: 10 });
const data = response.data; const data = response.data;
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태) // API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
@@ -57,50 +57,50 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
// API 응답 데이터를 UserRow 형식으로 변환 // API 응답 데이터를 UserRow 형식으로 변환
const transformedUsers: UserRow[] = usersArray.length > 0 const transformedUsers: UserRow[] = usersArray.length > 0
? usersArray.map((user: any) => { ? usersArray.map((user: any) => {
// 가입일을 YYYY-MM-DD 형식으로 변환 // 가입일을 YYYY-MM-DD 형식으로 변환
const formatDate = (dateString: string | null | undefined): string => { const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return new Date().toISOString().split('T')[0]; if (!dateString) return new Date().toISOString().split('T')[0];
try { try {
const date = new Date(dateString); const date = new Date(dateString);
return date.toISOString().split('T')[0]; return date.toISOString().split('T')[0];
} catch { } catch {
return new Date().toISOString().split('T')[0]; return new Date().toISOString().split('T')[0];
}
};
// null 값을 명시적으로 처리
const getValue = (value: any, fallback: string = '-') => {
if (value === null || value === undefined) return fallback;
if (typeof value === 'string' && value.trim() === '') return fallback;
return String(value);
};
// status가 "ACTIVE"이면 활성화, 아니면 비활성화
const accountStatus: 'active' | 'inactive' =
user.status === 'ACTIVE' || user.status === 'active' ? 'active' : 'inactive';
// role 데이터 처리
let userRole: 'learner' | 'instructor' | 'admin' = 'learner'; // 기본값
if (user.role) {
const roleLower = String(user.role).toLowerCase();
if (roleLower === 'instructor' || roleLower === '강사') {
userRole = 'instructor';
} else if (roleLower === 'admin' || roleLower === '관리자') {
userRole = 'admin';
} else {
userRole = 'learner';
}
} }
};
return {
// null 값을 명시적으로 처리 id: String(user.id || user.userId || Math.random()),
const getValue = (value: any, fallback: string = '-') => { joinDate: formatDate(user.createdAt || user.joinDate || user.join_date),
if (value === null || value === undefined) return fallback; name: getValue(user.name || user.userName, '-'),
if (typeof value === 'string' && value.trim() === '') return fallback; email: getValue(user.email || user.userEmail, '-'),
return String(value); role: userRole,
}; status: accountStatus,
};
// status가 "ACTIVE"이면 활성화, 아니면 비활성화 })
const accountStatus: 'active' | 'inactive' =
user.status === 'ACTIVE' || user.status === 'active' ? 'active' : 'inactive';
// role 데이터 처리
let userRole: 'learner' | 'instructor' | 'admin' = 'learner'; // 기본값
if (user.role) {
const roleLower = String(user.role).toLowerCase();
if (roleLower === 'instructor' || roleLower === '강사') {
userRole = 'instructor';
} else if (roleLower === 'admin' || roleLower === '관리자') {
userRole = 'admin';
} else {
userRole = 'learner';
}
}
return {
id: String(user.id || user.userId || Math.random()),
joinDate: formatDate(user.createdAt || user.joinDate || user.join_date),
name: getValue(user.name || user.userName, '-'),
email: getValue(user.email || user.userEmail, '-'),
role: userRole,
status: accountStatus,
};
})
: []; : [];
setInstructors(transformedUsers); setInstructors(transformedUsers);
@@ -322,10 +322,21 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
} else { } else {
// 등록 모드: POST /subjects // 등록 모드: POST /subjects
try { try {
await apiService.createSubject({ const createRequestBody: {
courseName: courseName.trim(), title: string;
instructorName: selectedInstructor.name, instructor: string;
}); imageKey?: string;
} = {
title: courseName.trim(),
instructor: selectedInstructor.name,
};
// imageKey 처리: 등록 모드에서 새 이미지가 있으면 포함
if (imageKey) {
createRequestBody.imageKey = imageKey;
}
await apiService.createSubject(createRequestBody);
// 성공 시 onSave 콜백 호출 및 모달 닫기 // 성공 시 onSave 콜백 호출 및 모달 닫기
if (onSave && selectedInstructor) { if (onSave && selectedInstructor) {

View File

@@ -41,6 +41,11 @@ export default function LessonEditPage() {
const [csvHeaders, setCsvHeaders] = useState<string[]>([]); const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
const [csvRows, setCsvRows] = useState<string[][]>([]); const [csvRows, setCsvRows] = useState<string[][]>([]);
// 파일 교체 확인 모달 상태
const [isFileReplaceModalOpen, setIsFileReplaceModalOpen] = useState(false);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [pendingFileType, setPendingFileType] = useState<'video' | 'vr' | 'csv' | null>(null);
// 원본 데이터 저장 (변경사항 비교용) // 원본 데이터 저장 (변경사항 비교용)
const [originalData, setOriginalData] = useState<{ const [originalData, setOriginalData] = useState<{
title?: string; title?: string;
@@ -243,6 +248,251 @@ export default function LessonEditPage() {
router.push(`/admin/lessons/${params.id}`); 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 파일 파싱
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');
// 단일 파일 업로드
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);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
}
};
// 파일 교체 확인 모달 핸들러
const handleFileReplaceConfirm = async () => {
if (!pendingFiles.length || !pendingFileType) return;
setIsFileReplaceModalOpen(false);
if (pendingFileType === 'video') {
await handleVideoFileUpload(pendingFiles);
} else if (pendingFileType === 'vr') {
await handleVrFileUpload(pendingFiles);
} else if (pendingFileType === 'csv' && pendingFiles.length > 0) {
await handleCsvFileUpload(pendingFiles[0]);
}
setPendingFiles([]);
setPendingFileType(null);
};
const handleFileReplaceCancel = () => {
setIsFileReplaceModalOpen(false);
setPendingFiles([]);
setPendingFileType(null);
};
const handleSaveClick = async () => { const handleSaveClick = async () => {
// 유효성 검사 // 유효성 검사
const newErrors: { const newErrors: {
@@ -666,32 +916,17 @@ export default function LessonEditPage() {
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { // 새로 첨부한 파일이 있으면 확인 모달 표시
// 다중 파일 업로드 if (courseVideoFiles.length > 0 || courseVideoFileKeys.length > 0) {
const uploadResponse = await apiService.uploadFiles(validFiles); setPendingFiles(validFiles);
setPendingFileType('video');
// 응답에서 fileKey 배열 추출 setIsFileReplaceModalOpen(true);
const fileKeys: string[] = []; e.target.value = '';
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) { return;
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setCourseVideoFileObjects(prev => [...prev, ...validFiles]);
setCourseVideoFileKeys(prev => [...prev, ...fileKeys]);
setCourseVideoCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) {
console.error('강좌 영상 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
} }
// 새로 첨부한 파일이 없으면 바로 업로드
handleVideoFileUpload(validFiles);
} }
e.target.value = ''; e.target.value = '';
}} }}
@@ -825,32 +1060,17 @@ export default function LessonEditPage() {
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { // 새로 첨부한 파일이 있으면 확인 모달 표시
// 다중 파일 업로드 if (vrContentFiles.length > 0 || vrContentFileKeys.length > 0) {
const uploadResponse = await apiService.uploadFiles(validFiles); setPendingFiles(validFiles);
setPendingFileType('vr');
// 응답에서 fileKey 배열 추출 setIsFileReplaceModalOpen(true);
const fileKeys: string[] = []; e.target.value = '';
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) { return;
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setVrContentFileObjects(prev => [...prev, ...validFiles]);
setVrContentFileKeys(prev => [...prev, ...fileKeys]);
setVrContentCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) {
console.error('VR 콘텐츠 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
} }
// 새로 첨부한 파일이 없으면 바로 업로드
handleVrFileUpload(validFiles);
} }
e.target.value = ''; e.target.value = '';
}} }}
@@ -1024,38 +1244,17 @@ export default function LessonEditPage() {
reader.readAsText(file, 'UTF-8'); reader.readAsText(file, 'UTF-8');
// 단일 파일 업로드 // 기존 파일이 있으면 확인 모달 표시
const uploadResponse = await apiService.uploadFile(file); if (questionFileObject || questionFileKey) {
setPendingFiles([file]);
// 응답에서 fileKey 추출 setPendingFileType('csv');
let fileKey: string | null = null; setIsFileReplaceModalOpen(true);
if (uploadResponse.data?.fileKey) { e.target.value = '';
fileKey = uploadResponse.data.fileKey; return;
} else if (uploadResponse.data?.key) {
fileKey = uploadResponse.data.key;
} else if (uploadResponse.data?.id) {
fileKey = uploadResponse.data.id;
} else if (uploadResponse.data?.imageKey) {
fileKey = uploadResponse.data.imageKey;
} else if (uploadResponse.data?.fileId) {
fileKey = uploadResponse.data.fileId;
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
fileKey = 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) {
fileKey = result.fileKey;
}
} }
if (fileKey) { // 기존 파일이 없으면 바로 업로드
setQuestionFileObject(file); await handleCsvFileUpload(file);
setQuestionFileKey(fileKey);
setQuestionFileCount(1);
setExistingQuestionFile(null);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('학습 평가 문제 업로드 실패:', error); console.error('학습 평가 문제 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
@@ -1203,6 +1402,52 @@ export default function LessonEditPage() {
</div> </div>
</div> </div>
)} )}
{/* 파일 교체 확인 모달 */}
{isFileReplaceModalOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleFileReplaceCancel}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[500px]">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<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={handleFileReplaceCancel}
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={handleFileReplaceConfirm}
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>
)}
</div> </div>
); );
} }

View File

@@ -51,6 +51,11 @@ export default function AdminLessonsPage() {
const [csvHeaders, setCsvHeaders] = useState<string[]>([]); const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
const [csvRows, setCsvRows] = useState<string[][]>([]); const [csvRows, setCsvRows] = useState<string[][]>([]);
// 파일 교체 확인 모달 상태
const [isFileReplaceModalOpen, setIsFileReplaceModalOpen] = useState(false);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [pendingFileType, setPendingFileType] = useState<'video' | 'vr' | 'csv' | null>(null);
// 에러 상태 // 에러 상태
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
selectedCourse?: string; selectedCourse?: string;
@@ -245,6 +250,250 @@ export default function AdminLessonsPage() {
setIsRegistrationMode(true); setIsRegistrationMode(true);
}; };
// 파일 업로드 함수
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(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(fileKeys.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) {
console.error('VR 콘텐츠 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
}
};
const handleCsvFileUpload = async (file: File) => {
try {
// CSV 파일 파싱
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');
// 단일 파일 업로드
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);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) {
console.error('학습 평가 문제 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
}
};
// 파일 교체 확인 모달 핸들러
const handleFileReplaceConfirm = async () => {
if (!pendingFiles.length || !pendingFileType) return;
setIsFileReplaceModalOpen(false);
if (pendingFileType === 'video') {
await handleVideoFileUpload(pendingFiles);
} else if (pendingFileType === 'vr') {
await handleVrFileUpload(pendingFiles);
} else if (pendingFileType === 'csv' && pendingFiles.length > 0) {
await handleCsvFileUpload(pendingFiles[0]);
}
setPendingFiles([]);
setPendingFileType(null);
};
const handleFileReplaceCancel = () => {
setIsFileReplaceModalOpen(false);
setPendingFiles([]);
setPendingFileType(null);
};
const handleSaveClick = async () => { const handleSaveClick = async () => {
// 유효성 검사 // 유효성 검사
const newErrors: { const newErrors: {
@@ -643,32 +892,17 @@ export default function AdminLessonsPage() {
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { // 기존 파일이 있으면 확인 모달 표시
// 다중 파일 업로드 if (courseVideoFiles.length > 0 || courseVideoFileKeys.length > 0) {
const uploadResponse = await apiService.uploadFiles(validFiles); setPendingFiles(validFiles);
setPendingFileType('video');
// 응답에서 fileKey 배열 추출 setIsFileReplaceModalOpen(true);
const fileKeys: string[] = []; e.target.value = '';
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) { return;
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setCourseVideoFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setCourseVideoFileObjects(prev => [...prev, ...validFiles]);
setCourseVideoFileKeys(prev => [...prev, ...fileKeys]);
setCourseVideoCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) {
console.error('강좌 영상 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
} }
// 기존 파일이 없으면 바로 업로드
handleVideoFileUpload(validFiles);
} }
// input 초기화 (같은 파일 다시 선택 가능하도록) // input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = ''; e.target.value = '';
@@ -782,32 +1016,17 @@ export default function AdminLessonsPage() {
} }
if (validFiles.length > 0) { if (validFiles.length > 0) {
try { // 기존 파일이 있으면 확인 모달 표시
// 다중 파일 업로드 if (vrContentFiles.length > 0 || vrContentFileKeys.length > 0) {
const uploadResponse = await apiService.uploadFiles(validFiles); setPendingFiles(validFiles);
setPendingFileType('vr');
// 응답에서 fileKey 배열 추출 setIsFileReplaceModalOpen(true);
const fileKeys: string[] = []; e.target.value = '';
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) { return;
uploadResponse.data.results.forEach((result: any) => {
if (result.ok && result.fileKey) {
fileKeys.push(result.fileKey);
}
});
}
if (fileKeys.length > 0) {
setVrContentFiles(prev => [...prev, ...validFiles.map(f => f.name)]);
setVrContentFileObjects(prev => [...prev, ...validFiles]);
setVrContentFileKeys(prev => [...prev, ...fileKeys]);
setVrContentCount(prev => prev + validFiles.length);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) {
console.error('VR 콘텐츠 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
} }
// 기존 파일이 없으면 바로 업로드
handleVrFileUpload(validFiles);
} }
// input 초기화 (같은 파일 다시 선택 가능하도록) // input 초기화 (같은 파일 다시 선택 가능하도록)
e.target.value = ''; e.target.value = '';
@@ -967,37 +1186,17 @@ export default function AdminLessonsPage() {
reader.readAsText(file, 'UTF-8'); reader.readAsText(file, 'UTF-8');
// 단일 파일 업로드 // 기존 파일이 있으면 확인 모달 표시
const uploadResponse = await apiService.uploadFile(file); if (questionFileObject || questionFileKey) {
setPendingFiles([file]);
// 응답에서 fileKey 추출 setPendingFileType('csv');
let fileKey: string | null = null; setIsFileReplaceModalOpen(true);
if (uploadResponse.data?.fileKey) { e.target.value = '';
fileKey = uploadResponse.data.fileKey; return;
} else if (uploadResponse.data?.key) {
fileKey = uploadResponse.data.key;
} else if (uploadResponse.data?.id) {
fileKey = uploadResponse.data.id;
} else if (uploadResponse.data?.imageKey) {
fileKey = uploadResponse.data.imageKey;
} else if (uploadResponse.data?.fileId) {
fileKey = uploadResponse.data.fileId;
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
fileKey = 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) {
fileKey = result.fileKey;
}
} }
if (fileKey) { // 기존 파일이 없으면 바로 업로드
setQuestionFileObject(file); await handleCsvFileUpload(file);
setQuestionFileKey(fileKey);
setQuestionFileCount(1);
} else {
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
}
} catch (error) { } catch (error) {
console.error('학습 평가 문제 업로드 실패:', error); console.error('학습 평가 문제 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.'); alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
@@ -1335,6 +1534,52 @@ export default function AdminLessonsPage() {
</div> </div>
</div> </div>
)} )}
{/* 파일 교체 확인 모달 */}
{isFileReplaceModalOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleFileReplaceCancel}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end min-w-[500px]">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<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={handleFileReplaceCancel}
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={handleFileReplaceConfirm}
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>
)}
</div> </div>
); );
} }

View File

@@ -24,6 +24,7 @@ export default function AdminNoticeDetailPage() {
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
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);
useEffect(() => { useEffect(() => {
async function fetchNotice() { async function fetchNotice() {
@@ -298,16 +299,34 @@ 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={() => { onClick={async () => {
if (confirm('정말 삭제하시겠습니까?')) { if (!confirm('정말 삭제하시겠습니까?')) {
// TODO: 삭제 API 호출 return;
}
if (!params?.id) {
alert('공지사항 ID를 찾을 수 없습니다.');
return;
}
try {
setIsDeleting(true);
await apiService.deleteNotice(params.id);
alert('공지사항이 삭제되었습니다.');
router.push('/admin/notices'); router.push('/admin/notices');
} catch (err) {
console.error('공지사항 삭제 오류:', err);
const errorMessage = err instanceof Error ? err.message : '공지사항 삭제에 실패했습니다.';
alert(errorMessage);
} finally {
setIsDeleting(false);
} }
}} }}
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={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"
> >
<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

View File

@@ -14,6 +14,7 @@ interface RequestConfig {
headers?: Record<string, string>; headers?: Record<string, string>;
body?: any; body?: any;
timeout?: number; timeout?: number;
params?: Record<string, string | number | undefined>;
} }
class ApiService { class ApiService {
@@ -96,10 +97,25 @@ class ApiService {
method = 'GET', method = 'GET',
headers = {}, headers = {},
body, body,
timeout = this.defaultTimeout timeout = this.defaultTimeout,
params
} = config; } = config;
const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`; let url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
// 쿼리 파라미터 추가
if (params) {
const queryParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
});
const queryString = queryParams.toString();
if (queryString) {
url += (url.includes('?') ? '&' : '?') + queryString;
}
}
// FormData 여부 확인 // FormData 여부 확인
const isFormData = body instanceof FormData; const isFormData = body instanceof FormData;
@@ -271,8 +287,9 @@ class ApiService {
* 과목 생성 * 과목 생성
*/ */
async createSubject(subjectData: { async createSubject(subjectData: {
courseName: string; title: string;
instructorName: string; instructor: string;
imageKey?: string;
}) { }) {
return this.request('/subjects', { return this.request('/subjects', {
method: 'POST', method: 'POST',
@@ -342,8 +359,13 @@ class ApiService {
/** /**
* 회원 리스트 조회 (컴팩트) - 관리자 전용 * 회원 리스트 조회 (컴팩트) - 관리자 전용
*/ */
async getUsersCompact() { async getUsersCompact(params?: { type?: string; limit?: number }) {
return this.request('/admin/users/compact'); return this.request('/admin/users/compact', {
params: params ? {
...(params.type && { type: params.type }),
...(params.limit && { limit: params.limit }),
} : undefined,
});
} }
/** /**