공지사항 admin ok

This commit is contained in:
2025-11-29 23:57:31 +09:00
parent eb7871133d
commit 109ca05e23
8 changed files with 225 additions and 59 deletions

View File

@@ -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>
); );
} }

View File

@@ -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">

View File

@@ -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>

View 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>
);
}

View File

@@ -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">

View File

@@ -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 형식으로 포맷팅
@@ -87,6 +89,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]);
const characterCount = useMemo(() => content.length, [content]); const characterCount = useMemo(() => content.length, [content]);
@@ -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>
)}
</> </>
); );
} }

View File

@@ -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);
}

View File

@@ -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 {