diff --git a/src/app/admin/lessons/[id]/edit/page.tsx b/src/app/admin/lessons/[id]/edit/page.tsx index f6d95ed..e8d6489 100644 --- a/src/app/admin/lessons/[id]/edit/page.tsx +++ b/src/app/admin/lessons/[id]/edit/page.tsx @@ -8,6 +8,7 @@ import BackArrowSvg from "@/app/svgs/backarrow"; import { getCourses, type Course } from "@/app/admin/courses/mockData"; import CloseXOSvg from "@/app/svgs/closexo"; import apiService from "@/app/lib/apiService"; +import NoChangesModal from "@/app/admin/notices/NoChangesModal"; export default function LessonEditPage() { const params = useParams(); @@ -20,7 +21,6 @@ export default function LessonEditPage() { const vrFileInputRef = useRef(null); const csvFileInputRef = useRef(null); const [courses, setCourses] = useState([]); - const [showToast, setShowToast] = useState(false); // 폼 상태 const [selectedCourse, setSelectedCourse] = useState(""); @@ -49,6 +49,9 @@ export default function LessonEditPage() { const [pendingFiles, setPendingFiles] = useState([]); const [pendingFileType, setPendingFileType] = useState<'video' | 'vr' | 'csv' | null>(null); + // 변경사항 없음 모달 상태 + const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false); + // 원본 데이터 저장 (변경사항 비교용) const [originalData, setOriginalData] = useState<{ title?: string; @@ -359,16 +362,6 @@ export default function LessonEditPage() { }; }, [isDropdownOpen]); - // 토스트 자동 닫기 - useEffect(() => { - if (showToast) { - const timer = setTimeout(() => { - setShowToast(false); - }, 3000); // 3초 후 자동 닫기 - - return () => clearTimeout(timer); - } - }, [showToast]); const handleBackClick = () => { router.push(`/admin/lessons/${params.id}`); @@ -763,7 +756,7 @@ export default function LessonEditPage() { // 변경사항이 없으면 알림 if (Object.keys(requestBody).length === 0) { - alert('변경된 내용이 없습니다.'); + setIsNoChangesModalOpen(true); setIsSaving(false); return; } @@ -771,13 +764,8 @@ export default function LessonEditPage() { // 강좌 수정 API 호출 (PATCH /lectures/{id}) await apiService.updateLecture(params.id as string, requestBody); - // 성공 시 토스트 표시 - setShowToast(true); - - // 토스트 표시 후 상세 페이지로 이동 (새로고침하여 최신 데이터 표시) - setTimeout(() => { - router.push(`/admin/lessons/${params.id}?refresh=${Date.now()}`); - }, 1500); + // 성공 시 강좌 리스트로 이동 (토스트는 리스트 페이지에서 표시) + router.push('/admin/lessons?updated=true'); } catch (error) { console.error('강좌 수정 실패:', error); const errorMessage = error instanceof Error ? error.message : '강좌 수정 중 오류가 발생했습니다.'; @@ -1369,7 +1357,7 @@ export default function LessonEditPage() { )} {/* CSV 표 */} -
+
{/* 헤더 */}
@@ -1455,23 +1443,6 @@ export default function LessonEditPage() {
- {/* 강좌 수정 완료 토스트 */} - {showToast && ( -
-
-
- - - - -
-

- 강좌가 수정되었습니다. -

-
-
- )} - {/* 파일 교체 확인 모달 */} {isFileReplaceModalOpen && (
@@ -1517,6 +1488,12 @@ export default function LessonEditPage() {
)} + + {/* 변경사항 없음 모달 */} + setIsNoChangesModalOpen(false)} + />
); } diff --git a/src/app/admin/lessons/[id]/page.tsx b/src/app/admin/lessons/[id]/page.tsx index e7ff710..211544e 100644 --- a/src/app/admin/lessons/[id]/page.tsx +++ b/src/app/admin/lessons/[id]/page.tsx @@ -991,7 +991,7 @@ export default function AdminCourseDetailPage() {
{course.quizData.length > 0 ? (
-
+
{/* 테이블 헤더 */}
diff --git a/src/app/admin/lessons/page.tsx b/src/app/admin/lessons/page.tsx index e61be90..b844e62 100644 --- a/src/app/admin/lessons/page.tsx +++ b/src/app/admin/lessons/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo, useRef, useEffect, useCallback } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import AdminSidebar from "@/app/components/AdminSidebar"; import ChevronDownSvg from "@/app/svgs/chevrondownsvg"; import DropdownIcon from "@/app/svgs/dropdownicon"; @@ -22,6 +22,7 @@ type Lesson = { export default function AdminLessonsPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [lessons, setLessons] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [isRegistrationMode, setIsRegistrationMode] = useState(false); @@ -30,6 +31,7 @@ export default function AdminLessonsPage() { const [courses, setCourses] = useState([]); const [currentUser, setCurrentUser] = useState("관리자"); const [showToast, setShowToast] = useState(false); + const [toastMessage, setToastMessage] = useState('강좌가 등록되었습니다.'); const rawLecturesRef = useRef([]); // 원본 강좌 데이터 저장 // 등록 폼 상태 @@ -882,6 +884,7 @@ export default function AdminLessonsPage() { setCsvRows([]); // 토스트 팝업 표시 + setToastMessage('강좌가 등록되었습니다.'); setShowToast(true); } catch (error) { console.error('강좌 등록 실패:', error); @@ -890,6 +893,18 @@ export default function AdminLessonsPage() { } }; + // 쿼리 파라미터에서 updated 확인하여 토스트 표시 + useEffect(() => { + const updated = searchParams.get('updated'); + if (updated === 'true') { + setToastMessage('강좌가 수정되었습니다.'); + setShowToast(true); + // URL에서 쿼리 파라미터 제거 + const newUrl = window.location.pathname; + router.replace(newUrl); + } + }, [searchParams, router]); + // 토스트 자동 닫기 useEffect(() => { if (showToast) { @@ -1779,7 +1794,7 @@ export default function AdminLessonsPage() {

- 강좌가 등록되었습니다. + {toastMessage}

diff --git a/src/app/admin/notices/NoChangesModal.tsx b/src/app/admin/notices/NoChangesModal.tsx new file mode 100644 index 0000000..f0bca1a --- /dev/null +++ b/src/app/admin/notices/NoChangesModal.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React from 'react'; + +type NoChangesModalProps = { + open: boolean; + onClose: () => void; +}; + +/** + * 변경사항이 없을 때 표시되는 모달 + */ +export default function NoChangesModal({ + open, + onClose, +}: NoChangesModalProps) { + if (!open) return null; + + return ( +
+ + ); +} + diff --git a/src/app/admin/notices/[id]/edit/page.tsx b/src/app/admin/notices/[id]/edit/page.tsx index 769c825..ea5b489 100644 --- a/src/app/admin/notices/[id]/edit/page.tsx +++ b/src/app/admin/notices/[id]/edit/page.tsx @@ -8,6 +8,7 @@ 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"; +import NoChangesModal from "@/app/admin/notices/NoChangesModal"; type Attachment = { name: string; @@ -24,10 +25,14 @@ export default function AdminNoticeEditPage() { const [attachedFile, setAttachedFile] = useState(null); const [fileKey, setFileKey] = useState(null); const [existingAttachment, setExistingAttachment] = useState(null); + const [originalTitle, setOriginalTitle] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + const [originalFileKey, setOriginalFileKey] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isLoadingData, setIsLoadingData] = useState(true); const [isValidationModalOpen, setIsValidationModalOpen] = useState(false); const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); + const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false); const fileInputRef = useRef(null); const characterCount = useMemo(() => content.length, [content]); @@ -46,18 +51,23 @@ export default function AdminNoticeEditPage() { const data = response.data; // 제목 설정 - setTitle(data.title || ''); + const loadedTitle = data.title || ''; + setTitle(loadedTitle); + setOriginalTitle(loadedTitle); // 내용 설정 (배열이면 join, 문자열이면 그대로) + let loadedContent = ''; if (data.content) { if (Array.isArray(data.content)) { - setContent(data.content.join('\n')); + loadedContent = data.content.join('\n'); } else if (typeof data.content === 'string') { - setContent(data.content); + loadedContent = data.content; } else { - setContent(String(data.content)); + loadedContent = String(data.content); } } + setContent(loadedContent); + setOriginalContent(loadedContent); // 기존 첨부파일 정보 설정 if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) { @@ -69,8 +79,10 @@ export default function AdminNoticeEditPage() { fileKey: att.fileKey || att.key || att.fileId, }); // 기존 파일이 있으면 fileKey도 설정 - if (att.fileKey || att.key || att.fileId) { - setFileKey(att.fileKey || att.key || att.fileId); + const loadedFileKey = att.fileKey || att.key || att.fileId; + if (loadedFileKey) { + setFileKey(loadedFileKey); + setOriginalFileKey(loadedFileKey); } } else if (data.attachment) { // 단일 첨부파일인 경우 @@ -80,8 +92,10 @@ export default function AdminNoticeEditPage() { 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); + const loadedFileKey = data.attachment.fileKey || data.attachment.key || data.attachment.fileId; + if (loadedFileKey) { + setFileKey(loadedFileKey); + setOriginalFileKey(loadedFileKey); } } } catch (error) { @@ -184,19 +198,36 @@ export default function AdminNoticeEditPage() { try { setIsLoading(true); - // 공지사항 수정 API 호출 - const noticeData: any = { - title: title.trim(), - content: content.trim(), - }; + // 변경된 필드만 포함하는 request body 생성 + const noticeData: any = {}; - // fileKey와 파일 정보가 있으면 attachments 배열로 포함 - if (fileKey) { + // 제목이 변경되었는지 확인 + const trimmedTitle = title.trim(); + if (trimmedTitle !== originalTitle) { + noticeData.title = trimmedTitle; + } + + // 내용이 변경되었는지 확인 + const trimmedContent = content.trim(); + if (trimmedContent !== originalContent) { + noticeData.content = trimmedContent; + } + + // 파일 변경사항 확인 + const currentFileKey = fileKey; + const hasFileChanged = currentFileKey !== originalFileKey; + + // 파일이 삭제된 경우 (기존에 파일이 있었는데 지금 없음) + if (originalFileKey && !currentFileKey) { + noticeData.attachments = []; + } + // 파일이 변경되었거나 새로 추가된 경우 + else if (hasFileChanged && currentFileKey) { if (attachedFile) { // 새로 업로드한 파일 noticeData.attachments = [ { - fileKey: fileKey, + fileKey: currentFileKey, filename: attachedFile.name, mimeType: attachedFile.type || 'application/octet-stream', size: attachedFile.size, @@ -213,10 +244,17 @@ export default function AdminNoticeEditPage() { } } + // 변경사항이 없으면 알림 후 리턴 + if (Object.keys(noticeData).length === 0) { + setIsNoChangesModalOpen(true); + setIsLoading(false); + return; + } + await apiService.updateNotice(params.id, noticeData); - alert('공지사항이 수정되었습니다.'); - router.push(`/admin/notices/${params.id}`); + // 성공 시 공지사항 리스트로 이동 (토스트는 리스트 페이지에서 표시) + router.push('/admin/notices?updated=true'); } catch (error) { console.error('공지사항 수정 실패:', error); alert('공지사항 수정에 실패했습니다. 다시 시도해주세요.'); @@ -270,6 +308,10 @@ export default function AdminNoticeEditPage() { onClose={() => setIsCancelModalOpen(false)} onConfirm={handleCancelConfirm} /> + setIsNoChangesModalOpen(false)} + />
{/* 메인 레이아웃 */}
diff --git a/src/app/admin/notices/page.tsx b/src/app/admin/notices/page.tsx index abc9fee..9aec750 100644 --- a/src/app/admin/notices/page.tsx +++ b/src/app/admin/notices/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import AdminSidebar from "@/app/components/AdminSidebar"; import ChevronDownSvg from "@/app/svgs/chevrondownsvg"; import BackArrowSvg from "@/app/svgs/backarrow"; @@ -12,6 +12,7 @@ import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal"; export default function AdminNoticesPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [notices, setNotices] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [isWritingMode, setIsWritingMode] = useState(false); @@ -22,6 +23,7 @@ export default function AdminNoticesPage() { const [isLoading, setIsLoading] = useState(false); const [isValidationModalOpen, setIsValidationModalOpen] = useState(false); const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); + const [showToast, setShowToast] = useState(false); const fileInputRef = useRef(null); // 날짜를 yyyy-mm-dd 형식으로 포맷팅 @@ -86,6 +88,20 @@ export default function AdminNoticesPage() { fetchNotices(); }, []); + + // 수정 완료 쿼리 파라미터 확인 및 토스트 표시 + useEffect(() => { + if (searchParams.get('updated') === 'true') { + setShowToast(true); + // URL에서 쿼리 파라미터 제거 + router.replace('/admin/notices'); + // 토스트 자동 닫기 + const timer = setTimeout(() => { + setShowToast(false); + }, 3000); + return () => clearTimeout(timer); + } + }, [searchParams, router]); const totalCount = useMemo(() => notices.length, [notices]); @@ -581,6 +597,23 @@ export default function AdminNoticesPage() {
+ + {/* 수정 완료 토스트 */} + {showToast && ( +
+
+
+ + + + +
+

+ 수정이 완료되었습니다. +

+
+
+ )} ); } diff --git a/src/app/globals.css b/src/app/globals.css index 293ffbe..c978079 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -84,3 +84,26 @@ button:hover { .dropdown-scroll:hover::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.2); } + +/* CSV 표 스크롤바 스타일 - 배경만 투명, thumb는 보이게 */ +.csv-table-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} + +.csv-table-scroll::-webkit-scrollbar { + width: 6px; +} + +.csv-table-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.csv-table-scroll::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.csv-table-scroll::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.3); +} diff --git a/src/app/lib/apiService.ts b/src/app/lib/apiService.ts index 4c28fa1..748aa27 100644 --- a/src/app/lib/apiService.ts +++ b/src/app/lib/apiService.ts @@ -44,6 +44,27 @@ class ApiService { return cookieToken || null; } + /** + * 토큰 삭제 및 로그인 페이지로 리다이렉트 + */ + private handleTokenError() { + if (typeof window === 'undefined') return; + + // 토큰 삭제 + localStorage.removeItem('token'); + document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + + // 현재 경로 가져오기 + const currentPath = window.location.pathname; + + // 로그인 페이지가 아닐 때만 리다이렉트 + if (currentPath !== '/login') { + const loginUrl = new URL('/login', window.location.origin); + loginUrl.searchParams.set('redirect', currentPath); + window.location.href = loginUrl.toString(); + } + } + /** * 기본 헤더 생성 */ @@ -137,6 +158,12 @@ class ApiService { const response = await this.fetchWithTimeout(url, requestOptions, timeout); if (!response.ok) { + // 토큰 오류 (401, 403) 발생 시 처리 + if (response.status === 401 || response.status === 403) { + this.handleTokenError(); + throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.'); + } + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try {