공지사항 admin ok
This commit is contained in:
@@ -8,6 +8,7 @@ import BackArrowSvg from "@/app/svgs/backarrow";
|
|||||||
import { getCourses, type Course } from "@/app/admin/courses/mockData";
|
import { getCourses, type Course } from "@/app/admin/courses/mockData";
|
||||||
import CloseXOSvg from "@/app/svgs/closexo";
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
import apiService from "@/app/lib/apiService";
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import NoChangesModal from "@/app/admin/notices/NoChangesModal";
|
||||||
|
|
||||||
export default function LessonEditPage() {
|
export default function LessonEditPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -20,7 +21,6 @@ export default function LessonEditPage() {
|
|||||||
const vrFileInputRef = useRef<HTMLInputElement>(null);
|
const vrFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const csvFileInputRef = useRef<HTMLInputElement>(null);
|
const csvFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [showToast, setShowToast] = useState(false);
|
|
||||||
|
|
||||||
// 폼 상태
|
// 폼 상태
|
||||||
const [selectedCourse, setSelectedCourse] = useState<string>("");
|
const [selectedCourse, setSelectedCourse] = useState<string>("");
|
||||||
@@ -49,6 +49,9 @@ export default function LessonEditPage() {
|
|||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
const [pendingFileType, setPendingFileType] = useState<'video' | 'vr' | 'csv' | null>(null);
|
const [pendingFileType, setPendingFileType] = useState<'video' | 'vr' | 'csv' | null>(null);
|
||||||
|
|
||||||
|
// 변경사항 없음 모달 상태
|
||||||
|
const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false);
|
||||||
|
|
||||||
// 원본 데이터 저장 (변경사항 비교용)
|
// 원본 데이터 저장 (변경사항 비교용)
|
||||||
const [originalData, setOriginalData] = useState<{
|
const [originalData, setOriginalData] = useState<{
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -359,16 +362,6 @@ export default function LessonEditPage() {
|
|||||||
};
|
};
|
||||||
}, [isDropdownOpen]);
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
// 토스트 자동 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
if (showToast) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setShowToast(false);
|
|
||||||
}, 3000); // 3초 후 자동 닫기
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [showToast]);
|
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
router.push(`/admin/lessons/${params.id}`);
|
router.push(`/admin/lessons/${params.id}`);
|
||||||
@@ -763,7 +756,7 @@ export default function LessonEditPage() {
|
|||||||
|
|
||||||
// 변경사항이 없으면 알림
|
// 변경사항이 없으면 알림
|
||||||
if (Object.keys(requestBody).length === 0) {
|
if (Object.keys(requestBody).length === 0) {
|
||||||
alert('변경된 내용이 없습니다.');
|
setIsNoChangesModalOpen(true);
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -771,13 +764,8 @@ export default function LessonEditPage() {
|
|||||||
// 강좌 수정 API 호출 (PATCH /lectures/{id})
|
// 강좌 수정 API 호출 (PATCH /lectures/{id})
|
||||||
await apiService.updateLecture(params.id as string, requestBody);
|
await apiService.updateLecture(params.id as string, requestBody);
|
||||||
|
|
||||||
// 성공 시 토스트 표시
|
// 성공 시 강좌 리스트로 이동 (토스트는 리스트 페이지에서 표시)
|
||||||
setShowToast(true);
|
router.push('/admin/lessons?updated=true');
|
||||||
|
|
||||||
// 토스트 표시 후 상세 페이지로 이동 (새로고침하여 최신 데이터 표시)
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push(`/admin/lessons/${params.id}?refresh=${Date.now()}`);
|
|
||||||
}, 1500);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('강좌 수정 실패:', error);
|
console.error('강좌 수정 실패:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : '강좌 수정 중 오류가 발생했습니다.';
|
const errorMessage = error instanceof Error ? error.message : '강좌 수정 중 오류가 발생했습니다.';
|
||||||
@@ -1369,7 +1357,7 @@ export default function LessonEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* CSV 표 */}
|
{/* CSV 표 */}
|
||||||
<div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto">
|
<div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto csv-table-scroll">
|
||||||
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip relative shrink-0 w-full sticky top-0 z-10">
|
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip relative shrink-0 w-full sticky top-0 z-10">
|
||||||
@@ -1455,23 +1443,6 @@ export default function LessonEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 강좌 수정 완료 토스트 */}
|
|
||||||
{showToast && (
|
|
||||||
<div className="fixed right-[60px] bottom-[60px] z-50">
|
|
||||||
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
|
||||||
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
|
||||||
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
|
||||||
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
|
||||||
강좌가 수정되었습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 파일 교체 확인 모달 */}
|
{/* 파일 교체 확인 모달 */}
|
||||||
{isFileReplaceModalOpen && (
|
{isFileReplaceModalOpen && (
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
@@ -1517,6 +1488,12 @@ export default function LessonEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 변경사항 없음 모달 */}
|
||||||
|
<NoChangesModal
|
||||||
|
open={isNoChangesModalOpen}
|
||||||
|
onClose={() => setIsNoChangesModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -991,7 +991,7 @@ export default function AdminCourseDetailPage() {
|
|||||||
<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="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto">
|
<div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto csv-table-scroll">
|
||||||
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
||||||
{/* 테이블 헤더 */}
|
{/* 테이블 헤더 */}
|
||||||
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip shrink-0 w-full sticky top-0 z-10">
|
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip shrink-0 w-full sticky top-0 z-10">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
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 AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
import DropdownIcon from "@/app/svgs/dropdownicon";
|
import DropdownIcon from "@/app/svgs/dropdownicon";
|
||||||
@@ -22,6 +22,7 @@ type Lesson = {
|
|||||||
|
|
||||||
export default function AdminLessonsPage() {
|
export default function AdminLessonsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isRegistrationMode, setIsRegistrationMode] = useState(false);
|
const [isRegistrationMode, setIsRegistrationMode] = useState(false);
|
||||||
@@ -30,6 +31,7 @@ export default function AdminLessonsPage() {
|
|||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [currentUser, setCurrentUser] = useState<string>("관리자");
|
const [currentUser, setCurrentUser] = useState<string>("관리자");
|
||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const [toastMessage, setToastMessage] = useState<string>('강좌가 등록되었습니다.');
|
||||||
const rawLecturesRef = useRef<any[]>([]); // 원본 강좌 데이터 저장
|
const rawLecturesRef = useRef<any[]>([]); // 원본 강좌 데이터 저장
|
||||||
|
|
||||||
// 등록 폼 상태
|
// 등록 폼 상태
|
||||||
@@ -882,6 +884,7 @@ export default function AdminLessonsPage() {
|
|||||||
setCsvRows([]);
|
setCsvRows([]);
|
||||||
|
|
||||||
// 토스트 팝업 표시
|
// 토스트 팝업 표시
|
||||||
|
setToastMessage('강좌가 등록되었습니다.');
|
||||||
setShowToast(true);
|
setShowToast(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('강좌 등록 실패:', 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(() => {
|
useEffect(() => {
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
@@ -1779,7 +1794,7 @@ export default function AdminLessonsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
강좌가 등록되었습니다.
|
{toastMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
49
src/app/admin/notices/NoChangesModal.tsx
Normal file
49
src/app/admin/notices/NoChangesModal.tsx
Normal file
@@ -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 (
|
||||||
|
<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 min-w-[320px]">
|
||||||
|
<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>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import CloseXOSvg from "@/app/svgs/closexo";
|
|||||||
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";
|
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
||||||
|
import NoChangesModal from "@/app/admin/notices/NoChangesModal";
|
||||||
|
|
||||||
type Attachment = {
|
type Attachment = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,10 +25,14 @@ export default function AdminNoticeEditPage() {
|
|||||||
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
const [fileKey, setFileKey] = useState<string | null>(null);
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
|
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
|
||||||
|
const [originalTitle, setOriginalTitle] = useState<string>('');
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>('');
|
||||||
|
const [originalFileKey, setOriginalFileKey] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
|
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
|
||||||
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
||||||
|
const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const characterCount = useMemo(() => content.length, [content]);
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
@@ -46,18 +51,23 @@ export default function AdminNoticeEditPage() {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// 제목 설정
|
// 제목 설정
|
||||||
setTitle(data.title || '');
|
const loadedTitle = data.title || '';
|
||||||
|
setTitle(loadedTitle);
|
||||||
|
setOriginalTitle(loadedTitle);
|
||||||
|
|
||||||
// 내용 설정 (배열이면 join, 문자열이면 그대로)
|
// 내용 설정 (배열이면 join, 문자열이면 그대로)
|
||||||
|
let loadedContent = '';
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
if (Array.isArray(data.content)) {
|
if (Array.isArray(data.content)) {
|
||||||
setContent(data.content.join('\n'));
|
loadedContent = data.content.join('\n');
|
||||||
} else if (typeof data.content === 'string') {
|
} else if (typeof data.content === 'string') {
|
||||||
setContent(data.content);
|
loadedContent = data.content;
|
||||||
} else {
|
} else {
|
||||||
setContent(String(data.content));
|
loadedContent = String(data.content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setContent(loadedContent);
|
||||||
|
setOriginalContent(loadedContent);
|
||||||
|
|
||||||
// 기존 첨부파일 정보 설정
|
// 기존 첨부파일 정보 설정
|
||||||
if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) {
|
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: att.fileKey || att.key || att.fileId,
|
||||||
});
|
});
|
||||||
// 기존 파일이 있으면 fileKey도 설정
|
// 기존 파일이 있으면 fileKey도 설정
|
||||||
if (att.fileKey || att.key || att.fileId) {
|
const loadedFileKey = att.fileKey || att.key || att.fileId;
|
||||||
setFileKey(att.fileKey || att.key || att.fileId);
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
}
|
}
|
||||||
} else if (data.attachment) {
|
} else if (data.attachment) {
|
||||||
// 단일 첨부파일인 경우
|
// 단일 첨부파일인 경우
|
||||||
@@ -80,8 +92,10 @@ export default function AdminNoticeEditPage() {
|
|||||||
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,
|
||||||
});
|
});
|
||||||
if (data.attachment.fileKey || data.attachment.key || data.attachment.fileId) {
|
const loadedFileKey = data.attachment.fileKey || data.attachment.key || data.attachment.fileId;
|
||||||
setFileKey(data.attachment.fileKey || data.attachment.key || data.attachment.fileId);
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -184,19 +198,36 @@ export default function AdminNoticeEditPage() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// 공지사항 수정 API 호출
|
// 변경된 필드만 포함하는 request body 생성
|
||||||
const noticeData: any = {
|
const noticeData: any = {};
|
||||||
title: title.trim(),
|
|
||||||
content: content.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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) {
|
if (attachedFile) {
|
||||||
// 새로 업로드한 파일
|
// 새로 업로드한 파일
|
||||||
noticeData.attachments = [
|
noticeData.attachments = [
|
||||||
{
|
{
|
||||||
fileKey: fileKey,
|
fileKey: currentFileKey,
|
||||||
filename: attachedFile.name,
|
filename: attachedFile.name,
|
||||||
mimeType: attachedFile.type || 'application/octet-stream',
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
size: attachedFile.size,
|
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);
|
await apiService.updateNotice(params.id, noticeData);
|
||||||
|
|
||||||
alert('공지사항이 수정되었습니다.');
|
// 성공 시 공지사항 리스트로 이동 (토스트는 리스트 페이지에서 표시)
|
||||||
router.push(`/admin/notices/${params.id}`);
|
router.push('/admin/notices?updated=true');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('공지사항 수정 실패:', error);
|
console.error('공지사항 수정 실패:', error);
|
||||||
alert('공지사항 수정에 실패했습니다. 다시 시도해주세요.');
|
alert('공지사항 수정에 실패했습니다. 다시 시도해주세요.');
|
||||||
@@ -270,6 +308,10 @@ export default function AdminNoticeEditPage() {
|
|||||||
onClose={() => setIsCancelModalOpen(false)}
|
onClose={() => setIsCancelModalOpen(false)}
|
||||||
onConfirm={handleCancelConfirm}
|
onConfirm={handleCancelConfirm}
|
||||||
/>
|
/>
|
||||||
|
<NoChangesModal
|
||||||
|
open={isNoChangesModalOpen}
|
||||||
|
onClose={() => setIsNoChangesModalOpen(false)}
|
||||||
|
/>
|
||||||
<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">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
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 AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
import BackArrowSvg from "@/app/svgs/backarrow";
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
@@ -12,6 +12,7 @@ import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
|||||||
|
|
||||||
export default function AdminNoticesPage() {
|
export default function AdminNoticesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [notices, setNotices] = useState<Notice[]>([]);
|
const [notices, setNotices] = useState<Notice[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isWritingMode, setIsWritingMode] = useState(false);
|
const [isWritingMode, setIsWritingMode] = useState(false);
|
||||||
@@ -22,6 +23,7 @@ export default function AdminNoticesPage() {
|
|||||||
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 [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
@@ -86,6 +88,20 @@ export default function AdminNoticesPage() {
|
|||||||
|
|
||||||
fetchNotices();
|
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]);
|
const totalCount = useMemo(() => notices.length, [notices]);
|
||||||
|
|
||||||
@@ -581,6 +597,23 @@ export default function AdminNoticesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 수정 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
수정이 완료되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,3 +84,26 @@ button:hover {
|
|||||||
.dropdown-scroll:hover::-webkit-scrollbar-thumb {
|
.dropdown-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,27 @@ class ApiService {
|
|||||||
return cookieToken || null;
|
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);
|
const response = await this.fetchWithTimeout(url, requestOptions, timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// 토큰 오류 (401, 403) 발생 시 처리
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
this.handleTokenError();
|
||||||
|
throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user