Compare commits
11 Commits
5a26d96386
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef3990f1d | ||
|
|
8ec9e4e402 | ||
| 8bdd615ec9 | |||
| 54d763e008 | |||
| 109ca05e23 | |||
| eb7871133d | |||
| 872a88866e | |||
| 91d14fdabf | |||
| 39d21a475b | |||
| 32e9fed5cd | |||
| 5e4337c7e8 |
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import MainLogoSvg from "./svgs/mainlogosvg";
|
import MainLogoSvg from "./svgs/mainlogosvg";
|
||||||
import ChevronDownSvg from "./svgs/chevrondownsvg";
|
import ChevronDownSvg from "./svgs/chevrondownsvg";
|
||||||
|
import apiService from "./lib/apiService";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ label: "교육 과정 목록", href: "/course-list" },
|
{ label: "교육 과정 목록", href: "/course-list" },
|
||||||
@@ -12,15 +13,23 @@ const NAV_ITEMS = [
|
|||||||
{ label: "공지사항", href: "/notices" },
|
{ label: "공지사항", href: "/notices" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const INSTRUCTOR_NAV_ITEMS = [
|
||||||
|
{ label: "강좌 현황", href: "/instructor/courses" },
|
||||||
|
{ label: "학습 자료실", href: "/admin/resources" },
|
||||||
|
{ label: "공지사항", href: "/admin/notices" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
const [userName, setUserName] = useState<string>('');
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [userRole, setUserRole] = useState<string>('');
|
||||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
|
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
|
||||||
const isAdminPage = pathname.startsWith('/admin');
|
const isAdminPage = pathname.startsWith('/admin');
|
||||||
|
const isInstructorPage = pathname.startsWith('/instructor');
|
||||||
|
|
||||||
// 사용자 정보 가져오기 및 비활성화 계정 체크
|
// 사용자 정보 가져오기 및 비활성화 계정 체크
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,19 +55,8 @@ export default function NavBar() {
|
|||||||
localStorage.setItem('token', cookieToken);
|
localStorage.setItem('token', cookieToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
const response = await apiService.getCurrentUser();
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/me`
|
|
||||||
: 'https://hrdi.coconutmeet.net/auth/me';
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// 토큰이 만료되었거나 유효하지 않은 경우
|
// 토큰이 만료되었거나 유효하지 않은 경우
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
@@ -67,11 +65,10 @@ export default function NavBar() {
|
|||||||
if (isMounted && pathname !== '/login') {
|
if (isMounted && pathname !== '/login') {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = response.data;
|
||||||
|
|
||||||
// 계정 상태 확인
|
// 계정 상태 확인
|
||||||
const userStatus = data.status || data.userStatus;
|
const userStatus = data.status || data.userStatus;
|
||||||
@@ -86,9 +83,13 @@ export default function NavBar() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMounted && data.name) {
|
if (isMounted) {
|
||||||
|
const role = data.role || data.userRole || '';
|
||||||
|
setUserRole(role);
|
||||||
|
if (data.name) {
|
||||||
setUserName(data.name);
|
setUserName(data.name);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('사용자 정보 조회 오류:', error);
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
}
|
}
|
||||||
@@ -129,11 +130,36 @@ export default function NavBar() {
|
|||||||
<header className="bg-[#060958] h-20">
|
<header className="bg-[#060958] h-20">
|
||||||
<div className="mx-auto flex h-full w-full max-w-[1440px] items-center justify-between px-8">
|
<div className="mx-auto flex h-full w-full max-w-[1440px] items-center justify-between px-8">
|
||||||
<div className="flex flex-1 items-center gap-9">
|
<div className="flex flex-1 items-center gap-9">
|
||||||
<Link href="/" aria-label="XR LMS 홈" className="flex items-center gap-2">
|
<Link
|
||||||
|
href={(userRole === 'ADMIN' || userRole === 'admin') ? "/instructor" : "/"}
|
||||||
|
aria-label="XR LMS 홈"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<MainLogoSvg width={46.703} height={36} />
|
<MainLogoSvg width={46.703} height={36} />
|
||||||
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
|
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
|
||||||
</Link>
|
</Link>
|
||||||
{!hideCenterNav && !isAdminPage && (
|
{!hideCenterNav && !isAdminPage && isInstructorPage && (
|
||||||
|
<nav className="flex h-full items-center">
|
||||||
|
{INSTRUCTOR_NAV_ITEMS.map((item) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={["px-4 py-2 text-[16px] font-semibold text-white"].join(" ")}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className={["px-4 py-2 text-[16px] font-semibold text-white"].join(" ")}
|
||||||
|
>
|
||||||
|
관리자페이지
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
{!hideCenterNav && !isAdminPage && !isInstructorPage && (
|
||||||
<nav className="flex h-full items-center">
|
<nav className="flex h-full items-center">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
return (
|
return (
|
||||||
@@ -150,13 +176,33 @@ export default function NavBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
{isAdminPage ? (
|
{(isAdminPage || isInstructorPage) ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/menu/account" className="px-4 py-2 text-[16px] font-semibold text-white">
|
|
||||||
내 정보
|
|
||||||
</Link>
|
|
||||||
<button
|
<button
|
||||||
|
ref={userButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => setIsUserMenuOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isUserMenuOpen}
|
||||||
|
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
{userName || '사용자'}
|
||||||
|
<ChevronDownSvg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isUserMenuOpen && (
|
||||||
|
<div
|
||||||
|
ref={userMenuRef}
|
||||||
|
role="menu"
|
||||||
|
aria-label="사용자 메뉴"
|
||||||
|
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
className="flex items-center w-[136px] h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 로컬 스토리지에서 토큰 제거
|
// 로컬 스토리지에서 토큰 제거
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
@@ -166,10 +212,11 @@ export default function NavBar() {
|
|||||||
// 로그인 페이지로 리다이렉트
|
// 로그인 페이지로 리다이렉트
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
|
|
||||||
>
|
>
|
||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
606
src/app/admin/banner/BannerRegistrationModal.tsx
Normal file
606
src/app/admin/banner/BannerRegistrationModal.tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import ModalCloseSvg from "@/app/svgs/closexsvg";
|
||||||
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
|
export type Banner = {
|
||||||
|
id: number;
|
||||||
|
order: number;
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
registeredDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave?: (title: string, description: string, imageKey?: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
editingBanner?: Banner | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BannerRegistrationModal({ open, onClose, onSave, onDelete, editingBanner }: Props) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isImageDeleted, setIsImageDeleted] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// previewUrl 변경 시 이전 Blob URL 정리
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
|
// 수정 모드일 때 기존 데이터 채우기
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && editingBanner) {
|
||||||
|
setTitle(editingBanner.title);
|
||||||
|
setDescription(editingBanner.description);
|
||||||
|
|
||||||
|
// 수정 모드일 때 이미지 로드
|
||||||
|
if (editingBanner.imageUrl) {
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPreviewUrl(editingBanner.imageUrl);
|
||||||
|
} else {
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
} else if (!open) {
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
setErrors({});
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
setSelectedImage(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setIsDragging(false);
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
}
|
||||||
|
}, [open, editingBanner]);
|
||||||
|
|
||||||
|
// 모달 클릭 시 이벤트 전파 방지
|
||||||
|
const handleModalClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 버튼 클릭 핸들러
|
||||||
|
const handleSave = async () => {
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
nextErrors.title = "배너 제목을 입력해 주세요.";
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
nextErrors.description = "배너 설명을 입력해 주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(nextErrors);
|
||||||
|
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSaving) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.submit;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let imageKey: string | null = null;
|
||||||
|
|
||||||
|
// 새 이미지가 선택된 경우 업로드
|
||||||
|
if (selectedImage) {
|
||||||
|
try {
|
||||||
|
const uploadResponse = await apiService.uploadFile(selectedImage);
|
||||||
|
|
||||||
|
if (uploadResponse.data) {
|
||||||
|
imageKey = uploadResponse.data.imageKey
|
||||||
|
|| uploadResponse.data.key
|
||||||
|
|| uploadResponse.data.id
|
||||||
|
|| uploadResponse.data.fileKey
|
||||||
|
|| uploadResponse.data.fileId
|
||||||
|
|| (uploadResponse.data.data && (uploadResponse.data.data.imageKey || uploadResponse.data.data.key))
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
} catch (uploadError) {
|
||||||
|
const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.';
|
||||||
|
console.error('이미지 업로드 오류:', errorMessage);
|
||||||
|
setErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
image: '이미지 업로드 중 오류가 발생했습니다. 이미지 없이 계속 진행됩니다.',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출
|
||||||
|
/*
|
||||||
|
if (editingBanner && editingBanner.id) {
|
||||||
|
// 수정 모드
|
||||||
|
await apiService.updateBanner(editingBanner.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
imageKey: isImageDeleted ? "null" : (imageKey || editingBanner.imageUrl || "null"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 등록 모드
|
||||||
|
await apiService.createBanner({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
imageKey: imageKey || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
||||||
|
if (onSave) {
|
||||||
|
onSave(title.trim(), description.trim(), imageKey || undefined);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('배너 저장 오류:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 버튼 클릭 핸들러
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인 핸들러
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!editingBanner || !editingBanner.id) {
|
||||||
|
console.error('삭제할 배너 정보가 없습니다.');
|
||||||
|
setErrors({ submit: '삭제할 배너 정보가 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeleting) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.submit;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출
|
||||||
|
// await apiService.deleteBanner(editingBanner.id);
|
||||||
|
|
||||||
|
// 성공 시 모달 닫기 및 콜백 호출
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '배너 삭제 중 오류가 발생했습니다.';
|
||||||
|
console.error('배너 삭제 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 취소 핸들러
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 유효성 검사
|
||||||
|
const validateImageFile = (file: File): string | null => {
|
||||||
|
const maxSize = 30 * 1024 * 1024; // 30MB
|
||||||
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg'];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return "PNG 또는 JPG 파일만 업로드 가능합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return "파일 크기는 30MB 미만이어야 합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 파일 처리
|
||||||
|
const handleImageFile = (file: File) => {
|
||||||
|
const error = validateImageFile(file);
|
||||||
|
if (error) {
|
||||||
|
setErrors((prev) => ({ ...prev, image: error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImage(file);
|
||||||
|
setIsImageDeleted(false);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.image;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기 URL 생성
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setPreviewUrl(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 선택 핸들러
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleImageFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 클릭으로 파일 선택
|
||||||
|
const handleImageAreaClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 오버 핸들러
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 리브 핸들러
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드롭 핸들러
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleImageFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미지 삭제 핸들러
|
||||||
|
const handleRemoveImage = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedImage(null);
|
||||||
|
// previewUrl이 Blob URL인 경우 메모리 해제
|
||||||
|
if (previewUrl && previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setIsImageDeleted(true);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.image;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open && !isDeleteConfirmOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 메인 모달 */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
aria-hidden={!open}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="relative z-10 shadow-xl"
|
||||||
|
onClick={handleModalClick}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[var(--color-neutral-40)] rounded-[12px] w-full min-w-[480px] max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-[10px] p-6">
|
||||||
|
<h2 className="text-[20px] font-bold leading-normal text-[var(--color-neutral-700)]">
|
||||||
|
{editingBanner ? "배너 수정" : "배너 등록"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-6 h-6 flex items-center justify-center cursor-pointer hover:opacity-80 shrink-0"
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
<ModalCloseSvg />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Container */}
|
||||||
|
<div className="px-6 py-0">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* 배너 제목 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||||
|
배너 제목<span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
if (errors.title) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.title;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="배너 제목을 입력해 주세요."
|
||||||
|
className={`h-[40px] px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none ${
|
||||||
|
errors.title
|
||||||
|
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.title && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배너 설명 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
|
||||||
|
배너 설명<span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
if (errors.description) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.description;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="배너 설명을 입력해 주세요."
|
||||||
|
rows={4}
|
||||||
|
className={`px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none resize-none ${
|
||||||
|
errors.description
|
||||||
|
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배너 이미지 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] whitespace-pre">
|
||||||
|
배너 이미지
|
||||||
|
</label>
|
||||||
|
<span className="text-[13px] font-normal leading-[1.4] text-[var(--color-text-meta)]">
|
||||||
|
30MB 미만의 PNG, JPG
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
aria-label="이미지 파일 선택"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={handleImageAreaClick}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`border border-dashed min-h-[192px] rounded-[8px] flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors px-0 relative overflow-hidden ${
|
||||||
|
isDragging
|
||||||
|
? "bg-blue-50 border-blue-300"
|
||||||
|
: previewUrl
|
||||||
|
? "bg-white border-[var(--color-neutral-40)]"
|
||||||
|
: "bg-gray-50 border-[var(--color-neutral-40)] hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="미리보기"
|
||||||
|
className="h-[160px] w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
className="w-[18px] h-[18px] flex items-center justify-center ml-1 z-10 hover:opacity-80 transition-opacity shrink-0"
|
||||||
|
aria-label="이미지 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg width={18} height={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 transition-colors flex items-center justify-center my-4">
|
||||||
|
<div className="text-center opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
<p className="text-[14px] font-normal leading-normal text-white">
|
||||||
|
클릭하여 이미지 변경
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center shrink-0">
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 13.3333V26.6667M13.3333 20H26.6667"
|
||||||
|
stroke="var(--color-text-meta)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[14px] font-normal leading-normal text-[var(--color-text-meta)] whitespace-pre">
|
||||||
|
(클릭하여 이미지 업로드)
|
||||||
|
<br aria-hidden="true" />
|
||||||
|
미첨부 시 기본 이미지가 노출됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.image && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.image}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 표시 */}
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions Container */}
|
||||||
|
<div className="flex flex-col gap-8 h-[96px] items-center p-6">
|
||||||
|
<div className="flex items-center justify-center gap-3 w-full">
|
||||||
|
{editingBanner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] w-[136px] hover:bg-[#fae6e6] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-bg-gray-light)] text-[16px] font-semibold leading-normal text-[var(--color-basic-text)] w-[136px] hover:bg-[var(--color-bg-gray-hover)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-active-button)] text-[16px] font-semibold leading-normal text-white w-[136px] hover:bg-[var(--color-active-button-hover)] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{isDeleteConfirmOpen && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
{errors.submit && (
|
||||||
|
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
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={handleDeleteConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
{isDeleting ? '삭제 중...' : '삭제'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
180
src/app/admin/banner/page.tsx
Normal file
180
src/app/admin/banner/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import BannerRegistrationModal, { type Banner } from "./BannerRegistrationModal";
|
||||||
|
|
||||||
|
export default function AdminBannerPage() {
|
||||||
|
// TODO: 나중에 실제 데이터로 교체
|
||||||
|
const [banners, setBanners] = useState<Banner[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
order: 1,
|
||||||
|
imageUrl: "http://localhost:3845/assets/43be88ae6f992fc221d0d9c29e82073e7b202f46.png",
|
||||||
|
title: "XR 교육 플랫폼에 오신 것을 환영합니다",
|
||||||
|
description: "다양한 강좌와 함께 성장하는 학습 경험을 시작하세요.",
|
||||||
|
registeredDate: "2025-09-10",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
|
||||||
|
|
||||||
|
const handleRegister = () => {
|
||||||
|
setEditingBanner(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingBanner(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBanner = (title: string, description: string, imageKey?: string) => {
|
||||||
|
// TODO: API가 추가되면 실제로 배너를 저장하고 리스트를 새로고침
|
||||||
|
console.log('배너 저장:', { title, description, imageKey });
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingBanner(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBanner = () => {
|
||||||
|
// TODO: API가 추가되면 실제로 배너를 삭제하고 리스트를 새로고침
|
||||||
|
console.log('배너 삭제');
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingBanner(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (banner: Banner) => {
|
||||||
|
setEditingBanner(banner);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
배너 관리
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 pt-8 flex flex-col gap-4">
|
||||||
|
{/* 상단 정보 및 버튼 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
총 {banners.length}건
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegister}
|
||||||
|
className="bg-[#1f2b91] text-white text-[16px] font-semibold leading-[1.5] px-4 py-2 rounded-lg hover:bg-[#1a2478] transition-colors"
|
||||||
|
>
|
||||||
|
등록하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{banners.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
현재 관리할 수 있는 항목이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-[#dee1e6] rounded-lg overflow-hidden">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className="bg-gray-50 flex h-12">
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
순서
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 shrink-0 w-[240px]">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
배너 이미지
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
배너 문구
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
|
||||||
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
|
||||||
|
등록일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 바디 */}
|
||||||
|
{banners.map((banner) => (
|
||||||
|
<div
|
||||||
|
key={banner.id}
|
||||||
|
className="border-t border-[#dee1e6] flex cursor-pointer hover:bg-[#F5F7FF] transition-colors"
|
||||||
|
onClick={() => handleRowClick(banner)}
|
||||||
|
>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||||
|
{banner.order}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[240px]">
|
||||||
|
<div className="h-[120px] w-[208px] rounded overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={banner.imageUrl}
|
||||||
|
alt={banner.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#1b2027]">
|
||||||
|
{banner.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] font-medium leading-[1.5] text-[#333c47]">
|
||||||
|
{banner.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||||
|
{banner.registeredDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BannerRegistrationModal
|
||||||
|
open={isModalOpen}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
onSave={handleSaveBanner}
|
||||||
|
onDelete={handleDeleteBanner}
|
||||||
|
editingBanner={editingBanner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -42,33 +42,9 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
|
|
||||||
setIsLoadingInstructors(true);
|
setIsLoadingInstructors(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token') || document.cookie
|
// 외부 API 호출 - type이 'ADMIN'인 사용자만 조회
|
||||||
.split('; ')
|
const response = await apiService.getUsersCompact({ type: 'ADMIN', limit: 10 });
|
||||||
.find(row => row.startsWith('token='))
|
const data = response.data;
|
||||||
?.split('=')[1];
|
|
||||||
|
|
||||||
// 외부 API 호출
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/compact`
|
|
||||||
: 'https://hrdi.coconutmeet.net/admin/users/compact';
|
|
||||||
|
|
||||||
// 쿼리 파라미터 추가: type=ADMIN
|
|
||||||
const apiUrl = new URL(baseUrl);
|
|
||||||
apiUrl.searchParams.set('type', 'ADMIN');
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`강사 목록을 가져오는데 실패했습니다. (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
let usersArray: any[] = [];
|
let usersArray: any[] = [];
|
||||||
@@ -293,15 +269,6 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('token') || document.cookie
|
|
||||||
.split('; ')
|
|
||||||
.find(row => row.startsWith('token='))
|
|
||||||
?.split('=')[1];
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
|
||||||
? process.env.NEXT_PUBLIC_API_BASE_URL
|
|
||||||
: 'https://hrdi.coconutmeet.net';
|
|
||||||
|
|
||||||
const requestBody: {
|
const requestBody: {
|
||||||
title: string;
|
title: string;
|
||||||
instructor: string;
|
instructor: string;
|
||||||
@@ -354,52 +321,35 @@ export default function CourseRegistrationModal({ open, onClose, onSave, onDelet
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 등록 모드: POST /subjects
|
// 등록 모드: POST /subjects
|
||||||
const response = await fetch(`${baseUrl}/subjects`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `과목 등록 실패 (${response.status})`;
|
|
||||||
try {
|
try {
|
||||||
const errorData = await response.json();
|
const createRequestBody: {
|
||||||
if (errorData.error) {
|
title: string;
|
||||||
errorMessage = errorData.error;
|
instructor: string;
|
||||||
} else if (errorData.message) {
|
imageKey?: string;
|
||||||
errorMessage = errorData.message;
|
} = {
|
||||||
}
|
title: courseName.trim(),
|
||||||
} catch (parseError) {
|
instructor: selectedInstructor.name,
|
||||||
// JSON 파싱 실패 시 기본 메시지 사용
|
};
|
||||||
}
|
|
||||||
console.error('과목 등록 실패:', errorMessage);
|
// imageKey 처리: 등록 모드에서 새 이미지가 있으면 포함
|
||||||
setErrors({ submit: errorMessage });
|
if (imageKey) {
|
||||||
setIsSaving(false);
|
createRequestBody.imageKey = imageKey;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 응답에서 id 추출하여 저장
|
await apiService.createSubject(createRequestBody);
|
||||||
try {
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
// 응답에서 id 추출 (다양한 가능한 필드명 확인)
|
|
||||||
const subjectId = responseData.id
|
|
||||||
|| responseData.data?.id
|
|
||||||
|| responseData.subjectId
|
|
||||||
|| responseData.data?.subjectId
|
|
||||||
|| null;
|
|
||||||
} catch (parseError) {
|
|
||||||
// 응답 파싱 실패 시 무시
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
// 성공 시 onSave 콜백 호출 및 모달 닫기
|
||||||
if (onSave && selectedInstructor) {
|
if (onSave && selectedInstructor) {
|
||||||
onSave(courseName.trim(), selectedInstructor.name);
|
onSave(courseName.trim(), selectedInstructor.name);
|
||||||
}
|
}
|
||||||
onClose(); // 모달 닫기
|
onClose(); // 모달 닫기
|
||||||
|
} catch (createError) {
|
||||||
|
const errorMessage = createError instanceof Error ? createError.message : '과목 등록 중 오류가 발생했습니다.';
|
||||||
|
console.error('과목 등록 실패:', errorMessage);
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from "react";
|
import { useState, useMemo, useEffect, useRef } from "react";
|
||||||
import AdminSidebar from "@/app/components/AdminSidebar";
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
import CourseRegistrationModal from "./CourseRegistrationModal";
|
import CourseRegistrationModal from "./CourseRegistrationModal";
|
||||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||||
@@ -31,6 +31,8 @@ export default function AdminCoursesPage() {
|
|||||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
const prevModalOpenRef = useRef(false);
|
||||||
|
const shouldRefreshRef = useRef(false);
|
||||||
|
|
||||||
// API에서 과목 리스트 가져오기
|
// API에서 과목 리스트 가져오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,35 +72,9 @@ export default function AdminCoursesPage() {
|
|||||||
}, [sortedCourses, currentPage]);
|
}, [sortedCourses, currentPage]);
|
||||||
|
|
||||||
const handleSaveCourse = async (courseName: string, instructorName: string) => {
|
const handleSaveCourse = async (courseName: string, instructorName: string) => {
|
||||||
if (editingCourse) {
|
shouldRefreshRef.current = true; // 새로고침 플래그 설정
|
||||||
// 수정 모드 - TODO: API 호출로 변경 필요
|
|
||||||
setCourses(prev => prev.map(course =>
|
|
||||||
course.id === editingCourse.id
|
|
||||||
? { ...course, courseName, instructorName }
|
|
||||||
: course
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// 등록 모드 - TODO: API 호출로 변경 필요
|
|
||||||
const newCourse: Course = {
|
|
||||||
id: String(Date.now()),
|
|
||||||
courseName,
|
|
||||||
instructorName,
|
|
||||||
createdAt: new Date().toISOString().split('T')[0],
|
|
||||||
createdBy: '', // API에서 받아오도록 변경 필요
|
|
||||||
hasLessons: false, // 기본값: 미포함
|
|
||||||
};
|
|
||||||
setCourses(prev => [...prev, newCourse]);
|
|
||||||
}
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingCourse(null);
|
setEditingCourse(null);
|
||||||
|
|
||||||
// 저장 후 리스트 새로고침
|
|
||||||
try {
|
|
||||||
const data = await getCourses();
|
|
||||||
setCourses(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('과목 리스트 새로고침 오류:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = (course: Course) => {
|
const handleRowClick = (course: Course) => {
|
||||||
@@ -117,24 +93,37 @@ export default function AdminCoursesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCourse = async () => {
|
const handleDeleteCourse = async () => {
|
||||||
if (editingCourse) {
|
shouldRefreshRef.current = true; // 새로고침 플래그 설정
|
||||||
// TODO: API 호출로 삭제 처리 필요
|
|
||||||
setCourses(prev => prev.filter(course => course.id !== editingCourse.id));
|
|
||||||
setEditingCourse(null);
|
setEditingCourse(null);
|
||||||
setShowToast(true);
|
setShowToast(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setShowToast(false);
|
setShowToast(false);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
// 삭제 후 리스트 새로고침
|
// 모달이 닫힌 후 리스트 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
// 모달이 열렸다가 닫힐 때, 그리고 새로고침 플래그가 설정되어 있을 때만 새로고침
|
||||||
|
if (prevModalOpenRef.current && !isModalOpen && shouldRefreshRef.current) {
|
||||||
|
shouldRefreshRef.current = false; // 플래그 리셋
|
||||||
|
|
||||||
|
async function refreshList() {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
const data = await getCourses();
|
const data = await getCourses();
|
||||||
|
console.log('📋 [AdminCoursesPage] 새로고침된 데이터:', data);
|
||||||
|
console.log('📋 [AdminCoursesPage] 새로고침된 데이터 개수:', data.length);
|
||||||
setCourses(data);
|
setCourses(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('과목 리스트 새로고침 오류:', error);
|
console.error('과목 리스트 새로고침 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
refreshList();
|
||||||
|
}
|
||||||
|
prevModalOpenRef.current = isModalOpen;
|
||||||
|
}, [isModalOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-white">
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
type RoleType = 'learner' | 'instructor' | 'admin';
|
type RoleType = 'learner' | 'instructor' | 'admin';
|
||||||
type AccountStatus = 'active' | 'inactive';
|
type AccountStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
@@ -22,43 +24,8 @@ export async function getInstructors(): Promise<UserRow[]> {
|
|||||||
?.split('=')[1])
|
?.split('=')[1])
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
const response = await apiService.getUsersCompact();
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/compact`
|
const data = response.data;
|
||||||
: 'https://hrdi.coconutmeet.net/admin/users/compact';
|
|
||||||
|
|
||||||
// 쿼리 파라미터 추가: type=ADMIN, limit=10
|
|
||||||
const apiUrl = new URL(baseUrl);
|
|
||||||
apiUrl.searchParams.set('type', 'ADMIN');
|
|
||||||
apiUrl.searchParams.set('limit', '10');
|
|
||||||
|
|
||||||
console.log('🔍 [getInstructors] API 호출 정보:', {
|
|
||||||
url: apiUrl.toString(),
|
|
||||||
hasToken: !!token,
|
|
||||||
tokenLength: token?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('📡 [getInstructors] API 응답 상태:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
ok: response.ok
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ [getInstructors] API 에러 응답:', errorText);
|
|
||||||
console.error('강사 목록 가져오기 실패:', response.status);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
console.log('📦 [getInstructors] 원본 API 응답 데이터:', {
|
console.log('📦 [getInstructors] 원본 API 응답 데이터:', {
|
||||||
type: typeof data,
|
type: typeof data,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useMemo } from "react";
|
|||||||
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 { type UserRow } from "./mockData";
|
import { type UserRow } from "./mockData";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
type TabType = 'all' | 'learner' | 'instructor' | 'admin';
|
type TabType = 'all' | 'learner' | 'instructor' | 'admin';
|
||||||
type RoleType = 'learner' | 'instructor' | 'admin';
|
type RoleType = 'learner' | 'instructor' | 'admin';
|
||||||
@@ -45,35 +46,9 @@ export default function AdminIdPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const token = localStorage.getItem('token') || document.cookie
|
|
||||||
.split('; ')
|
|
||||||
.find(row => row.startsWith('token='))
|
|
||||||
?.split('=')[1];
|
|
||||||
|
|
||||||
// 외부 API 호출
|
// 외부 API 호출
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
const response = await apiService.getUsersCompact();
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/compact`
|
const data = response.data;
|
||||||
: 'https://hrdi.coconutmeet.net/admin/users/compact';
|
|
||||||
|
|
||||||
// 쿼리 파라미터 추가: type=STUDENT, limit=10, page=currentPage
|
|
||||||
const apiUrl = new URL(baseUrl);
|
|
||||||
apiUrl.searchParams.set('type', 'STUDENT');
|
|
||||||
apiUrl.searchParams.set('limit', '10');
|
|
||||||
apiUrl.searchParams.set('page', String(currentPage));
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`사용자 데이터를 가져오는데 실패했습니다. (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
let usersArray: any[] = [];
|
let usersArray: any[] = [];
|
||||||
@@ -206,48 +181,7 @@ export default function AdminIdPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('token') || document.cookie
|
await apiService.unsuspendUser(selectedUserId);
|
||||||
.split('; ')
|
|
||||||
.find(row => row.startsWith('token='))
|
|
||||||
?.split('=')[1];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
setToastMessage('로그인이 필요합니다.');
|
|
||||||
setShowToast(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowToast(false);
|
|
||||||
}, 3000);
|
|
||||||
setIsActivateModalOpen(false);
|
|
||||||
setSelectedUserId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/${selectedUserId}/unsuspend`
|
|
||||||
: `https://hrdi.coconutmeet.net/admin/users/${selectedUserId}/unsuspend`;
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `계정 활성화 실패 (${response.status})`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (errorData.error) {
|
|
||||||
errorMessage = errorData.error;
|
|
||||||
} else if (errorData.message) {
|
|
||||||
errorMessage = errorData.message;
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 호출 성공 시 로컬 상태 업데이트
|
// API 호출 성공 시 로컬 상태 업데이트
|
||||||
setUsers(prevUsers =>
|
setUsers(prevUsers =>
|
||||||
@@ -295,52 +229,7 @@ export default function AdminIdPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token') || document.cookie
|
await apiService.suspendUser(selectedUserId);
|
||||||
.split('; ')
|
|
||||||
.find(row => row.startsWith('token='))
|
|
||||||
?.split('=')[1];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
setToastMessage('로그인이 필요합니다.');
|
|
||||||
setShowToast(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowToast(false);
|
|
||||||
}, 3000);
|
|
||||||
setIsDeactivateModalOpen(false);
|
|
||||||
setSelectedUserId(null);
|
|
||||||
setDeactivateReason('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/users/${selectedUserId}/suspend`
|
|
||||||
: `https://hrdi.coconutmeet.net/admin/users/${selectedUserId}/suspend`;
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
reason: deactivateReason,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `계정 비활성화 실패 (${response.status})`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (errorData.error) {
|
|
||||||
errorMessage = errorData.error;
|
|
||||||
} else if (errorData.message) {
|
|
||||||
errorMessage = errorData.message;
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 호출 성공 시 로컬 상태 업데이트
|
// API 호출 성공 시 로컬 상태 업데이트
|
||||||
setUsers(prevUsers =>
|
setUsers(prevUsers =>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -16,8 +17,10 @@ export default function LessonEditPage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const videoFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const vrFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const csvFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [showToast, setShowToast] = useState(false);
|
|
||||||
|
|
||||||
// 폼 상태
|
// 폼 상태
|
||||||
const [selectedCourse, setSelectedCourse] = useState<string>("");
|
const [selectedCourse, setSelectedCourse] = useState<string>("");
|
||||||
@@ -37,6 +40,17 @@ export default function LessonEditPage() {
|
|||||||
const [questionFileObject, setQuestionFileObject] = useState<File | null>(null);
|
const [questionFileObject, setQuestionFileObject] = useState<File | null>(null);
|
||||||
const [questionFileKey, setQuestionFileKey] = useState<string | null>(null);
|
const [questionFileKey, setQuestionFileKey] = useState<string | null>(null);
|
||||||
const [existingQuestionFile, setExistingQuestionFile] = useState<{fileName: string, fileKey?: string} | null>(null);
|
const [existingQuestionFile, setExistingQuestionFile] = useState<{fileName: string, fileKey?: string} | null>(null);
|
||||||
|
const [csvData, setCsvData] = useState<string[][]>([]);
|
||||||
|
const [csvHeaders, setCsvHeaders] = 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 [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false);
|
||||||
|
|
||||||
// 원본 데이터 저장 (변경사항 비교용)
|
// 원본 데이터 저장 (변경사항 비교용)
|
||||||
const [originalData, setOriginalData] = useState<{
|
const [originalData, setOriginalData] = useState<{
|
||||||
@@ -176,11 +190,134 @@ export default function LessonEditPage() {
|
|||||||
|
|
||||||
// 기존 평가 문제 파일
|
// 기존 평가 문제 파일
|
||||||
if (data.csvKey || data.csvUrl) {
|
if (data.csvKey || data.csvUrl) {
|
||||||
|
const csvFileKey = data.csvKey || (data.csvUrl && data.csvUrl.startsWith('csv/') ? data.csvUrl.substring(4) : data.csvUrl);
|
||||||
|
|
||||||
setExistingQuestionFile({
|
setExistingQuestionFile({
|
||||||
fileName: '평가문제.csv',
|
fileName: data.csvFileName || data.csv_file_name || data.csvName || '평가문제.csv',
|
||||||
fileKey: data.csvKey,
|
fileKey: csvFileKey,
|
||||||
});
|
});
|
||||||
setQuestionFileCount(1);
|
setQuestionFileCount(1);
|
||||||
|
|
||||||
|
// CSV 파일 다운로드 및 파싱
|
||||||
|
if (data.csvUrl || data.csvKey) {
|
||||||
|
try {
|
||||||
|
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net';
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
|
|
||||||
|
let fileKey: string | null = null;
|
||||||
|
|
||||||
|
// csvUrl에서 fileKey 추출
|
||||||
|
if (data.csvUrl) {
|
||||||
|
// 전체 URL인 경우 fileKey 추출
|
||||||
|
if (data.csvUrl.startsWith('http://') || data.csvUrl.startsWith('https://')) {
|
||||||
|
// URL에서 /api/files/ 이후 부분을 fileKey로 사용
|
||||||
|
const filesIndex = data.csvUrl.indexOf('/api/files/');
|
||||||
|
if (filesIndex !== -1) {
|
||||||
|
const extractedKey = data.csvUrl.substring(filesIndex + '/api/files/'.length);
|
||||||
|
// URL 디코딩
|
||||||
|
fileKey = decodeURIComponent(extractedKey);
|
||||||
|
} else {
|
||||||
|
// URL에서 마지막 경로를 fileKey로 사용
|
||||||
|
const urlParts = data.csvUrl.split('/');
|
||||||
|
const lastPart = urlParts[urlParts.length - 1];
|
||||||
|
if (lastPart) {
|
||||||
|
fileKey = decodeURIComponent(lastPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.csvUrl.startsWith('csv/')) {
|
||||||
|
// "csv/" 접두사 제거
|
||||||
|
fileKey = data.csvUrl.substring(4);
|
||||||
|
} else {
|
||||||
|
// 그 외의 경우 fileKey로 사용
|
||||||
|
fileKey = data.csvUrl;
|
||||||
|
}
|
||||||
|
} else if (data.csvKey) {
|
||||||
|
// csvKey가 있으면 fileKey로 사용
|
||||||
|
fileKey = data.csvKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileKey) {
|
||||||
|
return; // fileKey가 없으면 종료
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/files/{fileKey} 형태로 요청
|
||||||
|
const csvUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey as string)}`;
|
||||||
|
|
||||||
|
const csvResponse = await fetch(csvUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 에러는 파일이 없는 것으로 간주하고 조용히 처리
|
||||||
|
if (csvResponse.status === 404) {
|
||||||
|
console.warn('CSV 파일을 찾을 수 없습니다:', data.csvUrl || data.csvKey);
|
||||||
|
} else if (csvResponse.ok) {
|
||||||
|
const csvText = await csvResponse.text();
|
||||||
|
|
||||||
|
// CSV 파싱 함수
|
||||||
|
const parseCsv = (csvText: string): string[][] => {
|
||||||
|
const lines: string[][] = [];
|
||||||
|
let currentLine: string[] = [];
|
||||||
|
let currentField = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < csvText.length; i++) {
|
||||||
|
const char = csvText[i];
|
||||||
|
const nextChar = csvText[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && nextChar === '"') {
|
||||||
|
currentField += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
currentField = '';
|
||||||
|
} else if ((char === '\n' || char === '\r') && !inQuotes) {
|
||||||
|
if (char === '\r' && nextChar === '\n') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (currentField || currentLine.length > 0) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = [];
|
||||||
|
currentField = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentField || currentLine.length > 0) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseCsv(csvText);
|
||||||
|
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
const headers = parsed[0];
|
||||||
|
const rows = parsed.slice(1);
|
||||||
|
|
||||||
|
setCsvHeaders(headers);
|
||||||
|
setCsvRows(rows);
|
||||||
|
setCsvData(parsed);
|
||||||
|
}
|
||||||
|
} else if (!csvResponse.ok) {
|
||||||
|
console.error(`CSV 파일을 가져오는데 실패했습니다. (${csvResponse.status})`);
|
||||||
|
}
|
||||||
|
} catch (csvError) {
|
||||||
|
console.error('CSV 파일 파싱 실패:', csvError);
|
||||||
|
// CSV 파싱 실패해도 계속 진행
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('강좌 로드 실패:', err);
|
console.error('강좌 로드 실패:', err);
|
||||||
@@ -225,21 +362,270 @@ 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}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 파일 업로드 함수
|
||||||
|
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 파일 파싱 (Promise로 감싸서 파일 읽기 완료 대기)
|
||||||
|
const parseCsvFile = (): Promise<string[][]> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string;
|
||||||
|
if (!text) {
|
||||||
|
reject(new Error('파일을 읽을 수 없습니다.'));
|
||||||
|
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) {
|
||||||
|
reject(new Error('CSV 파일이 비어있습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(parsed);
|
||||||
|
} catch (parseError) {
|
||||||
|
reject(new Error('CSV 파일을 읽는 중 오류가 발생했습니다.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error('파일을 읽는 중 오류가 발생했습니다.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSV 파일 파싱
|
||||||
|
const parsed = await parseCsvFile();
|
||||||
|
|
||||||
|
// 첫 번째 줄을 헤더로 사용
|
||||||
|
const headers = parsed[0];
|
||||||
|
const rows = parsed.slice(1);
|
||||||
|
|
||||||
|
setCsvHeaders(headers);
|
||||||
|
setCsvRows(rows);
|
||||||
|
setCsvData(parsed);
|
||||||
|
|
||||||
|
// 단일 파일 업로드
|
||||||
|
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);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '파일 업로드에 실패했습니다. 다시 시도해주세요.';
|
||||||
|
alert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 교체 확인 모달 핸들러
|
||||||
|
const handleFileReplaceConfirm = () => {
|
||||||
|
if (!pendingFileType) return;
|
||||||
|
|
||||||
|
setIsFileReplaceModalOpen(false);
|
||||||
|
|
||||||
|
// 파일 선택 다이얼로그 열기
|
||||||
|
if (pendingFileType === 'video' && videoFileInputRef.current) {
|
||||||
|
videoFileInputRef.current.click();
|
||||||
|
} else if (pendingFileType === 'vr' && vrFileInputRef.current) {
|
||||||
|
vrFileInputRef.current.click();
|
||||||
|
} else if (pendingFileType === 'csv' && csvFileInputRef.current) {
|
||||||
|
csvFileInputRef.current.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingFiles([]);
|
||||||
|
setPendingFileType(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileReplaceCancel = () => {
|
||||||
|
setIsFileReplaceModalOpen(false);
|
||||||
|
setPendingFiles([]);
|
||||||
|
setPendingFileType(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveClick = async () => {
|
const handleSaveClick = async () => {
|
||||||
// 유효성 검사
|
// 유효성 검사
|
||||||
const newErrors: {
|
const newErrors: {
|
||||||
@@ -370,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;
|
||||||
}
|
}
|
||||||
@@ -378,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}`);
|
|
||||||
}, 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 : '강좌 수정 중 오류가 발생했습니다.';
|
||||||
@@ -605,9 +986,21 @@ export default function LessonEditPage() {
|
|||||||
30MB 미만 파일
|
30MB 미만 파일
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
|
<label
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
// 기존 파일이나 새로 첨부한 파일이 있으면 확인 모달 표시
|
||||||
|
if (existingVideoFiles.length > 0 || courseVideoFiles.length > 0 || courseVideoFileKeys.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPendingFiles([]);
|
||||||
|
setPendingFileType('video');
|
||||||
|
setIsFileReplaceModalOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>첨부</span>
|
<span>첨부</span>
|
||||||
<input
|
<input
|
||||||
|
ref={videoFileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept=".mp4,video/mp4"
|
accept=".mp4,video/mp4"
|
||||||
@@ -663,32 +1056,9 @@ export default function LessonEditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validFiles.length > 0) {
|
if (validFiles.length > 0) {
|
||||||
try {
|
// 기존 파일 삭제 후 새 파일 업로드
|
||||||
// 다중 파일 업로드
|
setExistingVideoFiles([]);
|
||||||
const uploadResponse = await apiService.uploadFiles(validFiles);
|
handleVideoFileUpload(validFiles);
|
||||||
|
|
||||||
// 응답에서 fileKey 배열 추출
|
|
||||||
const fileKeys: string[] = [];
|
|
||||||
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) {
|
|
||||||
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('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
@@ -710,7 +1080,7 @@ export default function LessonEditPage() {
|
|||||||
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
|
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
|
||||||
>
|
>
|
||||||
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
||||||
{file.fileName} (기존)
|
{file.fileName}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -764,9 +1134,21 @@ export default function LessonEditPage() {
|
|||||||
30MB 미만 파일
|
30MB 미만 파일
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
|
<label
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
// 기존 파일이나 새로 첨부한 파일이 있으면 확인 모달 표시
|
||||||
|
if (existingVrFiles.length > 0 || vrContentFiles.length > 0 || vrContentFileKeys.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPendingFiles([]);
|
||||||
|
setPendingFileType('vr');
|
||||||
|
setIsFileReplaceModalOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>첨부</span>
|
<span>첨부</span>
|
||||||
<input
|
<input
|
||||||
|
ref={vrFileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept=".zip,application/zip"
|
accept=".zip,application/zip"
|
||||||
@@ -822,32 +1204,9 @@ export default function LessonEditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validFiles.length > 0) {
|
if (validFiles.length > 0) {
|
||||||
try {
|
// 기존 파일 삭제 후 새 파일 업로드
|
||||||
// 다중 파일 업로드
|
setExistingVrFiles([]);
|
||||||
const uploadResponse = await apiService.uploadFiles(validFiles);
|
handleVrFileUpload(validFiles);
|
||||||
|
|
||||||
// 응답에서 fileKey 배열 추출
|
|
||||||
const fileKeys: string[] = [];
|
|
||||||
if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results)) {
|
|
||||||
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('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
@@ -869,7 +1228,7 @@ export default function LessonEditPage() {
|
|||||||
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
|
className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px]"
|
||||||
>
|
>
|
||||||
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
||||||
{file.fileName} (기존)
|
{file.fileName}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -924,9 +1283,21 @@ export default function LessonEditPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-[8px]">
|
<div className="flex items-center gap-[8px]">
|
||||||
<label className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center">
|
<label
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] border border-[#8c95a1] rounded-[6px] bg-white text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap hover:bg-gray-50 transition-colors cursor-pointer flex items-center justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
// 기존 파일이나 새로 첨부한 파일이 있으면 확인 모달 표시
|
||||||
|
if (questionFileObject || questionFileKey || existingQuestionFile) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPendingFiles([]);
|
||||||
|
setPendingFileType('csv');
|
||||||
|
setIsFileReplaceModalOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>첨부</span>
|
<span>첨부</span>
|
||||||
<input
|
<input
|
||||||
|
ref={csvFileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -935,47 +1306,37 @@ export default function LessonEditPage() {
|
|||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
if (file.name.toLowerCase().endsWith('.csv')) {
|
|
||||||
try {
|
|
||||||
// 단일 파일 업로드
|
|
||||||
const uploadResponse = await apiService.uploadFile(file);
|
|
||||||
|
|
||||||
// 응답에서 fileKey 추출
|
// CSV 파일만 허용
|
||||||
let fileKey: string | null = null;
|
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||||
if (uploadResponse.data?.fileKey) {
|
alert('CSV 파일 형식만 첨부할 수 있습니다.');
|
||||||
fileKey = uploadResponse.data.fileKey;
|
e.target.value = '';
|
||||||
} else if (uploadResponse.data?.key) {
|
return;
|
||||||
fileKey = uploadResponse.data.key;
|
|
||||||
} 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);
|
|
||||||
setQuestionFileKey(fileKey);
|
|
||||||
setQuestionFileCount(1);
|
|
||||||
setExistingQuestionFile(null);
|
setExistingQuestionFile(null);
|
||||||
} else {
|
await handleCsvFileUpload(file);
|
||||||
throw new Error('파일 업로드는 완료되었지만 fileKey를 받지 못했습니다.');
|
e.target.value = '';
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('학습 평가 문제 업로드 실패:', error);
|
|
||||||
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
|
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
|
||||||
{existingQuestionFile || questionFileObject ? (
|
{csvData.length === 0 ? (
|
||||||
<div className="h-[64px] px-[20px] flex items-center gap-[12px]">
|
<div className="h-[64px] flex items-center justify-center">
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
||||||
|
학습 평가용 문항 파일을 첨부해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* 파일 정보 및 삭제 버튼 */}
|
||||||
|
{(existingQuestionFile || questionFileObject) && (
|
||||||
|
<div className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px] bg-white border-b border-[#dee1e6]">
|
||||||
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
||||||
{existingQuestionFile ? `${existingQuestionFile.fileName} (기존)` : questionFileObject?.name}
|
{existingQuestionFile ? existingQuestionFile.fileName : questionFileObject?.name}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -984,6 +1345,9 @@ export default function LessonEditPage() {
|
|||||||
setQuestionFileKey(null);
|
setQuestionFileKey(null);
|
||||||
setExistingQuestionFile(null);
|
setExistingQuestionFile(null);
|
||||||
setQuestionFileCount(0);
|
setQuestionFileCount(0);
|
||||||
|
setCsvData([]);
|
||||||
|
setCsvHeaders([]);
|
||||||
|
setCsvRows([]);
|
||||||
}}
|
}}
|
||||||
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
||||||
aria-label="파일 삭제"
|
aria-label="파일 삭제"
|
||||||
@@ -991,12 +1355,62 @@ export default function LessonEditPage() {
|
|||||||
<CloseXOSvg />
|
<CloseXOSvg />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div className="h-[64px] flex items-center justify-center">
|
{/* CSV 표 */}
|
||||||
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
<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="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip relative shrink-0 w-full sticky top-0 z-10">
|
||||||
|
{csvHeaders.map((header, index) => {
|
||||||
|
const isLast = index === csvHeaders.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`border-[#dee1e6] ${
|
||||||
|
isLast ? '' : 'border-[0px_1px_0px_0px]'
|
||||||
|
} border-solid box-border content-stretch flex gap-[10px] h-full items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
|
||||||
|
index === 0 ? 'w-[48px]' : index === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col font-['Pretendard:SemiBold',sans-serif] justify-center leading-[0] not-italic relative shrink-0 text-[#4c5561] text-[14px] text-nowrap">
|
||||||
|
<p className="leading-[1.5] whitespace-pre">{header || `열 ${index + 1}`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
{csvRows.map((row, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={rowIndex}
|
||||||
|
className="border-[#dee1e6] border-[1px_0px_0px] border-solid h-[48px] relative shrink-0 w-full"
|
||||||
|
>
|
||||||
|
<div className="content-stretch flex h-[48px] items-start overflow-clip relative rounded-[inherit] w-full">
|
||||||
|
{csvHeaders.map((_, colIndex) => {
|
||||||
|
const isLast = colIndex === csvHeaders.length - 1;
|
||||||
|
const cellValue = row[colIndex] || '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={colIndex}
|
||||||
|
className={`border-[#dee1e6] ${
|
||||||
|
isLast ? '' : 'border-[0px_1px_0px_0px]'
|
||||||
|
} border-solid box-border content-stretch flex flex-col gap-[4px] items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
|
||||||
|
colIndex === 0 ? 'w-[48px]' : colIndex === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#1b2027] text-[15px] text-nowrap whitespace-pre">
|
||||||
|
{cellValue}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1029,22 +1443,57 @@ export default function LessonEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 강좌 수정 완료 토스트 */}
|
{/* 파일 교체 확인 모달 */}
|
||||||
{showToast && (
|
{isFileReplaceModalOpen && (
|
||||||
<div className="fixed right-[60px] bottom-[60px] z-50">
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
<div
|
||||||
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
className="absolute inset-0 bg-black/40"
|
||||||
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
onClick={handleFileReplaceCancel}
|
||||||
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
aria-hidden="true"
|
||||||
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
/>
|
||||||
</svg>
|
<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>
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
강좌가 수정되었습니다.
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
이미 첨부된 파일이 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 변경사항 없음 모달 */}
|
||||||
|
<NoChangesModal
|
||||||
|
open={isNoChangesModalOpen}
|
||||||
|
onClose={() => setIsNoChangesModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import AdminSidebar from '@/app/components/AdminSidebar';
|
import AdminSidebar from '@/app/components/AdminSidebar';
|
||||||
import BackArrowSvg from '@/app/svgs/backarrow';
|
import BackArrowSvg from '@/app/svgs/backarrow';
|
||||||
import DownloadIcon from '@/app/svgs/downloadicon';
|
import DownloadIcon from '@/app/svgs/downloadicon';
|
||||||
@@ -42,11 +42,14 @@ type CourseDetail = {
|
|||||||
videoFiles: VideoFile[];
|
videoFiles: VideoFile[];
|
||||||
vrFiles: VrFile[];
|
vrFiles: VrFile[];
|
||||||
quizData: QuizQuestion[];
|
quizData: QuizQuestion[];
|
||||||
|
csvKey?: string;
|
||||||
|
csvUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminCourseDetailPage() {
|
export default function AdminCourseDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [course, setCourse] = useState<CourseDetail | null>(null);
|
const [course, setCourse] = useState<CourseDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -164,23 +167,99 @@ export default function AdminCourseDetailPage() {
|
|||||||
|
|
||||||
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
|
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
|
||||||
if (videoFileKeys.length > 0) {
|
if (videoFileKeys.length > 0) {
|
||||||
|
// fileKey와 fileName 매핑 생성
|
||||||
|
const videoFileMap = new Map<string, string>();
|
||||||
|
if (data.videoFiles && Array.isArray(data.videoFiles)) {
|
||||||
|
data.videoFiles.forEach((vf: any) => {
|
||||||
|
const key = vf.fileKey || vf.key;
|
||||||
|
if (key && (vf.fileName || vf.name)) {
|
||||||
|
videoFileMap.set(key, vf.fileName || vf.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const videoFilePromises = videoFileKeys.map(async (fileKey, index) => {
|
const videoFilePromises = videoFileKeys.map(async (fileKey, index) => {
|
||||||
try {
|
try {
|
||||||
const fileUrl = await apiService.getFile(fileKey);
|
// 먼저 data에서 파일명 정보 확인 (fileKey로 매핑된 파일명 우선)
|
||||||
|
let fileName = videoFileMap.get(fileKey)
|
||||||
|
|| data.videoFiles?.[index]?.fileName
|
||||||
|
|| data.videoFiles?.[index]?.name
|
||||||
|
|| data.videoFileName
|
||||||
|
|| `강좌영상_${index + 1}.mp4`;
|
||||||
|
|
||||||
|
// 파일 정보 가져오기 (크기, 파일명 등)
|
||||||
|
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net';
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
|
const fileUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey)}`;
|
||||||
|
|
||||||
|
const response = await fetch(fileUrl, {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: {
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let fileSize = '0 KB';
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Content-Disposition 헤더에서 파일명 추출 (data에 없을 경우에만)
|
||||||
|
if (!data.videoFiles?.[index]?.fileName && !data.videoFiles?.[index]?.name) {
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
if (contentDisposition) {
|
||||||
|
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||||
|
if (fileNameMatch && fileNameMatch[1]) {
|
||||||
|
fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Length 헤더에서 파일 크기 추출
|
||||||
|
const contentLength = response.headers.get('Content-Length');
|
||||||
|
if (contentLength) {
|
||||||
|
const sizeInBytes = parseInt(contentLength, 10);
|
||||||
|
if (sizeInBytes > 0) {
|
||||||
|
if (sizeInBytes < 1024) {
|
||||||
|
fileSize = `${sizeInBytes} B`;
|
||||||
|
} else if (sizeInBytes < 1024 * 1024) {
|
||||||
|
fileSize = `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
||||||
|
} else {
|
||||||
|
fileSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 파일 URL 가져오기
|
||||||
|
const blobUrl = await apiService.getFile(fileKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(index + 1),
|
id: String(index + 1),
|
||||||
fileName: data.videoFiles?.[index]?.fileName || data.videoFiles?.[index]?.name || `강좌영상_${index + 1}.mp4`,
|
fileName: fileName,
|
||||||
fileSize: data.videoFiles?.[index]?.fileSize || '796.35 KB',
|
fileSize: fileSize,
|
||||||
fileKey: fileKey,
|
fileKey: fileKey,
|
||||||
url: fileUrl || undefined,
|
url: blobUrl || undefined,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
// HEAD 요청 실패 시 파일명은 data에서 가져온 값 사용
|
||||||
|
return {
|
||||||
|
id: String(index + 1),
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize,
|
||||||
|
fileKey: fileKey,
|
||||||
|
url: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`비디오 파일 ${index + 1} 조회 실패:`, err);
|
console.error(`비디오 파일 ${index + 1} 조회 실패:`, err);
|
||||||
// 조회 실패해도 fileKey는 저장
|
// 조회 실패해도 fileKey는 저장, 파일명은 data에서 가져온 값 사용
|
||||||
|
const fallbackFileName = data.videoFiles?.[index]?.fileName
|
||||||
|
|| data.videoFiles?.[index]?.name
|
||||||
|
|| data.videoFileName
|
||||||
|
|| `강좌영상_${index + 1}.mp4`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(index + 1),
|
id: String(index + 1),
|
||||||
fileName: data.videoFiles?.[index]?.fileName || data.videoFiles?.[index]?.name || `강좌영상_${index + 1}.mp4`,
|
fileName: fallbackFileName,
|
||||||
fileSize: data.videoFiles?.[index]?.fileSize || '796.35 KB',
|
fileSize: '0 KB',
|
||||||
fileKey: fileKey,
|
fileKey: fileKey,
|
||||||
url: undefined,
|
url: undefined,
|
||||||
};
|
};
|
||||||
@@ -189,29 +268,6 @@ export default function AdminCourseDetailPage() {
|
|||||||
|
|
||||||
const resolvedVideoFiles = await Promise.all(videoFilePromises);
|
const resolvedVideoFiles = await Promise.all(videoFilePromises);
|
||||||
videoFiles.push(...resolvedVideoFiles);
|
videoFiles.push(...resolvedVideoFiles);
|
||||||
} else {
|
|
||||||
// fileKey 배열이 없으면 기존 방식대로 처리
|
|
||||||
if (data.videoUrl) {
|
|
||||||
videoFiles.push({
|
|
||||||
id: '1',
|
|
||||||
fileName: data.videoFileName || '강좌영상.mp4',
|
|
||||||
fileSize: '796.35 KB',
|
|
||||||
fileKey: data.videoKey,
|
|
||||||
url: data.videoUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 여러 비디오 파일이 있는 경우 처리 (기존 로직 유지)
|
|
||||||
if (data.videoFiles && Array.isArray(data.videoFiles)) {
|
|
||||||
data.videoFiles.forEach((vf: any, index: number) => {
|
|
||||||
videoFiles.push({
|
|
||||||
id: String(index + 1),
|
|
||||||
fileName: vf.fileName || vf.name || `강좌영상_${index + 1}.mp4`,
|
|
||||||
fileSize: vf.fileSize || '796.35 KB',
|
|
||||||
fileKey: vf.fileKey || vf.key,
|
|
||||||
url: vf.url || vf.videoUrl,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VR 파일 목록 구성 - fileKey 배열에서 파일 조회
|
// VR 파일 목록 구성 - fileKey 배열에서 파일 조회
|
||||||
@@ -255,23 +311,101 @@ export default function AdminCourseDetailPage() {
|
|||||||
|
|
||||||
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
|
// fileKey 배열이 있으면 각각에 대해 파일 조회 API 호출
|
||||||
if (vrFileKeys.length > 0) {
|
if (vrFileKeys.length > 0) {
|
||||||
|
// fileKey와 fileName 매핑 생성
|
||||||
|
const vrFileMap = new Map<string, string>();
|
||||||
|
if (data.webglFiles && Array.isArray(data.webglFiles)) {
|
||||||
|
data.webglFiles.forEach((vf: any) => {
|
||||||
|
const key = vf.fileKey || vf.key;
|
||||||
|
if (key && (vf.fileName || vf.name)) {
|
||||||
|
vrFileMap.set(key, vf.fileName || vf.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const vrFilePromises = vrFileKeys.map(async (fileKey, index) => {
|
const vrFilePromises = vrFileKeys.map(async (fileKey, index) => {
|
||||||
try {
|
try {
|
||||||
const fileUrl = await apiService.getFile(fileKey);
|
// 먼저 data에서 파일명 정보 확인 (fileKey로 매핑된 파일명 우선)
|
||||||
|
let fileName = vrFileMap.get(fileKey)
|
||||||
|
|| data.webglFiles?.[index]?.fileName
|
||||||
|
|| data.webglFiles?.[index]?.name
|
||||||
|
|| data.vrFileName
|
||||||
|
|| data.webglFileName
|
||||||
|
|| `VR_콘텐츠_${index + 1}.zip`;
|
||||||
|
|
||||||
|
// 파일 정보 가져오기 (크기, 파일명 등)
|
||||||
|
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net';
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
|
const fileUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey)}`;
|
||||||
|
|
||||||
|
const response = await fetch(fileUrl, {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: {
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let fileSize = '0 KB';
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Content-Disposition 헤더에서 파일명 추출 (data에 없을 경우에만)
|
||||||
|
if (!data.webglFiles?.[index]?.fileName && !data.webglFiles?.[index]?.name) {
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
if (contentDisposition) {
|
||||||
|
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||||
|
if (fileNameMatch && fileNameMatch[1]) {
|
||||||
|
fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Length 헤더에서 파일 크기 추출
|
||||||
|
const contentLength = response.headers.get('Content-Length');
|
||||||
|
if (contentLength) {
|
||||||
|
const sizeInBytes = parseInt(contentLength, 10);
|
||||||
|
if (sizeInBytes > 0) {
|
||||||
|
if (sizeInBytes < 1024) {
|
||||||
|
fileSize = `${sizeInBytes} B`;
|
||||||
|
} else if (sizeInBytes < 1024 * 1024) {
|
||||||
|
fileSize = `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
||||||
|
} else {
|
||||||
|
fileSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 파일 URL 가져오기
|
||||||
|
const blobUrl = await apiService.getFile(fileKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(index + 1),
|
id: String(index + 1),
|
||||||
fileName: data.webglFiles?.[index]?.fileName || data.webglFiles?.[index]?.name || data.vrFileName || data.webglFileName || `VR_콘텐츠_${index + 1}.zip`,
|
fileName: fileName,
|
||||||
fileSize: data.webglFiles?.[index]?.fileSize || '796.35 KB',
|
fileSize: fileSize,
|
||||||
fileKey: fileKey,
|
fileKey: fileKey,
|
||||||
url: fileUrl || undefined,
|
url: blobUrl || undefined,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
// HEAD 요청 실패 시 파일명은 data에서 가져온 값 사용
|
||||||
|
return {
|
||||||
|
id: String(index + 1),
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize,
|
||||||
|
fileKey: fileKey,
|
||||||
|
url: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`VR 파일 ${index + 1} 조회 실패:`, err);
|
console.error(`VR 파일 ${index + 1} 조회 실패:`, err);
|
||||||
// 조회 실패해도 fileKey는 저장
|
// 조회 실패해도 fileKey는 저장, 파일명은 data에서 가져온 값 사용
|
||||||
|
const fallbackFileName = data.webglFiles?.[index]?.fileName
|
||||||
|
|| data.webglFiles?.[index]?.name
|
||||||
|
|| data.vrFileName
|
||||||
|
|| data.webglFileName
|
||||||
|
|| `VR_콘텐츠_${index + 1}.zip`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(index + 1),
|
id: String(index + 1),
|
||||||
fileName: data.webglFiles?.[index]?.fileName || data.webglFiles?.[index]?.name || data.vrFileName || data.webglFileName || `VR_콘텐츠_${index + 1}.zip`,
|
fileName: fallbackFileName,
|
||||||
fileSize: data.webglFiles?.[index]?.fileSize || '796.35 KB',
|
fileSize: '0 KB',
|
||||||
fileKey: fileKey,
|
fileKey: fileKey,
|
||||||
url: undefined,
|
url: undefined,
|
||||||
};
|
};
|
||||||
@@ -280,34 +414,153 @@ export default function AdminCourseDetailPage() {
|
|||||||
|
|
||||||
const resolvedVrFiles = await Promise.all(vrFilePromises);
|
const resolvedVrFiles = await Promise.all(vrFilePromises);
|
||||||
vrFiles.push(...resolvedVrFiles);
|
vrFiles.push(...resolvedVrFiles);
|
||||||
} else if (data.webglUrl || data.vrUrl) {
|
|
||||||
// fileKey 배열이 없고 webglUrl이 있으면 기존 방식대로 처리
|
|
||||||
vrFiles.push({
|
|
||||||
id: '1',
|
|
||||||
fileName: data.vrFileName || data.webglFileName || 'VR_콘텐츠.zip',
|
|
||||||
fileSize: '796.35 KB',
|
|
||||||
fileKey: data.webglKey || data.vrKey,
|
|
||||||
url: data.webglUrl || data.vrUrl,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 퀴즈 데이터 구성 (CSV 파일이 있으면 파싱, 없으면 빈 배열)
|
// 퀴즈 데이터 구성 (CSV 파일이 있으면 파싱, 없으면 빈 배열)
|
||||||
const quizData: QuizQuestion[] = [];
|
const quizData: QuizQuestion[] = [];
|
||||||
if (data.csvKey || data.quizData) {
|
if (data.csvKey || data.csvUrl || data.quizData) {
|
||||||
// 실제로는 CSV 파일을 파싱하거나 API에서 퀴즈 데이터를 가져와야 함
|
// quizData가 이미 배열로 제공되는 경우
|
||||||
// 여기서는 예시 데이터만 추가
|
|
||||||
if (data.quizData && Array.isArray(data.quizData)) {
|
if (data.quizData && Array.isArray(data.quizData)) {
|
||||||
data.quizData.forEach((q: any, index: number) => {
|
data.quizData.forEach((q: any, index: number) => {
|
||||||
quizData.push({
|
quizData.push({
|
||||||
id: String(q.id || index + 1),
|
id: String(q.id || index + 1),
|
||||||
number: q.number || index + 1,
|
number: q.number || index + 1,
|
||||||
question: q.question || '블라블라블라블라블라블라블라블라블라',
|
question: q.question || '',
|
||||||
correctAnswer: q.correctAnswer || q.correct_answer || '{정답}',
|
correctAnswer: q.correctAnswer || q.correct_answer || '',
|
||||||
wrongAnswer1: q.wrongAnswer1 || q.wrong_answer1 || '{오답1}',
|
wrongAnswer1: q.wrongAnswer1 || q.wrong_answer1 || '',
|
||||||
wrongAnswer2: q.wrongAnswer2 || q.wrong_answer2 || '{오답2}',
|
wrongAnswer2: q.wrongAnswer2 || q.wrong_answer2 || '',
|
||||||
wrongAnswer3: q.wrongAnswer3 || q.wrong_answer3 || '{오답3}',
|
wrongAnswer3: q.wrongAnswer3 || q.wrong_answer3 || '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// CSV 파일을 다운로드하고 파싱
|
||||||
|
if (data.csvUrl || data.csvKey) {
|
||||||
|
try {
|
||||||
|
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hrdi.coconutmeet.net';
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
|
|
||||||
|
let fileKey: string | null = null;
|
||||||
|
|
||||||
|
// csvUrl에서 fileKey 추출
|
||||||
|
if (data.csvUrl) {
|
||||||
|
// 전체 URL인 경우 fileKey 추출
|
||||||
|
if (data.csvUrl.startsWith('http://') || data.csvUrl.startsWith('https://')) {
|
||||||
|
// URL에서 /api/files/ 이후 부분을 fileKey로 사용
|
||||||
|
const filesIndex = data.csvUrl.indexOf('/api/files/');
|
||||||
|
if (filesIndex !== -1) {
|
||||||
|
const extractedKey = data.csvUrl.substring(filesIndex + '/api/files/'.length);
|
||||||
|
// URL 디코딩
|
||||||
|
fileKey = decodeURIComponent(extractedKey);
|
||||||
|
} else {
|
||||||
|
// URL에서 마지막 경로를 fileKey로 사용
|
||||||
|
const urlParts = data.csvUrl.split('/');
|
||||||
|
const lastPart = urlParts[urlParts.length - 1];
|
||||||
|
if (lastPart) {
|
||||||
|
fileKey = decodeURIComponent(lastPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.csvUrl.startsWith('csv/')) {
|
||||||
|
// "csv/" 접두사 제거
|
||||||
|
fileKey = data.csvUrl.substring(4);
|
||||||
|
} else {
|
||||||
|
// 그 외의 경우 fileKey로 사용
|
||||||
|
fileKey = data.csvUrl;
|
||||||
|
}
|
||||||
|
} else if (data.csvKey) {
|
||||||
|
// csvKey가 있으면 fileKey로 사용
|
||||||
|
fileKey = data.csvKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileKey) {
|
||||||
|
return; // fileKey가 없으면 종료
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/files/{fileKey} 형태로 요청
|
||||||
|
const csvUrl = `${baseURL}/api/files/${encodeURIComponent(fileKey as string)}`;
|
||||||
|
|
||||||
|
const csvResponse = await fetch(csvUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 에러는 파일이 없는 것으로 간주하고 조용히 처리
|
||||||
|
if (csvResponse.status === 404) {
|
||||||
|
console.warn('CSV 파일을 찾을 수 없습니다:', data.csvUrl || data.csvKey);
|
||||||
|
} else if (csvResponse.ok) {
|
||||||
|
const csvText = await csvResponse.text();
|
||||||
|
|
||||||
|
// CSV 파싱 함수
|
||||||
|
const parseCsv = (csvText: string): string[][] => {
|
||||||
|
const lines: string[][] = [];
|
||||||
|
let currentLine: string[] = [];
|
||||||
|
let currentField = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < csvText.length; i++) {
|
||||||
|
const char = csvText[i];
|
||||||
|
const nextChar = csvText[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && nextChar === '"') {
|
||||||
|
currentField += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
currentField = '';
|
||||||
|
} else if ((char === '\n' || char === '\r') && !inQuotes) {
|
||||||
|
if (char === '\r' && nextChar === '\n') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (currentField || currentLine.length > 0) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = [];
|
||||||
|
currentField = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentField || currentLine.length > 0) {
|
||||||
|
currentLine.push(currentField.trim());
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseCsv(csvText);
|
||||||
|
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
const rows = parsed.slice(1);
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
if (row.length >= 5) {
|
||||||
|
quizData.push({
|
||||||
|
id: String(index + 1),
|
||||||
|
number: index + 1,
|
||||||
|
question: row[0] || '',
|
||||||
|
correctAnswer: row[1] || '',
|
||||||
|
wrongAnswer1: row[2] || '',
|
||||||
|
wrongAnswer2: row[3] || '',
|
||||||
|
wrongAnswer3: row[4] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!csvResponse.ok) {
|
||||||
|
console.error(`CSV 파일을 가져오는데 실패했습니다. (${csvResponse.status})`);
|
||||||
|
}
|
||||||
|
} catch (csvError) {
|
||||||
|
console.error('CSV 파일 파싱 실패:', csvError);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +573,8 @@ export default function AdminCourseDetailPage() {
|
|||||||
videoFiles: videoFiles,
|
videoFiles: videoFiles,
|
||||||
vrFiles: vrFiles,
|
vrFiles: vrFiles,
|
||||||
quizData: quizData,
|
quizData: quizData,
|
||||||
|
csvKey: data.csvKey,
|
||||||
|
csvUrl: data.csvUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
setCourse(courseDetail);
|
setCourse(courseDetail);
|
||||||
@@ -332,7 +587,14 @@ export default function AdminCourseDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchCourse();
|
fetchCourse();
|
||||||
}, [params?.id]);
|
}, [params?.id, searchParams]);
|
||||||
|
|
||||||
|
// refresh 쿼리 파라미터가 있으면 URL에서 제거
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('refresh')) {
|
||||||
|
router.replace(`/admin/lessons/${params.id}`);
|
||||||
|
}
|
||||||
|
}, [searchParams, params?.id, router]);
|
||||||
|
|
||||||
// 토스트 자동 닫기
|
// 토스트 자동 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -451,6 +713,56 @@ export default function AdminCourseDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCsvDownload = async () => {
|
||||||
|
if (!course) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let fileKey: string | null = null;
|
||||||
|
|
||||||
|
// csvUrl에서 fileKey 추출
|
||||||
|
if (course.csvUrl) {
|
||||||
|
// 전체 URL인 경우 fileKey 추출
|
||||||
|
if (course.csvUrl.startsWith('http://') || course.csvUrl.startsWith('https://')) {
|
||||||
|
// URL에서 /api/files/ 이후 부분을 fileKey로 사용
|
||||||
|
const filesIndex = course.csvUrl.indexOf('/api/files/');
|
||||||
|
if (filesIndex !== -1) {
|
||||||
|
const extractedKey = course.csvUrl.substring(filesIndex + '/api/files/'.length);
|
||||||
|
// URL 디코딩
|
||||||
|
fileKey = decodeURIComponent(extractedKey);
|
||||||
|
} else {
|
||||||
|
// URL에서 마지막 경로를 fileKey로 사용
|
||||||
|
const urlParts = course.csvUrl.split('/');
|
||||||
|
const lastPart = urlParts[urlParts.length - 1];
|
||||||
|
if (lastPart) {
|
||||||
|
fileKey = decodeURIComponent(lastPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (course.csvUrl.startsWith('csv/')) {
|
||||||
|
// "csv/" 접두사 제거
|
||||||
|
fileKey = course.csvUrl.substring(4);
|
||||||
|
} else {
|
||||||
|
// 그 외의 경우 fileKey로 사용
|
||||||
|
fileKey = course.csvUrl;
|
||||||
|
}
|
||||||
|
} else if (course.csvKey) {
|
||||||
|
// csvKey가 있으면 fileKey로 사용
|
||||||
|
fileKey = course.csvKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileKey) {
|
||||||
|
alert('CSV 파일을 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 파일 다운로드
|
||||||
|
const fileName = `${course.title || 'quiz'}_문제.csv`;
|
||||||
|
await handleDownload(fileKey, undefined, fileName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('CSV 파일 다운로드 실패:', err);
|
||||||
|
alert('CSV 파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteClick = () => {
|
const handleDeleteClick = () => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -668,6 +980,7 @@ export default function AdminCourseDetailPage() {
|
|||||||
{course.quizData.length > 0 && (
|
{course.quizData.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={handleCsvDownload}
|
||||||
className="bg-white border border-[#8c95a1] h-[32px] rounded-[6px] px-4 flex items-center justify-center gap-[4px]"
|
className="bg-white border border-[#8c95a1] h-[32px] rounded-[6px] px-4 flex items-center justify-center gap-[4px]"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="size-[16px] text-[#4c5561]" />
|
<DownloadIcon className="size-[16px] text-[#4c5561]" />
|
||||||
@@ -677,54 +990,58 @@ export default function AdminCourseDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
|
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
|
||||||
{course.quizData.length > 0 ? (
|
{course.quizData.length > 0 ? (
|
||||||
<div className="p-6">
|
<div className="flex flex-col">
|
||||||
<div className="bg-white border border-[#dee1e6] rounded-[8px] overflow-hidden">
|
<div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto csv-table-scroll">
|
||||||
|
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
||||||
{/* 테이블 헤더 */}
|
{/* 테이블 헤더 */}
|
||||||
<div className="bg-[#f1f8ff] h-[48px] flex">
|
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip shrink-0 w-full sticky top-0 z-10">
|
||||||
<div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3">
|
<div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">번호</p>
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">번호</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">문제</p>
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">문제</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">정답</p>
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">정답</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">오답1</p>
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">오답1</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">오답2</p>
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">오답2</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] flex items-center px-2 py-3">
|
<div className="w-[140px] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">오답3</p>
|
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">오답3</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 테이블 행 */}
|
{/* 테이블 행 */}
|
||||||
{course.quizData.map((quiz) => (
|
{course.quizData.map((quiz) => (
|
||||||
<div key={quiz.id} className="border-t border-[#dee1e6] h-[48px] flex">
|
<div key={quiz.id} className="border-t border-[#dee1e6] h-[48px] flex relative shrink-0 w-full">
|
||||||
<div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3">
|
<div className="content-stretch flex h-[48px] items-start overflow-clip relative rounded-[inherit] w-full">
|
||||||
|
<div className="w-[48px] border-r border-[#dee1e6] flex items-center justify-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{quiz.number}</p>
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{quiz.number}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="flex-1 border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.question}</p>
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.question}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.correctAnswer}</p>
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.correctAnswer}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer1}</p>
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer1}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3">
|
<div className="w-[140px] border-r border-[#dee1e6] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer2}</p>
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer2}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[140px] flex items-center px-2 py-3">
|
<div className="w-[140px] flex items-center px-2 py-3 shrink-0">
|
||||||
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer3}</p>
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] truncate">{quiz.wrongAnswer3}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[64px] flex items-center justify-center">
|
<div className="h-[64px] flex items-center justify-center">
|
||||||
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
65
src/app/admin/notices/NoticeCancelModal.tsx
Normal file
65
src/app/admin/notices/NoticeCancelModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type NoticeCancelModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지사항 작성 취소 확인 모달
|
||||||
|
*/
|
||||||
|
export default function NoticeCancelModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: NoticeCancelModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end w-[400px]">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
작성 중인 내용이 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
|
||||||
|
<p className="mb-0">정말 취소하시겠습니까?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
|
||||||
|
취소
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-[#1f2b91] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#1a2478] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
확인
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
70
src/app/admin/notices/NoticeDeleteModal.tsx
Normal file
70
src/app/admin/notices/NoticeDeleteModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type NoticeDeleteModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isDeleting?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지사항 삭제 확인 모달
|
||||||
|
*/
|
||||||
|
export default function NoticeDeleteModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
isDeleting = false,
|
||||||
|
}: NoticeDeleteModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end w-[400px]">
|
||||||
|
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
|
||||||
|
공지사항을 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-start w-full">
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
|
||||||
|
<p className="mb-0">삭제 버튼을 누르면 공지사항이 삭제됩니다.</p>
|
||||||
|
<p>정말 삭제하시겠습니까?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[8px] items-center justify-end shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
|
||||||
|
취소
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
|
||||||
|
{isDeleting ? '삭제 중...' : '삭제'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
54
src/app/admin/notices/NoticeValidationModal.tsx
Normal file
54
src/app/admin/notices/NoticeValidationModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type NoticeValidationModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지사항 작성 시 제목 또는 내용이 비어있을 때 표시되는 검증 모달
|
||||||
|
*/
|
||||||
|
export default function NoticeValidationModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: NoticeValidationModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="닫기"
|
||||||
|
className="absolute inset-0 bg-black/40 cursor-default"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="notice-validation-title"
|
||||||
|
className="relative bg-white box-border flex flex-col items-stretch justify-end gap-[32px] p-6 rounded-[8px] w-[320px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 items-center w-full">
|
||||||
|
<p
|
||||||
|
className="text-[15px] font-normal leading-[1.5] text-[#333c47] w-full"
|
||||||
|
id="notice-validation-title"
|
||||||
|
>
|
||||||
|
내용 또는 제목을 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center justify-end w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-[37px] min-w-[82px] px-2 rounded-[8px] bg-[#1f2b91] text-white text-[16px] font-semibold leading-[1.5] cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
483
src/app/admin/notices/[id]/edit/page.tsx
Normal file
483
src/app/admin/notices/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
|
||||||
|
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
||||||
|
import NoChangesModal from "@/app/admin/notices/NoChangesModal";
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminNoticeEditPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
|
||||||
|
const [originalTitle, setOriginalTitle] = useState<string>('');
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>('');
|
||||||
|
const [originalFileKey, setOriginalFileKey] = useState<string | null>(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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
|
|
||||||
|
// 공지사항 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotice() {
|
||||||
|
if (!params?.id) {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingData(true);
|
||||||
|
const response = await apiService.getNotice(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 제목 설정
|
||||||
|
const loadedTitle = data.title || '';
|
||||||
|
setTitle(loadedTitle);
|
||||||
|
setOriginalTitle(loadedTitle);
|
||||||
|
|
||||||
|
// 내용 설정 (배열이면 join, 문자열이면 그대로)
|
||||||
|
let loadedContent = '';
|
||||||
|
if (data.content) {
|
||||||
|
if (Array.isArray(data.content)) {
|
||||||
|
loadedContent = data.content.join('\n');
|
||||||
|
} else if (typeof data.content === 'string') {
|
||||||
|
loadedContent = data.content;
|
||||||
|
} else {
|
||||||
|
loadedContent = String(data.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setContent(loadedContent);
|
||||||
|
setOriginalContent(loadedContent);
|
||||||
|
|
||||||
|
// 기존 첨부파일 정보 설정
|
||||||
|
if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) {
|
||||||
|
const att = data.attachments[0];
|
||||||
|
setExistingAttachment({
|
||||||
|
name: att.name || att.fileName || att.filename || '첨부파일',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
});
|
||||||
|
// 기존 파일이 있으면 fileKey도 설정
|
||||||
|
const loadedFileKey = att.fileKey || att.key || att.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
} else if (data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setExistingAttachment({
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
});
|
||||||
|
const loadedFileKey = data.attachment.fileKey || data.attachment.key || data.attachment.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 조회 오류:', error);
|
||||||
|
alert('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||||
|
router.push('/admin/notices');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotice();
|
||||||
|
}, [params?.id, router]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push(`/admin/notices/${params.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileAttach = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
|
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// 단일 파일 업로드
|
||||||
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
|
setAttachedFile(file);
|
||||||
|
// 새 파일을 업로드하면 기존 파일 정보 제거
|
||||||
|
setExistingAttachment(null);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileRemove = () => {
|
||||||
|
setAttachedFile(null);
|
||||||
|
setExistingAttachment(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
setIsValidationModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('공지사항 ID를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 변경된 필드만 포함하는 request body 생성
|
||||||
|
const noticeData: any = {};
|
||||||
|
|
||||||
|
// 제목이 변경되었는지 확인
|
||||||
|
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: currentFileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (existingAttachment && existingAttachment.fileKey) {
|
||||||
|
// 기존 파일 유지
|
||||||
|
noticeData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: existingAttachment.fileKey,
|
||||||
|
filename: existingAttachment.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경사항이 없으면 알림 후 리턴
|
||||||
|
if (Object.keys(noticeData).length === 0) {
|
||||||
|
setIsNoChangesModalOpen(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiService.updateNotice(params.id, noticeData);
|
||||||
|
|
||||||
|
// 성공 시 공지사항 리스트로 이동 (토스트는 리스트 페이지에서 표시)
|
||||||
|
router.push('/admin/notices?updated=true');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 수정 실패:', error);
|
||||||
|
alert('공지사항 수정에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
|
setIsCancelModalOpen(true);
|
||||||
|
} else {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NoticeValidationModal
|
||||||
|
open={isValidationModalOpen}
|
||||||
|
onClose={() => setIsValidationModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<NoticeCancelModal
|
||||||
|
open={isCancelModalOpen}
|
||||||
|
onClose={() => setIsCancelModalOpen(false)}
|
||||||
|
onConfirm={handleCancelConfirm}
|
||||||
|
/>
|
||||||
|
<NoChangesModal
|
||||||
|
open={isNoChangesModalOpen}
|
||||||
|
onClose={() => setIsNoChangesModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 작성 모드 헤더 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center justify-center w-8 h-8 cursor-pointer"
|
||||||
|
aria-label="뒤로가기"
|
||||||
|
>
|
||||||
|
<BackArrowSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
공지사항 수정
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성 폼 */}
|
||||||
|
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{/* 제목 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력해 주세요."
|
||||||
|
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContent = e.target.value;
|
||||||
|
if (newContent.length <= 1000) {
|
||||||
|
setContent(newContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
|
||||||
|
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
||||||
|
{characterCount}/1000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<div className="flex items-center justify-between h-8 w-full">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
|
||||||
|
첨부 파일{' '}
|
||||||
|
<span className="font-normal">
|
||||||
|
{(attachedFile || existingAttachment) ? 1 : 0}/1
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
|
||||||
|
30MB 미만 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileAttach}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-between px-4">
|
||||||
|
{attachedFile ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{attachedFile.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : existingAttachment ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{existingAttachment.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
|
파일을 첨부해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
392
src/app/admin/notices/[id]/page.tsx
Normal file
392
src/app/admin/notices/[id]/page.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import AdminSidebar from '@/app/components/AdminSidebar';
|
||||||
|
import BackCircleSvg from '@/app/svgs/backcirclesvg';
|
||||||
|
import DownloadIcon from '@/app/svgs/downloadicon';
|
||||||
|
import PaperClipSvg from '@/app/svgs/paperclipsvg';
|
||||||
|
import apiService from '@/app/lib/apiService';
|
||||||
|
import type { Notice } from '@/app/admin/notices/mockData';
|
||||||
|
import NoticeDeleteModal from '@/app/admin/notices/NoticeDeleteModal';
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminNoticeDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotice() {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await apiService.getNotice(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotice: Notice = {
|
||||||
|
id: data.id || data.noticeId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedNotice.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedNotice.title) {
|
||||||
|
throw new Error('공지사항을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotice(transformedNotice);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('공지사항 조회 오류:', err);
|
||||||
|
setError('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotice();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (showToast) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('파일 다운로드 실패:', err);
|
||||||
|
alert('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !notice || !notice.content || notice.content.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/notices"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
공지사항 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">
|
||||||
|
{error || '공지사항을 찾을 수 없습니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 상단 타이틀 */}
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/notices"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline shrink-0"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
|
||||||
|
공지사항 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<section className="flex flex-col gap-[40px] px-[32px] py-[24px]">
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-[32px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-[8px]">
|
||||||
|
<h2 className="m-0 text-[20px] font-bold leading-[1.5] text-[#333C47]">
|
||||||
|
{notice.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-[16px] text-[13px] font-medium leading-[1.4]">
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
|
<span className="text-[#333C47]">{notice.writer}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
|
<span className="text-[#333C47]">
|
||||||
|
{notice.date.includes('T')
|
||||||
|
? new Date(notice.date).toISOString().split('T')[0]
|
||||||
|
: notice.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
|
<span className="text-[#333C47]">{notice.views.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
|
{/* 본문 및 첨부파일 */}
|
||||||
|
<div className="flex flex-col gap-[40px] p-[32px]">
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#333C47]">
|
||||||
|
{notice.content.map((p, idx) => (
|
||||||
|
<p key={idx} className="mb-0 leading-[1.5]">
|
||||||
|
{p}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 섹션 */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-[24px] w-full">
|
||||||
|
<div className="flex flex-col gap-[8px] w-full">
|
||||||
|
<div className="flex items-center gap-[12px] h-[32px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="text-[15px] font-semibold leading-[1.5] text-[#6C7682] m-0">
|
||||||
|
첨부 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3">
|
||||||
|
{attachments.map((attachment, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full"
|
||||||
|
>
|
||||||
|
<div className="size-[24px] shrink-0">
|
||||||
|
<PaperClipSvg width={24} height={24} className="text-[#333C47]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-[8px] min-w-0">
|
||||||
|
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate m-0">
|
||||||
|
{attachment.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] shrink-0 m-0">
|
||||||
|
{attachment.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||||
|
className="bg-white border border-[#8C95A1] rounded-[6px] h-[32px] flex items-center justify-center gap-[4px] px-[16px] py-[3px] shrink-0 hover:bg-[#F9FAFB] cursor-pointer"
|
||||||
|
>
|
||||||
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||||
|
다운로드
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex items-center justify-end gap-[12px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/admin/notices/${params.id}/edit`)}
|
||||||
|
className="bg-[#F1F3F5] h-[48px] rounded-[10px] px-[16px] shrink-0 min-w-[90px] flex items-center justify-center hover:bg-[#E9ECEF] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-[16px] font-semibold leading-[1.5] text-[#4C5561] text-center">
|
||||||
|
수정하기
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<NoticeDeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onClose={() => setIsDeleteModalOpen(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('공지사항 ID를 찾을 수 없습니다.');
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await apiService.deleteNotice(params.id);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setShowToast(true);
|
||||||
|
// 토스트 표시 후 목록 페이지로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/admin/notices');
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('공지사항 삭제 오류:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '공지사항 삭제에 실패했습니다.';
|
||||||
|
alert(errorMessage);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
공지사항이 삭제되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,21 +1,108 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useRef, ChangeEvent } from "react";
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
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";
|
||||||
import { MOCK_NOTICES, type Notice } from "@/app/admin/notices/mockData";
|
import { type Notice } from "@/app/admin/notices/mockData";
|
||||||
import apiService from "@/app/lib/apiService";
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
|
||||||
|
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
||||||
|
|
||||||
export default function AdminNoticesPage() {
|
export default function AdminNoticesPage() {
|
||||||
const [notices, setNotices] = useState<Notice[]>(MOCK_NOTICES);
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
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);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isValidationModalOpen, setIsValidationModalOpen] = 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 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API에서 공지사항 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotices() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await apiService.getNotices();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
noticesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
noticesArray = data.items || data.notices || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setNotices(transformedNotices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 목록 조회 오류:', error);
|
||||||
|
// 에러 발생 시 빈 배열로 설정
|
||||||
|
setNotices([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
@@ -25,6 +112,11 @@ export default function AdminNoticesPage() {
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachedFile(null);
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileAttach = () => {
|
const handleFileAttach = () => {
|
||||||
@@ -39,51 +131,127 @@ export default function AdminNoticesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
// 단일 파일 업로드
|
// 단일 파일 업로드
|
||||||
await apiService.uploadFile(file);
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
setAttachedFile(file);
|
setAttachedFile(file);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 업로드 실패:', error);
|
console.error('파일 업로드 실패:', error);
|
||||||
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim() || !content.trim()) {
|
||||||
alert('제목을 입력해주세요.');
|
setIsValidationModalOpen(true);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!content.trim()) {
|
|
||||||
alert('내용을 입력해주세요.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 공지사항 추가
|
try {
|
||||||
const newNotice: Notice = {
|
setIsLoading(true);
|
||||||
id: notices.length > 0 ? Math.max(...notices.map(n => n.id)) + 1 : 1,
|
|
||||||
|
// 공지사항 생성 API 호출
|
||||||
|
const noticeData: any = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
date: new Date().toISOString().split('T')[0],
|
content: content.trim(),
|
||||||
views: 0,
|
|
||||||
writer: '관리자', // TODO: 실제 작성자 정보 사용
|
|
||||||
content: content.split('\n'),
|
|
||||||
hasAttachment: attachedFile !== null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setNotices([newNotice, ...notices]);
|
// fileKey와 파일 정보가 있으면 attachments 배열로 포함
|
||||||
|
if (fileKey && attachedFile) {
|
||||||
|
noticeData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: fileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.createNotice(noticeData);
|
||||||
|
|
||||||
|
// API 응답 후 목록 새로고침
|
||||||
|
const fetchResponse = await apiService.getNotices();
|
||||||
|
const data = fetchResponse.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
noticesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
noticesArray = data.items || data.notices || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || !!notice.fileKey || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setNotices(transformedNotices);
|
||||||
handleBack();
|
handleBack();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 저장 실패:', error);
|
||||||
|
alert('공지사항 저장에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (title.trim() || content.trim() || attachedFile) {
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) {
|
setIsCancelModalOpen(true);
|
||||||
handleBack();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
handleBack();
|
handleBack();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 10;
|
const ITEMS_PER_PAGE = 10;
|
||||||
const sortedNotices = useMemo(() => {
|
const sortedNotices = useMemo(() => {
|
||||||
return [...notices].sort((a, b) => {
|
return [...notices].sort((a, b) => {
|
||||||
@@ -100,6 +268,16 @@ export default function AdminNoticesPage() {
|
|||||||
}, [sortedNotices, currentPage]);
|
}, [sortedNotices, currentPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<NoticeValidationModal
|
||||||
|
open={isValidationModalOpen}
|
||||||
|
onClose={() => setIsValidationModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<NoticeCancelModal
|
||||||
|
open={isCancelModalOpen}
|
||||||
|
onClose={() => setIsCancelModalOpen(false)}
|
||||||
|
onConfirm={handleCancelConfirm}
|
||||||
|
/>
|
||||||
<div className="min-h-screen flex flex-col bg-white">
|
<div className="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">
|
||||||
@@ -190,10 +368,11 @@ export default function AdminNoticesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleFileAttach}
|
onClick={handleFileAttach}
|
||||||
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0"
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
첨부
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@@ -204,13 +383,13 @@ export default function AdminNoticesPage() {
|
|||||||
accept="*/*"
|
accept="*/*"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-center">
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center px-4">
|
||||||
{attachedFile ? (
|
{attachedFile ? (
|
||||||
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
|
||||||
{attachedFile.name}
|
{attachedFile.name}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
파일을 첨부해주세요.
|
파일을 첨부해주세요.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -230,9 +409,10 @@ export default function AdminNoticesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
저장하기
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +444,7 @@ export default function AdminNoticesPage() {
|
|||||||
<div className="flex-1 pt-2 flex flex-col">
|
<div className="flex-1 pt-2 flex flex-col">
|
||||||
{notices.length === 0 ? (
|
{notices.length === 0 ? (
|
||||||
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
|
||||||
등록된 공지사항이 없습니다.
|
등록된 공지사항이 없습니다.
|
||||||
<br />
|
<br />
|
||||||
공지사항을 등록해주세요.
|
공지사항을 등록해주세요.
|
||||||
@@ -298,7 +478,8 @@ export default function AdminNoticesPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={notice.id}
|
key={notice.id}
|
||||||
className="h-12 hover:bg-[#F5F7FF] transition-colors"
|
onClick={() => router.push(`/admin/notices/${notice.id}`)}
|
||||||
|
className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
|
||||||
{noticeNumber}
|
{noticeNumber}
|
||||||
@@ -307,7 +488,7 @@ export default function AdminNoticesPage() {
|
|||||||
{notice.title}
|
{notice.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
{notice.date}
|
{formatDate(notice.date)}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
{notice.views.toLocaleString()}
|
{notice.views.toLocaleString()}
|
||||||
@@ -416,6 +597,24 @@ 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
483
src/app/admin/resources/[id]/edit/page.tsx
Normal file
483
src/app/admin/resources/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||||
|
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||||
|
import CloseXOSvg from "@/app/svgs/closexo";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
|
||||||
|
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
|
||||||
|
import NoChangesModal from "@/app/admin/notices/NoChangesModal";
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminResourceEditPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
|
||||||
|
const [originalTitle, setOriginalTitle] = useState<string>('');
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>('');
|
||||||
|
const [originalFileKey, setOriginalFileKey] = useState<string | null>(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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
|
|
||||||
|
// 학습 자료 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResource() {
|
||||||
|
if (!params?.id) {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingData(true);
|
||||||
|
const response = await apiService.getLibraryItem(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 제목 설정
|
||||||
|
const loadedTitle = data.title || '';
|
||||||
|
setTitle(loadedTitle);
|
||||||
|
setOriginalTitle(loadedTitle);
|
||||||
|
|
||||||
|
// 내용 설정 (배열이면 join, 문자열이면 그대로)
|
||||||
|
let loadedContent = '';
|
||||||
|
if (data.content) {
|
||||||
|
if (Array.isArray(data.content)) {
|
||||||
|
loadedContent = data.content.join('\n');
|
||||||
|
} else if (typeof data.content === 'string') {
|
||||||
|
loadedContent = data.content;
|
||||||
|
} else {
|
||||||
|
loadedContent = String(data.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setContent(loadedContent);
|
||||||
|
setOriginalContent(loadedContent);
|
||||||
|
|
||||||
|
// 기존 첨부파일 정보 설정
|
||||||
|
if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) {
|
||||||
|
const att = data.attachments[0];
|
||||||
|
setExistingAttachment({
|
||||||
|
name: att.name || att.fileName || att.filename || '첨부파일',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
});
|
||||||
|
// 기존 파일이 있으면 fileKey도 설정
|
||||||
|
const loadedFileKey = att.fileKey || att.key || att.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
} else if (data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setExistingAttachment({
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
});
|
||||||
|
const loadedFileKey = data.attachment.fileKey || data.attachment.key || data.attachment.fileId;
|
||||||
|
if (loadedFileKey) {
|
||||||
|
setFileKey(loadedFileKey);
|
||||||
|
setOriginalFileKey(loadedFileKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 조회 오류:', error);
|
||||||
|
alert('학습 자료를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
router.push('/admin/resources');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResource();
|
||||||
|
}, [params?.id, router]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push(`/admin/resources/${params.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileAttach = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 30 * 1024 * 1024) {
|
||||||
|
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// 단일 파일 업로드
|
||||||
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
|
setAttachedFile(file);
|
||||||
|
// 새 파일을 업로드하면 기존 파일 정보 제거
|
||||||
|
setExistingAttachment(null);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 실패:', error);
|
||||||
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileRemove = () => {
|
||||||
|
setAttachedFile(null);
|
||||||
|
setExistingAttachment(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
setIsValidationModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('학습 자료 ID를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 변경된 필드만 포함하는 request body 생성
|
||||||
|
const resourceData: any = {};
|
||||||
|
|
||||||
|
// 제목이 변경되었는지 확인
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (trimmedTitle !== originalTitle) {
|
||||||
|
resourceData.title = trimmedTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내용이 변경되었는지 확인
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (trimmedContent !== originalContent) {
|
||||||
|
resourceData.content = trimmedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 변경사항 확인
|
||||||
|
const currentFileKey = fileKey;
|
||||||
|
const hasFileChanged = currentFileKey !== originalFileKey;
|
||||||
|
|
||||||
|
// 파일이 삭제된 경우 (기존에 파일이 있었는데 지금 없음)
|
||||||
|
if (originalFileKey && !currentFileKey) {
|
||||||
|
resourceData.attachments = [];
|
||||||
|
}
|
||||||
|
// 파일이 변경되었거나 새로 추가된 경우
|
||||||
|
else if (hasFileChanged && currentFileKey) {
|
||||||
|
if (attachedFile) {
|
||||||
|
// 새로 업로드한 파일
|
||||||
|
resourceData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: currentFileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (existingAttachment && existingAttachment.fileKey) {
|
||||||
|
// 기존 파일 유지
|
||||||
|
resourceData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: existingAttachment.fileKey,
|
||||||
|
filename: existingAttachment.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경사항이 없으면 알림 후 리턴
|
||||||
|
if (Object.keys(resourceData).length === 0) {
|
||||||
|
setIsNoChangesModalOpen(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiService.updateLibraryItem(params.id, resourceData);
|
||||||
|
|
||||||
|
// 성공 시 학습 자료 리스트로 이동 (토스트는 리스트 페이지에서 표시)
|
||||||
|
router.push('/admin/resources?updated=true');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 수정 실패:', error);
|
||||||
|
alert('학습 자료 수정에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
|
setIsCancelModalOpen(true);
|
||||||
|
} else {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setIsCancelModalOpen(false);
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NoticeValidationModal
|
||||||
|
open={isValidationModalOpen}
|
||||||
|
onClose={() => setIsValidationModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<NoticeCancelModal
|
||||||
|
open={isCancelModalOpen}
|
||||||
|
onClose={() => setIsCancelModalOpen(false)}
|
||||||
|
onConfirm={handleCancelConfirm}
|
||||||
|
/>
|
||||||
|
<NoChangesModal
|
||||||
|
open={isNoChangesModalOpen}
|
||||||
|
onClose={() => setIsNoChangesModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
{/* 메인 레이아웃 */}
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col px-8">
|
||||||
|
{/* 작성 모드 헤더 */}
|
||||||
|
<div className="h-[100px] flex items-center">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center justify-center w-8 h-8 cursor-pointer"
|
||||||
|
aria-label="뒤로가기"
|
||||||
|
>
|
||||||
|
<BackArrowSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
학습 자료 수정
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성 폼 */}
|
||||||
|
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{/* 제목 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력해 주세요."
|
||||||
|
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 입력 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newContent = e.target.value;
|
||||||
|
if (newContent.length <= 1000) {
|
||||||
|
setContent(newContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
|
||||||
|
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
|
||||||
|
{characterCount}/1000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-center w-full">
|
||||||
|
<div className="flex items-center justify-between h-8 w-full">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
|
||||||
|
첨부 파일{' '}
|
||||||
|
<span className="font-normal">
|
||||||
|
{(attachedFile || existingAttachment) ? 1 : 0}/1
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
|
||||||
|
30MB 미만 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileAttach}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-between px-4">
|
||||||
|
{attachedFile ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{attachedFile.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : existingAttachment ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
|
||||||
|
{existingAttachment.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileRemove}
|
||||||
|
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
|
||||||
|
aria-label="파일 삭제"
|
||||||
|
>
|
||||||
|
<CloseXOSvg />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
|
파일을 첨부해주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
390
src/app/admin/resources/[id]/page.tsx
Normal file
390
src/app/admin/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import AdminSidebar from '@/app/components/AdminSidebar';
|
||||||
|
import BackCircleSvg from '@/app/svgs/backcirclesvg';
|
||||||
|
import DownloadIcon from '@/app/svgs/downloadicon';
|
||||||
|
import PaperClipSvg from '@/app/svgs/paperclipsvg';
|
||||||
|
import apiService from '@/app/lib/apiService';
|
||||||
|
import type { Resource } from '@/app/admin/resources/mockData';
|
||||||
|
import NoticeDeleteModal from '@/app/admin/notices/NoticeDeleteModal';
|
||||||
|
|
||||||
|
type Attachment = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminResourceDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [resource, setResource] = useState<Resource | null>(null);
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResource() {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await apiService.getLibraryItem(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResource: Resource = {
|
||||||
|
id: data.id || data.resourceId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedResource.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedResource.title) {
|
||||||
|
throw new Error('학습 자료를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setResource(transformedResource);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('학습 자료 조회 오류:', err);
|
||||||
|
setError('학습 자료를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResource();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (showToast) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('파일 다운로드 실패:', err);
|
||||||
|
alert('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource || !resource.content || resource.content.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/resources"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
학습 자료 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">
|
||||||
|
{error || '학습 자료를 찾을 수 없습니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
|
<div className="flex flex-1 min-h-0 justify-center">
|
||||||
|
<div className="w-[1440px] flex min-h-0">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<div className="flex">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<main className="w-[1120px] bg-white">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 상단 타이틀 */}
|
||||||
|
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||||
|
<Link
|
||||||
|
href="/admin/resources"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline shrink-0"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
|
||||||
|
학습 자료 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<section className="flex flex-col gap-[40px] px-[32px] py-[24px]">
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-[32px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-[8px]">
|
||||||
|
<h2 className="m-0 text-[20px] font-bold leading-[1.5] text-[#333C47]">
|
||||||
|
{resource.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-[16px] text-[13px] font-medium leading-[1.4]">
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
|
<span className="text-[#333C47]">{resource.writer}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
|
<span className="text-[#333C47]">
|
||||||
|
{resource.date.includes('T')
|
||||||
|
? new Date(resource.date).toISOString().split('T')[0]
|
||||||
|
: resource.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||||
|
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
|
<span className="text-[#333C47]">{resource.views.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
|
{/* 본문 및 첨부파일 */}
|
||||||
|
<div className="flex flex-col gap-[40px] p-[32px]">
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="text-[15px] font-normal leading-[1.5] text-[#333C47]">
|
||||||
|
{resource.content.map((p, idx) => (
|
||||||
|
<p key={idx} className="mb-0 leading-[1.5]">
|
||||||
|
{p}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 섹션 */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-[24px] w-full">
|
||||||
|
<div className="flex flex-col gap-[8px] w-full">
|
||||||
|
<div className="flex items-center gap-[12px] h-[32px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="text-[15px] font-semibold leading-[1.5] text-[#6C7682] m-0">
|
||||||
|
첨부 파일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{attachments.map((attachment, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full"
|
||||||
|
>
|
||||||
|
<div className="size-[24px] shrink-0">
|
||||||
|
<PaperClipSvg width={24} height={24} className="text-[#333C47]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-[8px] min-w-0">
|
||||||
|
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate m-0">
|
||||||
|
{attachment.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] shrink-0 m-0">
|
||||||
|
{attachment.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||||
|
className="bg-white border border-[#8C95A1] rounded-[6px] h-[32px] flex items-center justify-center gap-[4px] px-[16px] py-[3px] shrink-0 hover:bg-[#F9FAFB] cursor-pointer"
|
||||||
|
>
|
||||||
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||||
|
다운로드
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex items-center justify-end gap-[12px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/admin/resources/${params.id}/edit`)}
|
||||||
|
className="bg-[#F1F3F5] h-[48px] rounded-[10px] px-[16px] shrink-0 min-w-[90px] flex items-center justify-center hover:bg-[#E9ECEF] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-[16px] font-semibold leading-[1.5] text-[#4C5561] text-center">
|
||||||
|
수정하기
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<NoticeDeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onClose={() => setIsDeleteModalOpen(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!params?.id) {
|
||||||
|
alert('학습 자료 ID를 찾을 수 없습니다.');
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await apiService.deleteLibraryItem(params.id);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setShowToast(true);
|
||||||
|
// 토스트 표시 후 목록 페이지로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/admin/resources');
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('학습 자료 삭제 오류:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '학습 자료 삭제에 실패했습니다.';
|
||||||
|
alert(errorMessage);
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 완료 토스트 */}
|
||||||
|
{showToast && (
|
||||||
|
<div className="fixed right-[60px] bottom-[60px] z-50">
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
|
||||||
|
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
|
||||||
|
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
|
||||||
|
학습 자료가 삭제되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,21 +1,104 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useRef, ChangeEvent } from "react";
|
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||||
|
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";
|
||||||
import { MOCK_RESOURCES, type Resource } from "@/app/admin/resources/mockData";
|
import { type Resource } from "@/app/admin/resources/mockData";
|
||||||
import apiService from "@/app/lib/apiService";
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
export default function AdminResourcesPage() {
|
export default function AdminResourcesPage() {
|
||||||
const [resources, setResources] = useState<Resource[]>(MOCK_RESOURCES);
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [isWritingMode, setIsWritingMode] = useState(false);
|
const [isWritingMode, setIsWritingMode] = useState(false);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||||
|
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showToast, setShowToast] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API에서 학습 자료 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResources() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await apiService.getLibrary();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let resourcesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
resourcesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
resourcesArray = data.items || data.resources || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({
|
||||||
|
id: resource.id || resource.resourceId || 0,
|
||||||
|
title: resource.title || '',
|
||||||
|
date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: resource.views || resource.viewCount || 0,
|
||||||
|
writer: resource.writer || resource.author || resource.createdBy || '관리자',
|
||||||
|
content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined,
|
||||||
|
hasAttachment: resource.hasAttachment || resource.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(transformedResources);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 목록 조회 오류:', error);
|
||||||
|
// 에러 발생 시 빈 배열로 설정
|
||||||
|
setResources([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResources();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 완료 쿼리 파라미터 확인 및 토스트 표시
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('updated') === 'true') {
|
||||||
|
setShowToast(true);
|
||||||
|
// URL에서 쿼리 파라미터 제거
|
||||||
|
router.replace('/admin/resources');
|
||||||
|
// 토스트 자동 닫기
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowToast(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
const totalCount = useMemo(() => resources.length, [resources]);
|
const totalCount = useMemo(() => resources.length, [resources]);
|
||||||
|
|
||||||
const characterCount = useMemo(() => content.length, [content]);
|
const characterCount = useMemo(() => content.length, [content]);
|
||||||
@@ -25,6 +108,11 @@ export default function AdminResourcesPage() {
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachedFile(null);
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileAttach = () => {
|
const handleFileAttach = () => {
|
||||||
@@ -39,43 +127,116 @@ export default function AdminResourcesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
// 단일 파일 업로드
|
// 단일 파일 업로드
|
||||||
await apiService.uploadFile(file);
|
const uploadResponse = await apiService.uploadFile(file);
|
||||||
|
|
||||||
|
// 응답에서 fileKey 추출
|
||||||
|
let extractedFileKey: string | null = null;
|
||||||
|
if (uploadResponse.data?.fileKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.key) {
|
||||||
|
extractedFileKey = uploadResponse.data.key;
|
||||||
|
} else if (uploadResponse.data?.id) {
|
||||||
|
extractedFileKey = uploadResponse.data.id;
|
||||||
|
} else if (uploadResponse.data?.imageKey) {
|
||||||
|
extractedFileKey = uploadResponse.data.imageKey;
|
||||||
|
} else if (uploadResponse.data?.fileId) {
|
||||||
|
extractedFileKey = uploadResponse.data.fileId;
|
||||||
|
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||||
|
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||||
|
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||||
|
const result = uploadResponse.data.results[0];
|
||||||
|
if (result.ok && result.fileKey) {
|
||||||
|
extractedFileKey = result.fileKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedFileKey) {
|
||||||
|
setFileKey(extractedFileKey);
|
||||||
setAttachedFile(file);
|
setAttachedFile(file);
|
||||||
|
} else {
|
||||||
|
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 업로드 실패:', error);
|
console.error('파일 업로드 실패:', error);
|
||||||
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
setAttachedFile(null);
|
||||||
|
setFileKey(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// 파일 입력 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim() || !content.trim()) {
|
||||||
alert('제목을 입력해주세요.');
|
alert('제목과 내용을 입력해주세요.');
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!content.trim()) {
|
|
||||||
alert('내용을 입력해주세요.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 학습 자료 추가
|
try {
|
||||||
const newResource: Resource = {
|
setIsLoading(true);
|
||||||
id: resources.length > 0 ? Math.max(...resources.map(r => r.id)) + 1 : 1,
|
|
||||||
|
// 학습 자료 생성 API 호출
|
||||||
|
const resourceData: any = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
date: new Date().toISOString().split('T')[0],
|
content: content.trim(),
|
||||||
views: 0,
|
|
||||||
writer: '관리자', // TODO: 실제 작성자 정보 사용
|
|
||||||
content: content.split('\n'),
|
|
||||||
hasAttachment: attachedFile !== null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setResources([newResource, ...resources]);
|
// fileKey와 파일 정보가 있으면 attachments 배열로 포함
|
||||||
|
if (fileKey && attachedFile) {
|
||||||
|
resourceData.attachments = [
|
||||||
|
{
|
||||||
|
fileKey: fileKey,
|
||||||
|
filename: attachedFile.name,
|
||||||
|
mimeType: attachedFile.type || 'application/octet-stream',
|
||||||
|
size: attachedFile.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.createLibraryItem(resourceData);
|
||||||
|
|
||||||
|
// API 응답 후 목록 새로고침
|
||||||
|
const fetchResponse = await apiService.getLibrary();
|
||||||
|
const data = fetchResponse.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리
|
||||||
|
let resourcesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
resourcesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
resourcesArray = data.items || data.resources || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({
|
||||||
|
id: resource.id || resource.resourceId || 0,
|
||||||
|
title: resource.title || '',
|
||||||
|
date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: resource.views || resource.viewCount || 0,
|
||||||
|
writer: resource.writer || resource.author || resource.createdBy || '관리자',
|
||||||
|
content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined,
|
||||||
|
hasAttachment: resource.hasAttachment || resource.attachment || !!resource.fileKey || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(transformedResources);
|
||||||
handleBack();
|
handleBack();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 저장 실패:', error);
|
||||||
|
alert('학습 자료 저장에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (title.trim() || content.trim() || attachedFile) {
|
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||||
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) {
|
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) {
|
||||||
handleBack();
|
handleBack();
|
||||||
}
|
}
|
||||||
@@ -190,10 +351,11 @@ export default function AdminResourcesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleFileAttach}
|
onClick={handleFileAttach}
|
||||||
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0"
|
disabled={isLoading}
|
||||||
|
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
첨부
|
{isLoading ? '업로드 중...' : '첨부'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@@ -204,13 +366,13 @@ export default function AdminResourcesPage() {
|
|||||||
accept="*/*"
|
accept="*/*"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-center">
|
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center px-4">
|
||||||
{attachedFile ? (
|
{attachedFile ? (
|
||||||
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
|
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
|
||||||
{attachedFile.name}
|
{attachedFile.name}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
|
||||||
파일을 첨부해주세요.
|
파일을 첨부해주세요.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -230,9 +392,10 @@ export default function AdminResourcesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
|
disabled={isLoading}
|
||||||
|
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
저장하기
|
{isLoading ? '저장 중...' : '저장하기'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +427,7 @@ export default function AdminResourcesPage() {
|
|||||||
<div className="flex-1 pt-2 flex flex-col">
|
<div className="flex-1 pt-2 flex flex-col">
|
||||||
{resources.length === 0 ? (
|
{resources.length === 0 ? (
|
||||||
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
|
||||||
등록된 학습 자료가 없습니다.
|
등록된 학습 자료가 없습니다.
|
||||||
<br />
|
<br />
|
||||||
학습 자료를 등록해주세요.
|
학습 자료를 등록해주세요.
|
||||||
@@ -298,7 +461,8 @@ export default function AdminResourcesPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={resource.id}
|
key={resource.id}
|
||||||
className="h-12 hover:bg-[#F5F7FF] transition-colors"
|
onClick={() => router.push(`/admin/resources/${resource.id}`)}
|
||||||
|
className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
|
||||||
{resourceNumber}
|
{resourceNumber}
|
||||||
@@ -307,7 +471,7 @@ export default function AdminResourcesPage() {
|
|||||||
{resource.title}
|
{resource.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
{resource.date}
|
{formatDate(resource.date)}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||||
{resource.views.toLocaleString()}
|
{resource.views.toLocaleString()}
|
||||||
@@ -415,6 +579,23 @@ export default function AdminResourcesPage() {
|
|||||||
</main>
|
</main>
|
||||||
</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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const ADMIN_SIDEBAR_ITEMS: NavItem[] = [
|
|||||||
{ label: "공지사항", href: "/admin/notices" },
|
{ label: "공지사항", href: "/admin/notices" },
|
||||||
{ label: "학습 자료실", href: "/admin/resources" },
|
{ label: "학습 자료실", href: "/admin/resources" },
|
||||||
{ label: "로그/접속 기록", href: "/admin/logs" },
|
{ label: "로그/접속 기록", href: "/admin/logs" },
|
||||||
|
{ label: "배너 관리", href: "/admin/banner" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminSidebar() {
|
export default function AdminSidebar() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
|
|
||||||
const HIDE_FOOTER_PREFIXES = ["/pages"];
|
const HIDE_FOOTER_PREFIXES = ["/pages", "/login"];
|
||||||
|
|
||||||
export default function FooterVisibility() {
|
export default function FooterVisibility() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import apiService from '../../lib/apiService';
|
import apiService from '../../lib/apiService';
|
||||||
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
|
|
||||||
|
const imgPlay = '/imgs/play.svg';
|
||||||
|
const imgMusicAudioPlay = '/imgs/music-audio-play.svg';
|
||||||
|
|
||||||
type Lesson = {
|
type Lesson = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +33,7 @@ export default function CourseDetailPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [course, setCourse] = useState<CourseDetail | null>(null);
|
const [course, setCourse] = useState<CourseDetail | null>(null);
|
||||||
|
const [lectures, setLectures] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -39,32 +44,103 @@ export default function CourseDetailPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await apiService.getLecture(params.id as string);
|
|
||||||
|
let data: any = null;
|
||||||
|
const subjectId = String(params.id);
|
||||||
|
|
||||||
|
// getSubjects로 과목 정보 가져오기
|
||||||
|
try {
|
||||||
|
const subjectsResponse = await apiService.getSubjects();
|
||||||
|
let subjectsData: any[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(subjectsResponse.data)) {
|
||||||
|
subjectsData = subjectsResponse.data;
|
||||||
|
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
|
||||||
|
subjectsData = subjectsResponse.data.items ||
|
||||||
|
subjectsResponse.data.courses ||
|
||||||
|
subjectsResponse.data.data ||
|
||||||
|
subjectsResponse.data.list ||
|
||||||
|
subjectsResponse.data.subjects ||
|
||||||
|
subjectsResponse.data.subjectList ||
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID로 과목 찾기
|
||||||
|
data = subjectsData.find((s: any) => String(s.id || s.subjectId) === subjectId);
|
||||||
|
} catch (subjectsErr) {
|
||||||
|
console.error('getSubjects 실패:', subjectsErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('강좌를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 썸네일 이미지 가져오기
|
||||||
|
let thumbnail = '/imgs/talk.png';
|
||||||
|
if (data.imageKey) {
|
||||||
|
try {
|
||||||
|
const imageUrl = await apiService.getFile(data.imageKey);
|
||||||
|
if (imageUrl) {
|
||||||
|
thumbnail = imageUrl;
|
||||||
|
}
|
||||||
|
} catch (imgErr) {
|
||||||
|
console.error('이미지 다운로드 실패:', imgErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLectures로 모든 lecture 가져오기
|
||||||
|
let allLectures: any[] = [];
|
||||||
|
try {
|
||||||
|
const lecturesResponse = await apiService.getLectures();
|
||||||
|
if (Array.isArray(lecturesResponse.data)) {
|
||||||
|
allLectures = lecturesResponse.data;
|
||||||
|
} else if (lecturesResponse.data && typeof lecturesResponse.data === 'object') {
|
||||||
|
allLectures = lecturesResponse.data.items ||
|
||||||
|
lecturesResponse.data.lectures ||
|
||||||
|
lecturesResponse.data.data ||
|
||||||
|
lecturesResponse.data.list ||
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// subjectId로 필터링
|
||||||
|
const filteredLectures = allLectures.filter((lecture: any) =>
|
||||||
|
String(lecture.subjectId || lecture.subject_id) === subjectId
|
||||||
|
);
|
||||||
|
|
||||||
|
setLectures(filteredLectures);
|
||||||
|
|
||||||
// API 응답 데이터를 CourseDetail 타입으로 변환
|
// API 응답 데이터를 CourseDetail 타입으로 변환
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
// API 응답 구조에 맞게 데이터 매핑
|
|
||||||
// 실제 API 응답 구조에 따라 조정 필요
|
|
||||||
const courseDetail: CourseDetail = {
|
const courseDetail: CourseDetail = {
|
||||||
id: String(data.id || params.id),
|
id: String(data.id || params.id),
|
||||||
status: data.status || "수강 예정",
|
status: data.status || "수강 예정",
|
||||||
title: data.title || data.lectureName || '',
|
title: data.title || data.lectureName || data.subjectName || '',
|
||||||
goal: data.objective || data.goal || '',
|
goal: data.objective || data.goal || '',
|
||||||
method: data.method || '',
|
method: data.method || '',
|
||||||
summary: data.summary || `VOD · 총 ${data.lessons?.length || 0}강`,
|
summary: data.summary || `VOD · 총 ${filteredLectures.length || 0}강`,
|
||||||
submitSummary: data.submitSummary || '',
|
submitSummary: data.submitSummary || '',
|
||||||
thumbnail: data.thumbnail || data.imageKey || data.imageUrl || '/imgs/talk.png',
|
thumbnail: thumbnail,
|
||||||
lessons: (data.lessons || []).map((lesson: any, index: number) => ({
|
lessons: [],
|
||||||
id: String(lesson.id || lesson.lessonId || index + 1),
|
|
||||||
title: `${index + 1}. ${lesson.title || lesson.lessonName || ''}`,
|
|
||||||
duration: lesson.duration || '00:00',
|
|
||||||
state: lesson.isCompleted ? "제출완료" : "제출대기",
|
|
||||||
action: lesson.isCompleted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기"),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setCourse(courseDetail);
|
setCourse(courseDetail);
|
||||||
|
} catch (lecturesErr) {
|
||||||
|
console.error('getLectures 실패:', lecturesErr);
|
||||||
|
|
||||||
|
// API 응답 데이터를 CourseDetail 타입으로 변환 (lecture 없이)
|
||||||
|
const courseDetail: CourseDetail = {
|
||||||
|
id: String(data.id || params.id),
|
||||||
|
status: data.status || "수강 예정",
|
||||||
|
title: data.title || data.lectureName || data.subjectName || '',
|
||||||
|
goal: data.objective || data.goal || '',
|
||||||
|
method: data.method || '',
|
||||||
|
summary: data.summary || `VOD · 총 0강`,
|
||||||
|
submitSummary: data.submitSummary || '',
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
lessons: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setCourse(courseDetail);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('강좌 조회 실패:', err);
|
console.error('강좌 조회 실패:', err);
|
||||||
setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
|
setError(err instanceof Error ? err.message : '강좌를 불러오는데 실패했습니다.');
|
||||||
@@ -78,11 +154,19 @@ export default function CourseDetailPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col items-center">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
</div>
|
</div>
|
||||||
<section className="px-8 pb-20">
|
<section className="w-full max-w-[1440px] px-8 pb-20">
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<p className="text-[16px] text-[#8c95a1]">로딩 중...</p>
|
<p className="text-[16px] text-[#8c95a1]">로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,11 +177,19 @@ export default function CourseDetailPage() {
|
|||||||
|
|
||||||
if (error || !course) {
|
if (error || !course) {
|
||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col items-center">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
</div>
|
</div>
|
||||||
<section className="px-8 pb-20">
|
<section className="w-full max-w-[1440px] px-8 pb-20">
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
|
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
|
||||||
<button
|
<button
|
||||||
@@ -114,96 +206,180 @@ export default function CourseDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col items-center">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
{/* 헤더 */}
|
||||||
|
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</button>
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="px-8 pb-20">
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6">
|
<section className="w-full max-w-[1440px] px-8 pb-[80px] pt-[24px]">
|
||||||
{/* 상단 소개 카드 */}
|
{/* 상단 정보 카드 */}
|
||||||
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
|
<div className="bg-[#f8f9fa] box-border flex gap-[24px] items-start p-[24px] rounded-[8px] w-full">
|
||||||
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
|
{/* 이미지 컨테이너 */}
|
||||||
|
<div className="overflow-clip relative rounded-[4px] shrink-0 w-[220.5px] h-[159px]">
|
||||||
<Image
|
<Image
|
||||||
src={course.thumbnail}
|
src={course.thumbnail}
|
||||||
alt={course.title}
|
alt={course.title}
|
||||||
fill
|
fill
|
||||||
sizes="292px"
|
sizes="220.5px"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex h-[27px] items-center gap-2">
|
{/* 교육과정 정보 */}
|
||||||
<span className="h-[20px] rounded-[4px] bg-[#e5f5ec] px-1.5 text-[13px] font-semibold leading-[1.4] text-[#0c9d61]">
|
<div className="basis-0 flex flex-col gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
|
||||||
{course.status}
|
{/* 제목 영역 */}
|
||||||
</span>
|
<div className="flex gap-[8px] h-[27px] items-center w-full">
|
||||||
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{course.title}</h2>
|
<div className="bg-[#e5f5ec] box-border flex h-[20px] items-center justify-center px-[4px] py-0 rounded-[4px] shrink-0">
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#0c9d61] text-[13px] leading-[1.4]">{course.status}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1">
|
<h2 className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[18px] leading-[1.5]">{course.title}</h2>
|
||||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
</div>
|
||||||
<span className="font-medium">학습 목표:</span> {course.goal}
|
|
||||||
|
{/* 학습 목표 및 방법 */}
|
||||||
|
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||||
|
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5] mb-0">
|
||||||
|
<span className="font-['Pretendard:Medium',sans-serif]">학습 목표:</span>
|
||||||
|
<span>{` ${course.goal}`}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5]">
|
||||||
<span className="font-medium">학습 방법:</span> {course.method}
|
<span className="font-['Pretendard:Medium',sans-serif]">학습 방법:</span>
|
||||||
|
<span>{` ${course.method}`}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
|
|
||||||
<span>{course.summary}</span>
|
{/* 통계 정보 */}
|
||||||
{course.submitSummary && <span>{course.submitSummary}</span>}
|
<div className="flex gap-[4px] items-center w-full">
|
||||||
|
<div className="flex gap-[20px] items-center">
|
||||||
|
{/* VOD 정보 */}
|
||||||
|
<div className="flex gap-[4px] items-center">
|
||||||
|
<div className="relative shrink-0 size-[16px]">
|
||||||
|
<img src={imgPlay} alt="" className="block max-w-none size-full" />
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 학습 제출 정보 */}
|
||||||
|
{course.submitSummary && (
|
||||||
|
<div className="flex gap-[4px] items-center">
|
||||||
|
<div className="relative shrink-0 size-[16px]">
|
||||||
|
<img src={imgMusicAudioPlay} alt="" className="block max-w-none size-full" />
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.submitSummary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 차시 리스트 */}
|
{/* Lecture 리스트 */}
|
||||||
<div className="mt-6 space-y-2">
|
<div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
|
||||||
{course.lessons.map((l) => {
|
{lectures.length > 0 ? (
|
||||||
const isSubmitted = l.state === "제출완료";
|
lectures.map((lecture: any, index: number) => {
|
||||||
const submitBtnStyle =
|
const isSubmitted = false; // TODO: 진행률 API에서 가져와야 함
|
||||||
l.state === "제출완료"
|
const action = isSubmitted ? "복습하기" : (index === 0 ? "수강하기" : "이어서 수강하기");
|
||||||
? "border border-transparent text-[#384fbf]"
|
const submitBtnBorder = isSubmitted
|
||||||
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
? "border-transparent"
|
||||||
|
: (action === "이어서 수강하기" || action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||||
|
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (action === "이어서 수강하기" || action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
|
||||||
const rightBtnStyle =
|
const rightBtnStyle =
|
||||||
l.action === "이어서 수강하기"
|
action === "이어서 수강하기" || action === "수강하기"
|
||||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
|
||||||
: l.action === "수강하기"
|
|
||||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
: "bg-[#f1f3f5] text-[#4c5561]";
|
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
|
<div key={lecture.id || lecture.lectureId || index} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
|
||||||
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
|
<div className="box-border flex gap-[16px] items-center overflow-clip px-[24px] py-[16px] rounded-[inherit] w-full">
|
||||||
<div className="min-w-0">
|
<div className="basis-0 flex grow h-[46px] items-center justify-between min-h-px min-w-px relative shrink-0">
|
||||||
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
|
{/* Lecture 정보 */}
|
||||||
<div className="mt-1 flex items-center gap-3">
|
<div className="basis-0 grow min-h-px min-w-px relative shrink-0">
|
||||||
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
|
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[16px] leading-[1.5]">
|
||||||
|
{index + 1}. {lecture.title || lecture.lectureName || ''}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-[12px] items-center w-full">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4] w-[40px]">
|
||||||
|
{lecture.duration || '00:00'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="flex gap-[8px] items-center">
|
||||||
|
{/* 학습 제출 버튼 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={[
|
className={[
|
||||||
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
"bg-white box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
|
||||||
"bg-white",
|
"border border-solid",
|
||||||
submitBtnStyle,
|
submitBtnBorder,
|
||||||
|
submitBtnText,
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"}
|
{isSubmitted ? (
|
||||||
|
<div className="flex gap-[4px] h-[18px] items-center">
|
||||||
|
<div className="relative shrink-0 size-[12px]">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 3L4.5 8.5L2 6" stroke="#384fbf" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] text-[#384fbf] text-[13px] leading-[1.4]">학습 제출 완료</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className={[
|
||||||
|
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||||
|
submitBtnText,
|
||||||
|
].join(" ")}>학습 제출 하기</p>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 수강/복습 버튼 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const lectureId = lecture.id || lecture.lectureId;
|
||||||
|
if (lectureId) {
|
||||||
|
router.push(`/menu/courses/lessons/${lectureId}/start`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={[
|
className={[
|
||||||
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0 transition-colors",
|
||||||
rightBtnStyle,
|
rightBtnStyle,
|
||||||
|
action === "이어서 수강하기" || action === "수강하기"
|
||||||
|
? "hover:bg-[#d0d9ff]"
|
||||||
|
: "hover:bg-[#e5e8eb]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{l.action}
|
<p className={[
|
||||||
|
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||||
|
action === "이어서 수강하기" || action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]",
|
||||||
|
].join(" ")}>{action}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8 w-full">
|
||||||
|
<p className="text-[14px] text-[#8c95a1]">등록된 강의가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
|
import apiService from '../lib/apiService';
|
||||||
|
|
||||||
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
|
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
|
||||||
// - 프로젝트는 Tailwind v4(@import "tailwindcss")를 사용하므로 클래스 그대로 적용
|
// - 프로젝트는 Tailwind v4(@import "tailwindcss")를 사용하므로 클래스 그대로 적용
|
||||||
@@ -10,10 +11,21 @@ import ChevronDownSvg from '../svgs/chevrondownsvg';
|
|||||||
// - 카드 구성: 섬네일(rounded 8px) + 상태 태그 + 제목 + 메타(재생 아이콘 + 텍스트)
|
// - 카드 구성: 섬네일(rounded 8px) + 상태 태그 + 제목 + 메타(재생 아이콘 + 텍스트)
|
||||||
|
|
||||||
const imgPlay = '/imgs/play.svg'; // public/imgs/play.svg
|
const imgPlay = '/imgs/play.svg'; // public/imgs/play.svg
|
||||||
const imgThumbA = '/imgs/thumb-a.png'; // public/imgs/thumb-a.png
|
|
||||||
const imgThumbB = '/imgs/thumb-b.png'; // public/imgs/thumb-b.png
|
interface Subject {
|
||||||
const imgThumbC = '/imgs/thumb-c.png'; // public/imgs/thumb-c.png
|
id: string | number;
|
||||||
const imgThumbD = '/imgs/thumb-d.png'; // public/imgs/thumb-d.png
|
title: string;
|
||||||
|
imageKey?: string;
|
||||||
|
instructor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Course {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
inProgress?: boolean;
|
||||||
|
meta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function ColorfulTag({ text }: { text: string }) {
|
function ColorfulTag({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -23,37 +35,114 @@ function ColorfulTag({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Course = { id: string; title: string; image: string; inProgress?: boolean };
|
|
||||||
|
|
||||||
export default function CourseListPage() {
|
export default function CourseListPage() {
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const base: Omit<Course, 'id'>[] = [
|
// 과목 리스트 가져오기
|
||||||
{ title: '원자로 운전 및 계통', image: imgThumbA, inProgress: true },
|
useEffect(() => {
|
||||||
{ title: '핵연료', image: imgThumbB },
|
let isMounted = true;
|
||||||
{ title: '방사선 안전', image: imgThumbC },
|
|
||||||
{ title: '방사선 폐기물', image: imgThumbD },
|
async function fetchSubjects() {
|
||||||
{ title: '원자로 운전 및 계통', image: imgThumbA },
|
try {
|
||||||
{ title: '핵연료', image: imgThumbB },
|
setLoading(true);
|
||||||
{ title: '방사선 안전', image: imgThumbC },
|
const response = await apiService.getSubjects();
|
||||||
];
|
|
||||||
const courses: Course[] = Array.from({ length: 28 }, (_, i) => {
|
if (response.status !== 200 || !response.data) {
|
||||||
const item = base[i % base.length];
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 데이터 구조 확인 및 배열 추출
|
||||||
|
let subjectsData: any[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
subjectsData = response.data;
|
||||||
|
total = response.data.length;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
// 다양한 응답 구조 처리
|
||||||
|
subjectsData = response.data.items ||
|
||||||
|
response.data.courses ||
|
||||||
|
response.data.data ||
|
||||||
|
response.data.list ||
|
||||||
|
response.data.subjects ||
|
||||||
|
response.data.subjectList ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
// 전체 개수는 total, totalCount, count 등의 필드에서 가져오거나 배열 길이 사용
|
||||||
|
total = response.data.total !== undefined ? response.data.total :
|
||||||
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||||
|
response.data.count !== undefined ? response.data.count :
|
||||||
|
subjectsData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
setTotalCount(total);
|
||||||
|
setSubjects(subjectsData);
|
||||||
|
|
||||||
|
// 각 과목의 이미지 다운로드
|
||||||
|
const coursesWithImages = await Promise.all(
|
||||||
|
subjectsData.map(async (subject: Subject) => {
|
||||||
|
let imageUrl = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
|
|
||||||
|
if (subject.imageKey) {
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(subject.imageKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
imageUrl = fileUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`이미지 다운로드 실패 (과목 ID: ${subject.id}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `p${i + 1}`,
|
id: subject.id,
|
||||||
title: item.title,
|
title: subject.title || '',
|
||||||
image: item.image,
|
image: imageUrl,
|
||||||
inProgress: item.inProgress ?? (i % 7 === 0),
|
inProgress: false, // TODO: 수강 중 상태는 진행률 API에서 가져와야 함
|
||||||
|
meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인',
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
const totalPages = Math.ceil(courses.length / ITEMS_PER_PAGE);
|
);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setCourses(coursesWithImages);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('과목 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSubjects();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||||
const pagedCourses = useMemo(
|
const pagedCourses = useMemo(
|
||||||
() => courses.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE),
|
() => courses.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE),
|
||||||
[courses, page]
|
[courses, page]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 페이지네이션: 10개씩 표시
|
||||||
|
const pageGroup = Math.floor((page - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col items-center">
|
<main className="flex w-full flex-col items-center">
|
||||||
{/* 상단 타이틀 영역 */}
|
{/* 상단 타이틀 영역 */}
|
||||||
@@ -66,11 +155,17 @@ export default function CourseListPage() {
|
|||||||
{/* 상단 카운트/정렬 영역 */}
|
{/* 상단 카운트/정렬 영역 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[15px] font-medium leading-normal text-neutral-700">
|
<p className="text-[15px] font-medium leading-normal text-neutral-700">
|
||||||
총 <span className="text-primary">{courses.length}</span>건
|
총 <span className="text-primary">{totalCount}</span>건
|
||||||
</p>
|
</p>
|
||||||
<div className="h-[40px] w-[114px]" />
|
<div className="h-[40px] w-[114px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="mt-4 flex items-center justify-center h-[400px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* 카드 그리드(고정 5열, gap 32px) */}
|
{/* 카드 그리드(고정 5열, gap 32px) */}
|
||||||
<div className="mt-4 flex flex-col items-center">
|
<div className="mt-4 flex flex-col items-center">
|
||||||
<div className="w-[1376px] grid grid-cols-5 gap-[32px]">
|
<div className="w-[1376px] grid grid-cols-5 gap-[32px]">
|
||||||
@@ -78,14 +173,18 @@ export default function CourseListPage() {
|
|||||||
<article
|
<article
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => router.push(`/course-list/${c.id}`)}
|
onClick={() => router.push(`/course-list/${c.id}`)}
|
||||||
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer hover:shadow-lg transition-shadow"
|
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* 섬네일 */}
|
{/* 섬네일 */}
|
||||||
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px]">
|
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
|
||||||
<img
|
<img
|
||||||
src={c.image}
|
src={c.image}
|
||||||
alt=""
|
alt={c.title}
|
||||||
className="absolute inset-0 size-full object-cover"
|
className="h-full w-auto object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
const t = e.currentTarget as HTMLImageElement;
|
||||||
|
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,14 +192,14 @@ export default function CourseListPage() {
|
|||||||
<div className="flex w-full flex-col gap-[4px]">
|
<div className="flex w-full flex-col gap-[4px]">
|
||||||
<div className="flex flex-col gap-[4px]">
|
<div className="flex flex-col gap-[4px]">
|
||||||
{c.inProgress && <ColorfulTag text="수강 중" />}
|
{c.inProgress && <ColorfulTag text="수강 중" />}
|
||||||
<h2 className="text-[18px] font-semibold leading-normal text-neutral-700">
|
<h2 className="text-[18px] font-semibold leading-normal text-neutral-700 truncate" title={c.title}>
|
||||||
{c.title}
|
{c.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-[4px]">
|
<div className="flex items-center gap-[4px]">
|
||||||
<img src={imgPlay} alt="" className="size-[16px]" />
|
<img src={imgPlay} alt="" className="size-[16px]" />
|
||||||
<p className="text-[13px] font-medium leading-[1.4] text-text-meta">
|
<p className="text-[13px] font-medium leading-[1.4] text-text-meta">
|
||||||
VOD · 총 6강 · 4시간 20분
|
{c.meta || 'VOD • 온라인'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,21 +208,36 @@ export default function CourseListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 (피그마 스타일 반영) */}
|
{/* 페이지네이션 - admin 페이지와 동일한 메커니즘 (10개씩 표시) */}
|
||||||
|
{totalCount > ITEMS_PER_PAGE && (
|
||||||
<div className="mt-8 flex items-center justify-center gap-[8px]">
|
<div className="mt-8 flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Prev */}
|
{/* Prev */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
aria-label="이전 페이지"
|
aria-label="이전 페이지"
|
||||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40"
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
>
|
>
|
||||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Numbers */}
|
{/* Numbers */}
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => {
|
{visiblePages.map((n) => {
|
||||||
const active = n === page;
|
const active = n === page;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -132,7 +246,7 @@ export default function CourseListPage() {
|
|||||||
onClick={() => setPage(n)}
|
onClick={() => setPage(n)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={[
|
className={[
|
||||||
'flex items-center justify-center rounded-[1000px] size-[32px]',
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
active ? 'bg-bg-primary-light' : 'bg-white',
|
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
@@ -146,15 +260,30 @@ export default function CourseListPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
aria-label="다음 페이지"
|
aria-label="다음 페이지"
|
||||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40"
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
>
|
>
|
||||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 전역 스크롤바 스타일 - 배경(트랙)만 투명하게, 썸은 보이게 */
|
||||||
|
html {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -84,3 +107,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);
|
||||||
|
}
|
||||||
|
|||||||
731
src/app/instructor/courses/page.tsx
Normal file
731
src/app/instructor/courses/page.tsx
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import ChevronDownSvg from '../../svgs/chevrondownsvg';
|
||||||
|
import apiService from '../../lib/apiService';
|
||||||
|
|
||||||
|
// 드롭다운 아이콘 컴포넌트
|
||||||
|
function ArrowDownIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 6L8 10L12 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 아이콘 컴포넌트
|
||||||
|
function SearchIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 17C13.4183 17 17 13.4183 17 9C17 4.58172 13.4183 1 9 1C4.58172 1 1 4.58172 1 9C1 13.4183 4.58172 17 9 17Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 19L14.65 14.65"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 태그 컴포넌트
|
||||||
|
function StatusTag({ text, type = 'default', color = 'primary' }: { text: string; type?: 'default' | 'emphasis'; color?: 'primary' | 'gray' }) {
|
||||||
|
if (type === 'default' && color === 'primary') {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#ecf0ff]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#384fbf] whitespace-nowrap">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'default' && color === 'gray') {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#f1f3f5]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center justify-center h-[20px] px-[4px] rounded-[4px] bg-[#ecf0ff]">
|
||||||
|
<span className="text-[13px] font-semibold leading-[1.4] text-[#384fbf] whitespace-nowrap">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LearnerProgress = {
|
||||||
|
id: string;
|
||||||
|
courseName: string;
|
||||||
|
lessonName: string;
|
||||||
|
learnerName: string;
|
||||||
|
enrollmentDate: string;
|
||||||
|
lastStudyDate: string;
|
||||||
|
progressRate: number;
|
||||||
|
hasSubmitted: boolean;
|
||||||
|
score: number | null;
|
||||||
|
isCompleted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InstructorCoursesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [userRole, setUserRole] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [selectedCourse, setSelectedCourse] = useState<string>('all');
|
||||||
|
const [selectedSubmissionStatus, setSelectedSubmissionStatus] = useState<string>('all');
|
||||||
|
const [selectedCompletionStatus, setSelectedCompletionStatus] = useState<string>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
|
||||||
|
// 드롭다운 열림 상태
|
||||||
|
const [isCourseDropdownOpen, setIsCourseDropdownOpen] = useState(false);
|
||||||
|
const [isSubmissionDropdownOpen, setIsSubmissionDropdownOpen] = useState(false);
|
||||||
|
const [isCompletionDropdownOpen, setIsCompletionDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
const [courses, setCourses] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [learnerProgress, setLearnerProgress] = useState<LearnerProgress[]>([]);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
// 사용자 정보 및 권한 확인
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const localStorageToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const token = localStorageToken || cookieToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.getCurrentUser();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
const role = data.role || data.userRole || '';
|
||||||
|
setUserRole(role);
|
||||||
|
|
||||||
|
// admin이 아니면 접근 불가
|
||||||
|
if (role !== 'ADMIN' && role !== 'admin') {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// 교육 과정 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchCourses() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token') || document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const response = await apiService.getSubjects();
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
const coursesArray = Array.isArray(data) ? data : (data.items || data.courses || data.data || []);
|
||||||
|
setCourses(coursesArray.map((item: any) => ({
|
||||||
|
id: String(item.id || item.subjectId || ''),
|
||||||
|
name: item.courseName || item.name || item.subjectName || '',
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('교육 과정 목록 조회 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCourses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 학습자 진행 상황 데이터 (더미 데이터 - 실제 API로 교체 필요)
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLearnerProgress() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// TODO: 실제 API 호출로 교체
|
||||||
|
// 현재는 더미 데이터 사용
|
||||||
|
const dummyData: LearnerProgress[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 100,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 100,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 100,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
courseName: '원자로 운전 및 계통',
|
||||||
|
lessonName: '6. 원자로 시동, 운전 및 정지 절차',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 60,
|
||||||
|
isCompleted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 30,
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: true,
|
||||||
|
score: 30,
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
courseName: '방사선 관리',
|
||||||
|
lessonName: '{강좌명}',
|
||||||
|
learnerName: '김하늘',
|
||||||
|
enrollmentDate: '2025-09-10',
|
||||||
|
lastStudyDate: '2025-09-10',
|
||||||
|
progressRate: 100,
|
||||||
|
hasSubmitted: false,
|
||||||
|
score: null,
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setLearnerProgress(dummyData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습자 진행 상황 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLearnerProgress();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필터링된 데이터
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return learnerProgress.filter((item) => {
|
||||||
|
// 교육 과정 필터
|
||||||
|
if (selectedCourse !== 'all' && item.courseName !== selectedCourse) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문제 제출 여부 필터
|
||||||
|
if (selectedSubmissionStatus === 'submitted' && !item.hasSubmitted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedSubmissionStatus === 'not-submitted' && item.hasSubmitted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수료 여부 필터
|
||||||
|
if (selectedCompletionStatus === 'completed' && !item.isCompleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedCompletionStatus === 'not-completed' && item.isCompleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색어 필터
|
||||||
|
if (searchQuery && !item.learnerName.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [learnerProgress, selectedCourse, selectedSubmissionStatus, selectedCompletionStatus, searchQuery]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return filteredData.slice(startIndex, endIndex);
|
||||||
|
}, [filteredData, currentPage]);
|
||||||
|
|
||||||
|
// 드롭다운 외부 클릭 감지
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.dropdown-container')) {
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen flex flex-col">
|
||||||
|
<div className="flex-1 max-w-[1440px] w-full mx-auto px-0">
|
||||||
|
<div className="flex flex-col h-[100px] items-start justify-center px-[32px] ">
|
||||||
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
강좌 현황
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[16px] px-[32px] py-[32px]">
|
||||||
|
{/* 필터 및 검색 영역 */}
|
||||||
|
<div className="flex items-end justify-between gap-[16px]">
|
||||||
|
<div className="flex gap-[8px] items-end">
|
||||||
|
{/* 교육 과정 드롭다운 */}
|
||||||
|
<div className="flex flex-col gap-[4px] relative dropdown-container">
|
||||||
|
<label className="text-[14px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||||
|
교육 과정
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCourseDropdownOpen(!isCourseDropdownOpen);
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[6px] flex items-center justify-between gap-[8px] w-[400px]"
|
||||||
|
>
|
||||||
|
<span className="text-[15px] font-normal leading-[1.5] text-[#6c7682] flex-1 text-left">
|
||||||
|
{selectedCourse === 'all' ? '선택 안함' : courses.find(c => c.id === selectedCourse)?.name || '선택 안함'}
|
||||||
|
</span>
|
||||||
|
<ArrowDownIcon className="size-[16px] text-[#6c7682] shrink-0" />
|
||||||
|
</button>
|
||||||
|
{isCourseDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-50 w-[400px] max-h-[200px] overflow-y-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCourse('all');
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
선택 안함
|
||||||
|
</button>
|
||||||
|
{courses.map((course) => (
|
||||||
|
<button
|
||||||
|
key={course.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCourse(course.name);
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
{course.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문제 제출 여부 드롭다운 */}
|
||||||
|
<div className="flex flex-col gap-[4px] relative dropdown-container">
|
||||||
|
<label className="text-[14px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||||
|
문제 제출 여부
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSubmissionDropdownOpen(!isSubmissionDropdownOpen);
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[6px] flex items-center justify-between gap-[8px] w-[190px]"
|
||||||
|
>
|
||||||
|
<span className="text-[15px] font-normal leading-[1.5] text-[#6c7682] flex-1 text-left">
|
||||||
|
{selectedSubmissionStatus === 'all' ? '모든 상태' : selectedSubmissionStatus === 'submitted' ? '제출' : '미제출'}
|
||||||
|
</span>
|
||||||
|
<ArrowDownIcon className="size-[16px] text-[#6c7682] shrink-0" />
|
||||||
|
</button>
|
||||||
|
{isSubmissionDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-50 w-[190px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubmissionStatus('all');
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
모든 상태
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubmissionStatus('submitted');
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
제출
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubmissionStatus('not-submitted');
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
미제출
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수료 여부 드롭다운 */}
|
||||||
|
<div className="flex flex-col gap-[4px] relative dropdown-container">
|
||||||
|
<label className="text-[14px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||||
|
수료 여부
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCompletionDropdownOpen(!isCompletionDropdownOpen);
|
||||||
|
setIsCourseDropdownOpen(false);
|
||||||
|
setIsSubmissionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[6px] flex items-center justify-between gap-[8px] w-[190px]"
|
||||||
|
>
|
||||||
|
<span className="text-[15px] font-normal leading-[1.5] text-[#6c7682] flex-1 text-left">
|
||||||
|
{selectedCompletionStatus === 'all' ? '모든 상태' : selectedCompletionStatus === 'completed' ? '완료' : '미완료'}
|
||||||
|
</span>
|
||||||
|
<ArrowDownIcon className="size-[16px] text-[#6c7682] shrink-0" />
|
||||||
|
</button>
|
||||||
|
{isCompletionDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#dee1e6] rounded-[8px] shadow-lg z-50 w-[190px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompletionStatus('all');
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
모든 상태
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompletionStatus('completed');
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
완료
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompletionStatus('not-completed');
|
||||||
|
setIsCompletionDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-[12px] py-[8px] text-left text-[15px] font-normal leading-[1.5] text-[#1b2027] hover:bg-[#f1f3f5]"
|
||||||
|
>
|
||||||
|
미완료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색바 */}
|
||||||
|
<div className="bg-white border border-[#dee1e6] rounded-[8px] h-[40px] px-[12px] py-[8px] flex items-center gap-[10px] w-[240px]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="학습자명으로 검색"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="flex-1 text-[16px] font-normal leading-[1.5] text-[#b1b8c0] outline-none placeholder:text-[#b1b8c0]"
|
||||||
|
/>
|
||||||
|
<SearchIcon className="size-[20px] text-[#b1b8c0] shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 영역 */}
|
||||||
|
<div className="border border-[#dee1e6] rounded-[8px] overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredData.length === 0 ? (
|
||||||
|
<div className="min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">데이터가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className="bg-gray-50 h-[48px] flex items-center border-b border-[#dee1e6]">
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">교육 과정명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">강좌명</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">학습자명</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">가입일</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">마지막 수강일</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[76px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">진도율</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[112px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">문제 제출 여부</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">평가 점수</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">수료 여부</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 바디 */}
|
||||||
|
<div className="bg-white">
|
||||||
|
{paginatedData.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// 상세 페이지로 이동 (API 연동 시 item.id 사용)
|
||||||
|
router.push(`/instructor/courses/${item.id}`);
|
||||||
|
}}
|
||||||
|
className="h-[48px] w-full flex items-center border-b border-[#dee1e6] last:border-b-0 cursor-pointer hover:bg-[#F5F7FF] transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.courseName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.lessonName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.learnerName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.enrollmentDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px] border-r border-[#dee1e6] px-[16px] py-[12px]">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.lastStudyDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[76px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">{item.progressRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[112px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
{item.hasSubmitted ? (
|
||||||
|
<StatusTag text="제출" type="default" color="primary" />
|
||||||
|
) : (
|
||||||
|
<StatusTag text="미제출" type="default" color="gray" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] border-r border-[#dee1e6] px-[16px] py-[12px] text-center">
|
||||||
|
<span className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
|
||||||
|
{item.score !== null ? `${item.score}점` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-[84px] px-[16px] py-[12px] text-center">
|
||||||
|
{item.isCompleted ? (
|
||||||
|
<StatusTag text="완료" type="default" color="primary" />
|
||||||
|
) : (
|
||||||
|
<StatusTag text="미완료" type="default" color="gray" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{filteredData.length > ITEMS_PER_PAGE && (
|
||||||
|
<div className="flex items-center justify-center gap-[8px] pt-[32px]">
|
||||||
|
{/* First */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
{Array.from({ length: Math.min(10, totalPages) }, (_, i) => {
|
||||||
|
const pageNum = i + 1;
|
||||||
|
const isActive = pageNum === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className={`flex items-center justify-center rounded-full size-[32px] text-[16px] leading-[1.4] text-[#333c47] cursor-pointer ${
|
||||||
|
isActive ? 'bg-[#ecf0ff]' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="flex items-center justify-center rounded-full size-[32px] p-[8.615px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
344
src/app/instructor/page.tsx
Normal file
344
src/app/instructor/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import MainLogoSvg from '../svgs/mainlogosvg';
|
||||||
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
|
import apiService from '../lib/apiService';
|
||||||
|
|
||||||
|
// 아이콘 컴포넌트들
|
||||||
|
function BookIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 19.5C4 18.837 4.263 18.201 4.732 17.732C5.201 17.263 5.837 17 6.5 17H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6.5 2H20V22H6.5C5.837 22 5.201 21.737 4.732 21.268C4.263 20.799 4 20.163 4 19.5V4.5C4 3.837 4.263 3.201 4.732 2.732C5.201 2.263 5.837 2 6.5 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 2V8H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 13H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 17H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 9H9H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckCircleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.7088 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M22 4L12 14.01L9 11.01"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 8C9.65685 8 11 6.65685 11 5C11 3.34315 9.65685 2 8 2C6.34315 2 5 3.34315 5 5C5 6.65685 6.34315 8 8 8Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2 13.3333C2 11.0862 3.75333 9.33333 6 9.33333H10C12.2467 9.33333 14 11.0862 14 13.3333V14H2V13.3333Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronRightIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 12L10 8L6 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Activity = {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InstructorPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [userRole, setUserRole] = useState<string>('');
|
||||||
|
const [totalCourses, setTotalCourses] = useState<number>(5);
|
||||||
|
const [submissionStatus, setSubmissionStatus] = useState<{ current: number; total: number }>({ current: 10, total: 50 });
|
||||||
|
const [completionStatus, setCompletionStatus] = useState<{ current: number; total: number }>({ current: 14, total: 50 });
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([
|
||||||
|
{ id: '1', userName: '김하늘', message: '{강좌명} 문제를 제출했습니다.', timestamp: '2025-12-12 14:44' },
|
||||||
|
{ id: '2', userName: '김하늘', message: '{강좌명} 문제를 제출했습니다.', timestamp: '2025-12-12 14:44' },
|
||||||
|
{ id: '3', userName: '김하늘', message: '모든 강좌를 수강했습니다.', timestamp: '2025-12-12 14:44' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 사용자 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const localStorageToken = localStorage.getItem('token');
|
||||||
|
const cookieToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('token='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
const token = localStorageToken || cookieToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.getCurrentUser();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
const role = data.role || data.userRole || '';
|
||||||
|
setUserRole(role);
|
||||||
|
|
||||||
|
// admin이 아니면 접근 불가
|
||||||
|
if (role !== 'ADMIN' && role !== 'admin') {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
setUserName(data.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white min-h-screen flex flex-col">
|
||||||
|
<div className="flex-1 max-w-[1440px] w-full mx-auto px-0">
|
||||||
|
<div className="flex flex-col gap-[40px] w-full">
|
||||||
|
{/* 강좌별 상세 내역 섹션 */}
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<div className="flex h-[100px] items-center justify-between px-[32px]">
|
||||||
|
<h2 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
강좌별 상세 내역
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/instructor/courses"
|
||||||
|
className="flex items-center gap-[2px] text-[14px] font-medium text-[#6c7682]"
|
||||||
|
>
|
||||||
|
<span>전체보기</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-[16px] pb-[32px] pt-0 px-[32px]">
|
||||||
|
<div className="flex gap-[16px] h-[120px]">
|
||||||
|
{/* 총 강좌 수 카드 */}
|
||||||
|
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
|
||||||
|
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
|
||||||
|
<BookIcon className="size-[24px] text-[#060958]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
|
||||||
|
총 강좌 수
|
||||||
|
</p>
|
||||||
|
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
|
||||||
|
{totalCourses}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 학습자 문제 제출 현황 카드 */}
|
||||||
|
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
|
||||||
|
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
|
||||||
|
<DocumentIcon className="size-[24px] text-[#060958]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
|
||||||
|
학습자 문제 제출 현황
|
||||||
|
</p>
|
||||||
|
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
|
||||||
|
{submissionStatus.current} / {submissionStatus.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 학습자 수료 현황 카드 */}
|
||||||
|
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
|
||||||
|
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
|
||||||
|
<CheckCircleIcon className="size-[24px] text-[#060958]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
|
||||||
|
학습자 수료 현황
|
||||||
|
</p>
|
||||||
|
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
|
||||||
|
{completionStatus.current} / {completionStatus.total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 학습자 활동 섹션 */}
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<div className="flex gap-[10px] h-[100px] items-center px-[32px]">
|
||||||
|
<h2 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
|
||||||
|
최근 학습자 활동
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-[16px] pb-[80px] pt-0 px-[32px]">
|
||||||
|
<div className="flex flex-col gap-[8px] min-h-[256px]">
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
className="bg-white border border-[#dee1e6] rounded-[8px] flex gap-[12px] items-center p-[17px]"
|
||||||
|
>
|
||||||
|
<div className="bg-[#f1f3f5] rounded-full size-[32px] flex items-center justify-center shrink-0">
|
||||||
|
<UserIcon className="size-[16px] text-[#333c47]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-start">
|
||||||
|
<div className="flex flex-col text-[15px] leading-[1.5] text-[#1b2027]">
|
||||||
|
<span className="font-semibold">{activity.userName}</span>
|
||||||
|
<span className="font-normal">{activity.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] font-normal text-[#6c7682] leading-[1.4] shrink-0">
|
||||||
|
{activity.timestamp}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
@@ -43,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 헤더 생성
|
* 기본 헤더 생성
|
||||||
*/
|
*/
|
||||||
@@ -96,10 +118,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;
|
||||||
@@ -121,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 {
|
||||||
@@ -271,8 +314,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',
|
||||||
@@ -339,15 +383,90 @@ class ApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 리스트 조회 (컴팩트) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async getUsersCompact(params?: { type?: string; limit?: number }) {
|
||||||
|
return this.request('/admin/users/compact', {
|
||||||
|
params: params ? {
|
||||||
|
...(params.type && { type: params.type }),
|
||||||
|
...(params.limit && { limit: params.limit }),
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 회원 정지 (ID 기준) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async suspendUser(userId: string | number) {
|
||||||
|
return this.request(`/admin/users/${userId}/suspend`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 회원 정지 해제 (ID 기준) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async unsuspendUser(userId: string | number) {
|
||||||
|
return this.request(`/admin/users/${userId}/unsuspend`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 대량 정지 (ID 배열) - 관리자 전용
|
||||||
|
*/
|
||||||
|
async suspendUsers(userIds: (string | number)[]) {
|
||||||
|
return this.request('/admin/users/suspend', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { userIds },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 기타 API =====
|
// ===== 기타 API =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공지사항 조회
|
* 공지사항 목록 조회 (페이징)
|
||||||
*/
|
*/
|
||||||
async getNotices() {
|
async getNotices() {
|
||||||
return this.request('/notices');
|
return this.request('/notices');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 단건 조회
|
||||||
|
*/
|
||||||
|
async getNotice(id: string | number) {
|
||||||
|
return this.request(`/notices/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 등록 (ADMIN)
|
||||||
|
*/
|
||||||
|
async createNotice(noticeData: any) {
|
||||||
|
return this.request('/notices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: noticeData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 수정 (ADMIN)
|
||||||
|
*/
|
||||||
|
async updateNotice(id: string | number, noticeData: any) {
|
||||||
|
return this.request(`/notices/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: noticeData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 삭제 (ADMIN)
|
||||||
|
*/
|
||||||
|
async deleteNotice(id: string | number) {
|
||||||
|
return this.request(`/notices/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 강좌 조회
|
* 강좌 조회
|
||||||
*/
|
*/
|
||||||
@@ -424,6 +543,135 @@ class ApiService {
|
|||||||
return this.request('/resources');
|
return this.request('/resources');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 학습 자료실 (Library) 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료실 목록 조회 (로그인 필요, 페이징)
|
||||||
|
*/
|
||||||
|
async getLibrary() {
|
||||||
|
return this.request('/library');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 단건 조회
|
||||||
|
*/
|
||||||
|
async getLibraryItem(id: string | number) {
|
||||||
|
return this.request(`/library/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 등록 (ADMIN)
|
||||||
|
*/
|
||||||
|
async createLibraryItem(libraryData: any) {
|
||||||
|
return this.request('/library', {
|
||||||
|
method: 'POST',
|
||||||
|
body: libraryData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 수정 (ADMIN)
|
||||||
|
*/
|
||||||
|
async updateLibraryItem(id: string | number, libraryData: any) {
|
||||||
|
return this.request(`/library/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: libraryData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학습 자료 삭제 (ADMIN)
|
||||||
|
*/
|
||||||
|
async deleteLibraryItem(id: string | number) {
|
||||||
|
return this.request(`/library/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 진행률 (Progress) 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강의에서 "내" 진행률 조회
|
||||||
|
*/
|
||||||
|
async getLectureProgress(lectureId: string | number) {
|
||||||
|
return this.request(`/progress/lectures/${lectureId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강의 진행(Heartbeat) 업서트
|
||||||
|
*/
|
||||||
|
async updateLectureProgress(progressData: any) {
|
||||||
|
return this.request('/progress/lectures/progress', {
|
||||||
|
method: 'POST',
|
||||||
|
body: progressData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 과목 진행률 요약(내 기준)
|
||||||
|
*/
|
||||||
|
async getSubjectProgressSummary(subjectId: string | number) {
|
||||||
|
return this.request(`/progress/subjects/${subjectId}/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 평가 (Evaluation) 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강의 평가 제출 (60점 이상만 저장)
|
||||||
|
*/
|
||||||
|
async submitEvaluation(evaluationData: any) {
|
||||||
|
return this.request('/evaluations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: evaluationData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강의에서 "내" 마지막 평가 결과 조회
|
||||||
|
*/
|
||||||
|
async getMyEvaluation(lectureId: string | number) {
|
||||||
|
return this.request(`/evaluations/lectures/${lectureId}/me`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강의에서 특정 수강생의 마지막 평가 결과 조회 (ADMIN/INSTRUCTOR)
|
||||||
|
*/
|
||||||
|
async getUserEvaluation(lectureId: string | number, userId: string | number) {
|
||||||
|
return this.request(`/evaluations/lectures/${lectureId}/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 관리자 강의 현황 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강좌별 학습/문제 제출/수료 현황 리스트 (관리자/강사용)
|
||||||
|
*/
|
||||||
|
async getLecturesStatus() {
|
||||||
|
return this.request('/admin/lectures/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 강좌 + 특정 학습자의 수강/문제 풀이 상세 (관리자/강사용)
|
||||||
|
*/
|
||||||
|
async getLectureStudentDetail(lectureId: string | number, userId: string | number) {
|
||||||
|
return this.request(`/admin/lectures/${lectureId}/students/${userId}/detail`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 수료증 및 결과 관련 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 과목 수료증용 정보 조회 (학생 본인)
|
||||||
|
*/
|
||||||
|
async getCertificate(subjectId: string | number) {
|
||||||
|
return this.request(`/certificates/subjects/${subjectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 학생 기준 학습 결과(수료 과목 목록)
|
||||||
|
*/
|
||||||
|
async getMyResults() {
|
||||||
|
return this.request('/results/my-subjects');
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 파일 업로드 관련 API =====
|
// ===== 파일 업로드 관련 API =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -457,7 +705,7 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 다운로드 (이미지 URL 가져오기)
|
* 파일 다운로드
|
||||||
* @param fileKey 파일 키
|
* @param fileKey 파일 키
|
||||||
* @returns 파일 URL (Blob URL), 파일이 없으면 null 반환
|
* @returns 파일 URL (Blob URL), 파일이 없으면 null 반환
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -160,8 +160,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-screen w-full flex flex-col items-center pt-[180px]">
|
|
||||||
|
|
||||||
<LoginErrorModal
|
<LoginErrorModal
|
||||||
open={isLoginErrorOpen}
|
open={isLoginErrorOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -175,8 +173,10 @@ export default function LoginPage() {
|
|||||||
loginErrorModalEnabled={isLoginErrorOpen}
|
loginErrorModalEnabled={isLoginErrorOpen}
|
||||||
setLoginErrorModalEnabled={setIsLoginErrorOpen}
|
setLoginErrorModalEnabled={setIsLoginErrorOpen}
|
||||||
/>
|
/>
|
||||||
|
<div className="h-screen w-full flex flex-col overflow-hidden">
|
||||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full">
|
{/* 메인 컨텐츠 영역 - flex-1로 남은 공간 차지 */}
|
||||||
|
<div className="flex-1 flex items-center justify-center min-h-0">
|
||||||
|
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full my-auto">
|
||||||
{/* 로고 영역 */}
|
{/* 로고 영역 */}
|
||||||
<div className="my-15 flex flex-col items-center">
|
<div className="my-15 flex flex-col items-center">
|
||||||
<div className="mb-[7px]">
|
<div className="mb-[7px]">
|
||||||
@@ -321,11 +321,12 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center py-[40px] text-[15px] text-basic-text">
|
{/* Copyright 영역 - 하단 고정 */}
|
||||||
|
<p className="text-center py-[40px] text-[15px] text-basic-text flex-shrink-0">
|
||||||
Copyright ⓒ 2025 XL LMS. All rights reserved
|
Copyright ⓒ 2025 XL LMS. All rights reserved
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import ModalCloseSvg from "../svgs/closexsvg";
|
import ModalCloseSvg from "../svgs/closexsvg";
|
||||||
|
import apiService from "../lib/apiService";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,54 +23,7 @@ export default function AccountDeleteModal({ open, onClose, onConfirm }: Props)
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
await apiService.deleteAccount();
|
||||||
if (!token) {
|
|
||||||
alert('로그인이 필요합니다.');
|
|
||||||
setIsLoading(false);
|
|
||||||
onClose();
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('회원 탈퇴 요청 시작, 토큰 존재:', !!token);
|
|
||||||
console.log('토큰 길이:', token?.length);
|
|
||||||
console.log('토큰 시작 부분:', token?.substring(0, 20));
|
|
||||||
|
|
||||||
const response = await fetch('https://hrdi.coconutmeet.net/auth/delete/me', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('회원 탈퇴 응답 상태:', response.status);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `회원 탈퇴 실패 (${response.status})`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
console.error('회원 탈퇴 API 오류 응답:', errorData);
|
|
||||||
if (errorData.error) {
|
|
||||||
errorMessage = errorData.error;
|
|
||||||
} else if (errorData.message) {
|
|
||||||
errorMessage = errorData.message;
|
|
||||||
} else if (errorData.errorMessage) {
|
|
||||||
errorMessage = errorData.errorMessage;
|
|
||||||
} else if (response.statusText) {
|
|
||||||
errorMessage = `${response.statusText} (${response.status})`;
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('응답 파싱 오류:', parseError);
|
|
||||||
if (response.statusText) {
|
|
||||||
errorMessage = `${response.statusText} (${response.status})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error('회원 탈퇴 실패:', errorMessage, '상태 코드:', response.status);
|
|
||||||
alert(errorMessage);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ChangePasswordModal from "../ChangePasswordModal";
|
|||||||
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
|
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
|
||||||
import AccountDeleteModal from "../AccountDeleteModal";
|
import AccountDeleteModal from "../AccountDeleteModal";
|
||||||
import MenuAccountOption from "@/app/menu/account/MenuAccountOption";
|
import MenuAccountOption from "@/app/menu/account/MenuAccountOption";
|
||||||
|
import apiService from "@/app/lib/apiService";
|
||||||
|
|
||||||
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
|
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
|
||||||
|
|
||||||
@@ -51,19 +52,8 @@ export default function AccountPage() {
|
|||||||
localStorage.setItem('token', cookieToken);
|
localStorage.setItem('token', cookieToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
const response = await apiService.getCurrentUser();
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/me`
|
|
||||||
: 'https://hrdi.coconutmeet.net/auth/me';
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// 토큰이 만료되었거나 유효하지 않은 경우
|
// 토큰이 만료되었거나 유효하지 않은 경우
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
@@ -72,17 +62,9 @@ export default function AccountPage() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let errorMessage = `사용자 정보 조회 실패 (${response.status})`;
|
|
||||||
try {
|
if (response.status !== 200) {
|
||||||
const errorData = await response.json();
|
const errorMessage = response.message || `사용자 정보 조회 실패 (${response.status})`;
|
||||||
if (errorData.error) {
|
|
||||||
errorMessage = errorData.error;
|
|
||||||
} else if (errorData.message) {
|
|
||||||
errorMessage = errorData.message;
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
console.error('사용자 정보 조회 실패:', errorMessage);
|
console.error('사용자 정보 조회 실패:', errorMessage);
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -90,7 +72,7 @@ export default function AccountPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = response.data;
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setUserInfo(data);
|
setUserInfo(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -191,35 +173,7 @@ export default function AccountPage() {
|
|||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
await apiService.deleteAccount();
|
||||||
const response = await fetch('https://hrdi.coconutmeet.net/auth/delete/me', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `회원 탈퇴 실패 (${response.status})`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (errorData.error) {
|
|
||||||
errorMessage = errorData.error;
|
|
||||||
} else if (errorData.message) {
|
|
||||||
errorMessage = errorData.message;
|
|
||||||
} else if (response.statusText) {
|
|
||||||
errorMessage = `${response.statusText} (${response.status})`;
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
if (response.statusText) {
|
|
||||||
errorMessage = `${response.statusText} (${response.status})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error('회원 탈퇴 실패:', errorMessage);
|
|
||||||
alert(errorMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import MenuSidebar from "./MenuSidebar";
|
|||||||
export default function MenuLayout({ children }: { children: ReactNode }) {
|
export default function MenuLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
|
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
|
||||||
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
<aside className="hidden w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
||||||
<MenuSidebar />
|
<MenuSidebar />
|
||||||
</aside>
|
</aside>
|
||||||
<section className="flex-1">{children}</section>
|
<section className="flex-1">{children}</section>
|
||||||
|
|||||||
@@ -1,17 +1,169 @@
|
|||||||
import Link from 'next/link';
|
'use client';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
|
||||||
import { MOCK_NOTICES } from '../../admin/notices/mockData';
|
|
||||||
|
|
||||||
export default async function NoticeDetailPage({
|
import { useState, useEffect } from 'react';
|
||||||
params,
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
}: {
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
params: Promise<{ id: string }>;
|
import DownloadIcon from '../../svgs/downloadicon';
|
||||||
}) {
|
import apiService from '../../lib/apiService';
|
||||||
const { id } = await params;
|
import type { Notice } from '../../admin/notices/mockData';
|
||||||
const numericId = Number(id);
|
|
||||||
const item = MOCK_NOTICES.find((r) => r.id === numericId);
|
type Attachment = {
|
||||||
if (!item || !item.content) return notFound();
|
name: string;
|
||||||
|
size: string;
|
||||||
|
url?: string;
|
||||||
|
fileKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NoticeDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNotice() {
|
||||||
|
if (!params?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const noticeId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
|
const response = await apiService.getNotice(noticeId);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotice: Notice = {
|
||||||
|
id: data.id || data.noticeId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: formatDate(data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0]),
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedNotice.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedNotice.title) {
|
||||||
|
setError('공지사항을 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotice(transformedNotice);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('공지사항 조회 오류:', err);
|
||||||
|
setError('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotice();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
alert('파일 다운로드 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !notice) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">{error || '공지사항을 찾을 수 없습니다.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -19,13 +171,14 @@ export default async function NoticeDetailPage({
|
|||||||
<div className="w-full max-w-[1440px]">
|
<div className="w-full max-w-[1440px]">
|
||||||
{/* 상단 타이틀 */}
|
{/* 상단 타이틀 */}
|
||||||
<div className="h-[100px] flex items-center gap-3 px-8">
|
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||||
<Link
|
<button
|
||||||
href="/notices"
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
aria-label="뒤로 가기"
|
aria-label="뒤로 가기"
|
||||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||||
>
|
>
|
||||||
<BackCircleSvg width={32} height={32} />
|
<BackCircleSvg width={32} height={32} />
|
||||||
</Link>
|
</button>
|
||||||
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
공지사항 상세
|
공지사항 상세
|
||||||
</h1>
|
</h1>
|
||||||
@@ -37,17 +190,17 @@ export default async function NoticeDetailPage({
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
|
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
|
||||||
{item.title}
|
{notice.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
|
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
|
||||||
<span className="text-[#8C95A1]">작성자</span>
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
<span className="text-[#333C47]">{item.writer}</span>
|
<span className="text-[#333C47]">{notice.writer}</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">게시일</span>
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
<span className="text-[#333C47]">{item.date}</span>
|
<span className="text-[#333C47]">{notice.date}</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">조회수</span>
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
<span className="text-[#333C47]">{notice.views.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,15 +208,80 @@ export default async function NoticeDetailPage({
|
|||||||
<div className="h-px bg-[#DEE1E6] w-full" />
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
{/* 본문 */}
|
{/* 본문 */}
|
||||||
<div className="p-8">
|
<div className="p-8 flex flex-col gap-10">
|
||||||
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
<div className="text-[15px] leading-normal text-[#333C47]">
|
||||||
{item.content.map((p, idx) => (
|
{notice.content && notice.content.length > 0 ? (
|
||||||
<p key={idx} className="m-0">
|
notice.content.map((p, idx) => (
|
||||||
|
<p key={idx} className="m-0 mb-2 last:mb-0">
|
||||||
{p}
|
{p}
|
||||||
</p>
|
</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="m-0">내용이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 섹션 */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="text-[15px] font-semibold leading-[1.5] text-[#6C7682]">
|
||||||
|
첨부 파일
|
||||||
|
</div>
|
||||||
|
<div className="pt-3">
|
||||||
|
{attachments.map((attachment, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-3 px-[17px]"
|
||||||
|
>
|
||||||
|
<div className="size-6 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="text-[#8C95A1]"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 2V8H20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||||
|
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate">
|
||||||
|
{attachment.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] whitespace-nowrap">
|
||||||
|
{attachment.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||||
|
className="bg-white border border-[#8C95A1] rounded-[6px] h-8 px-4 flex items-center justify-center gap-1 hover:bg-[#F9FAFB] transition-colors"
|
||||||
|
>
|
||||||
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
|
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||||
|
다운로드
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,6 +289,3 @@ export default async function NoticeDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,115 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import PaperClipSvg from '../svgs/paperclipsvg';
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||||
import { MOCK_NOTICES } from '../admin/notices/mockData';
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
|
import apiService from '../lib/apiService';
|
||||||
|
import type { Notice } from '../admin/notices/mockData';
|
||||||
|
|
||||||
export default function NoticesPage() {
|
export default function NoticesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [notices, setNotices] = useState<Notice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 공지사항 리스트 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchNotices() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiService.getNotices();
|
||||||
|
|
||||||
|
if (response.status !== 200 || !response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
noticesArray = response.data;
|
||||||
|
total = response.data.length;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || [];
|
||||||
|
total = response.data.total !== undefined ? response.data.total :
|
||||||
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||||
|
response.data.count !== undefined ? response.data.count :
|
||||||
|
noticesArray.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]),
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
const rows = [...MOCK_NOTICES].sort((a, b) => b.date.localeCompare(a.date));
|
const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
setNotices(sortedNotices);
|
||||||
|
setTotalCount(total || sortedNotices.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotices();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||||
|
const pagedNotices = useMemo(
|
||||||
|
() => notices.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE),
|
||||||
|
[notices, currentPage]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 페이지네이션: 10개씩 표시
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -26,10 +127,16 @@ export default function NoticesPage() {
|
|||||||
{/* 총 건수 */}
|
{/* 총 건수 */}
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">{rows.length}</span>건
|
총 <span className="text-[#384FBF]">{totalCount}</span>건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-[240px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* 표 */}
|
{/* 표 */}
|
||||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
@@ -51,49 +158,131 @@ export default function NoticesPage() {
|
|||||||
|
|
||||||
{/* 바디 */}
|
{/* 바디 */}
|
||||||
<div>
|
<div>
|
||||||
{rows.map((r, index) => {
|
{pagedNotices.length === 0 ? (
|
||||||
// 번호는 정렬된 목록에서의 순서
|
<div className="flex items-center justify-center h-[240px]">
|
||||||
const noticeNumber = rows.length - index;
|
<p className="text-[16px] font-medium text-[#6C7682]">공지사항이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pagedNotices.map((notice, index) => {
|
||||||
|
// 번호는 전체 목록에서의 순서 (최신이 1번)
|
||||||
|
const noticeNumber = totalCount - ((currentPage - 1) * ITEMS_PER_PAGE + index);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={notice.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => router.push(`/notices/${r.id}`)}
|
onClick={() => router.push(`/notices/${notice.id}`)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
router.push(`/notices/${r.id}`);
|
router.push(`/notices/${notice.id}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer"
|
||||||
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
||||||
{noticeNumber}
|
{noticeNumber}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
title={r.title}
|
title={notice.title}
|
||||||
>
|
>
|
||||||
{r.title}
|
{notice.title}
|
||||||
{r.hasAttachment && (
|
{notice.hasAttachment && (
|
||||||
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
||||||
{r.date}
|
{notice.date}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
||||||
{r.views.toLocaleString()}
|
{notice.views.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4">{r.writer}</div>
|
<div className="flex items-center px-4">{notice.writer}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{totalCount > ITEMS_PER_PAGE && (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
366
src/app/page.tsx
366
src/app/page.tsx
@@ -3,92 +3,36 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import MainLogoSvg from './svgs/mainlogosvg';
|
import MainLogoSvg from './svgs/mainlogosvg';
|
||||||
|
import apiService from './lib/apiService';
|
||||||
|
import type { Notice } from './admin/notices/mockData';
|
||||||
|
|
||||||
|
interface Subject {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
imageKey?: string;
|
||||||
|
instructor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourseCard {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
meta: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [userName, setUserName] = useState<string>('');
|
const [userName, setUserName] = useState<string>('');
|
||||||
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
// 코스, 공지사항 더미 데이터
|
const [courseCards, setCourseCards] = useState<CourseCard[]>([]);
|
||||||
const courseCards = useMemo(
|
const [loadingSubjects, setLoadingSubjects] = useState(true);
|
||||||
() =>
|
const [totalSubjectsCount, setTotalSubjectsCount] = useState(0);
|
||||||
[
|
const [notices, setNotices] = useState<Notice[]>([]);
|
||||||
{
|
const [loadingNotices, setLoadingNotices] = useState(true);
|
||||||
id: 'c1',
|
|
||||||
title: '원자력 운영 기초',
|
|
||||||
meta: 'VOD • 초급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c1/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c2',
|
|
||||||
title: '반도체',
|
|
||||||
meta: 'VOD • 중급 • 3시간 10분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c2/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c3',
|
|
||||||
title: '방사선 안전',
|
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c3/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c4',
|
|
||||||
title: '방사선 폐기물',
|
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c4/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c5',
|
|
||||||
title: '원자력 운전 개론',
|
|
||||||
meta: 'VOD • 초급 • 3시간 00분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c5/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c6',
|
|
||||||
title: '안전 표지와 표준',
|
|
||||||
meta: 'VOD • 초급 • 2시간 40분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c6/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c7',
|
|
||||||
title: '발전소 운영',
|
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c7/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c8',
|
|
||||||
title: '방사선 안전 실습',
|
|
||||||
meta: 'VOD • 중급 • 3시간 30분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c8/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c9',
|
|
||||||
title: '실험실 안전',
|
|
||||||
meta: 'VOD • 초급 • 2시간 10분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c9/1200/800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c10',
|
|
||||||
title: '기초 장비 운용',
|
|
||||||
meta: 'VOD • 초급 • 2시간 50분',
|
|
||||||
image: 'https://picsum.photos/seed/xrlms-c10/1200/800',
|
|
||||||
},
|
|
||||||
] as Array<{ id: string; title: string; meta: string; image: string }>,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const noticeRows = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
{ id: 5, title: '(공지)시스템 개선이 완료되었...', date: '2025-09-10', views: 1320, writer: '운영팀' },
|
|
||||||
{ id: 4, title: '(공지)서버 점검 안내(9/10 새벽)', date: '2025-09-10', views: 1210, writer: '운영팀' },
|
|
||||||
{ id: 3, title: '(공지)서비스 개선 안내', date: '2025-09-10', views: 1230, writer: '운영팀' },
|
|
||||||
{ id: 2, title: '(공지)시장점검 공지', date: '2025-09-10', views: 1320, writer: '관리자' },
|
|
||||||
{ id: 1, title: '뉴: 봉사시간 안내 및 한눈에 보는 현황 정리', date: '2025-08-28', views: 594, writer: '운영팀' },
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
|
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
|
||||||
const slides = useMemo(
|
const slides = useMemo(
|
||||||
@@ -133,23 +77,13 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL
|
const response = await apiService.getCurrentUser();
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/me`
|
|
||||||
: 'https://hrdi.coconutmeet.net/auth/me';
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
if (response.status !== 200) {
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = response.data;
|
||||||
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
// 사용자 권한 확인
|
// 사용자 권한 확인
|
||||||
@@ -176,6 +110,176 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 과목 리스트 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchSubjects() {
|
||||||
|
try {
|
||||||
|
setLoadingSubjects(true);
|
||||||
|
const response = await apiService.getSubjects();
|
||||||
|
|
||||||
|
if (response.status !== 200 || !response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 데이터 구조 확인 및 배열 추출
|
||||||
|
let subjectsData: any[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
subjectsData = response.data;
|
||||||
|
totalCount = response.data.length;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
// 다양한 응답 구조 처리
|
||||||
|
subjectsData = response.data.items ||
|
||||||
|
response.data.courses ||
|
||||||
|
response.data.data ||
|
||||||
|
response.data.list ||
|
||||||
|
response.data.subjects ||
|
||||||
|
response.data.subjectList ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
// 전체 개수는 total, totalCount, count 등의 필드에서 가져오거나 배열 길이 사용
|
||||||
|
totalCount = response.data.total !== undefined ? response.data.total :
|
||||||
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||||
|
response.data.count !== undefined ? response.data.count :
|
||||||
|
subjectsData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('과목 리스트 응답:', response.data);
|
||||||
|
console.log('추출된 과목 데이터:', subjectsData);
|
||||||
|
console.log('전체 과목 개수:', totalCount);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 전체 과목 개수 저장
|
||||||
|
setTotalSubjectsCount(totalCount);
|
||||||
|
|
||||||
|
// 상위 10개만 가져오기
|
||||||
|
const top10Subjects = subjectsData.slice(0, 10);
|
||||||
|
setSubjects(top10Subjects);
|
||||||
|
|
||||||
|
// 각 과목의 이미지 다운로드
|
||||||
|
const courseCardsWithImages = await Promise.all(
|
||||||
|
top10Subjects.map(async (subject: Subject) => {
|
||||||
|
let imageUrl = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
|
|
||||||
|
if (subject.imageKey) {
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(subject.imageKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
imageUrl = fileUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`이미지 다운로드 실패 (과목 ID: ${subject.id}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: subject.id,
|
||||||
|
title: subject.title || '',
|
||||||
|
meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인',
|
||||||
|
image: imageUrl,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setCourseCards(courseCardsWithImages);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('과목 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingSubjects(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSubjects();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 공지사항 리스트 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchNotices() {
|
||||||
|
try {
|
||||||
|
setLoadingNotices(true);
|
||||||
|
const response = await apiService.getNotices();
|
||||||
|
|
||||||
|
if (response.status !== 200 || !response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let noticesArray: any[] = [];
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
noticesArray = response.data;
|
||||||
|
} else if (response.data && typeof response.data === 'object') {
|
||||||
|
noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 응답 데이터를 Notice 형식으로 변환
|
||||||
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||||
|
id: notice.id || notice.noticeId || 0,
|
||||||
|
title: notice.title || '',
|
||||||
|
date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]),
|
||||||
|
views: notice.views || notice.viewCount || 0,
|
||||||
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||||
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||||
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
setNotices(sortedNotices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('공지사항 리스트 조회 오류:', error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingNotices(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNotices();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const containerEl = containerRef.current;
|
const containerEl = containerRef.current;
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
@@ -330,11 +434,11 @@ export default function Home() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">교육 과정</h2>
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">교육 과정</h2>
|
||||||
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">28</span>건
|
총 <span className="text-[#384FBF]">{totalSubjectsCount}</span>건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<Link
|
||||||
href="#"
|
href="/course-list"
|
||||||
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
||||||
>
|
>
|
||||||
전체보기
|
전체보기
|
||||||
@@ -348,17 +452,26 @@ export default function Home() {
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loadingSubjects ? (
|
||||||
|
<div className="flex items-center justify-center h-[260px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-5 gap-8">
|
<div className="grid grid-cols-5 gap-8">
|
||||||
{courseCards.map((c) => (
|
{courseCards.map((c) => (
|
||||||
<article key={c.id} className="flex flex-col gap-4 h-[260px]">
|
<article
|
||||||
<div className="h-[166.4px] overflow-hidden rounded-[8px]">
|
key={c.id}
|
||||||
|
onClick={() => router.push(`/course-list/${c.id}`)}
|
||||||
|
className="flex flex-col gap-4 h-[260px] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="h-[166.4px] overflow-hidden rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
|
||||||
<img
|
<img
|
||||||
alt={c.title}
|
alt={c.title}
|
||||||
src={c.image}
|
src={c.image}
|
||||||
className="w-full h-full object-cover block"
|
className="h-full w-auto object-contain block"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const t = e.currentTarget as HTMLImageElement;
|
const t = e.currentTarget as HTMLImageElement;
|
||||||
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||||
@@ -366,11 +479,6 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{c.id === 'c1' && (
|
|
||||||
<span className="inline-flex h-[20px] items-center justify-center px-1 bg-[#E5F5EC] rounded-[4px] text-[13px] font-semibold leading-[1.4] text-[#0C9D61]">
|
|
||||||
수강 중
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
|
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
|
||||||
{c.title}
|
{c.title}
|
||||||
</h5>
|
</h5>
|
||||||
@@ -384,19 +492,20 @@ export default function Home() {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 공지사항 */}
|
{/* 공지사항 */}
|
||||||
<section className="mt-9">
|
<section className="mt-9 pb-20">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">공지사항</h2>
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">공지사항</h2>
|
||||||
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">{noticeRows.length}</span>건
|
총 <span className="text-[#384FBF]">{notices.length}</span>건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<Link
|
||||||
href="#"
|
href="/notices"
|
||||||
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
||||||
>
|
>
|
||||||
전체보기
|
전체보기
|
||||||
@@ -410,9 +519,14 @@ export default function Home() {
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loadingNotices ? (
|
||||||
|
<div className="flex items-center justify-center h-[240px]">
|
||||||
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
||||||
@@ -423,25 +537,39 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{noticeRows.map((r) => (
|
{notices.map((notice, index) => {
|
||||||
|
// 번호는 정렬된 목록에서의 순서 (최신이 1번)
|
||||||
|
const noticeNumber = notices.length - index;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={notice.id}
|
||||||
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6]"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => router.push(`/notices/${notice.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(`/notices/${notice.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{r.id}</div>
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{noticeNumber}</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
title={r.title}
|
title={notice.title}
|
||||||
>
|
>
|
||||||
{r.title}
|
{notice.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.date}</div>
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.date}</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.views.toLocaleString()}</div>
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.views.toLocaleString()}</div>
|
||||||
<div className="flex items-center px-4">{r.writer}</div>
|
<div className="flex items-center px-4">{notice.writer}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import PaperClipSvg from '../../svgs/paperclipsvg';
|
import PaperClipSvg from '../../svgs/paperclipsvg';
|
||||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
|
import DownloadIcon from '../../svgs/downloadicon';
|
||||||
|
import apiService from '../../lib/apiService';
|
||||||
|
import type { Resource } from '../../admin/resources/mockData';
|
||||||
|
|
||||||
type ResourceRow = {
|
type Attachment = {
|
||||||
id: number;
|
name: string;
|
||||||
title: string;
|
size: string;
|
||||||
date: string;
|
url?: string;
|
||||||
views: number;
|
fileKey?: string;
|
||||||
writer: string;
|
|
||||||
content: string[];
|
|
||||||
attachments?: Array<{ name: string; size: string; url: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DATA: ResourceRow[] = [
|
export default function ResourceDetailPage() {
|
||||||
{
|
const params = useParams();
|
||||||
id: 6,
|
const router = useRouter();
|
||||||
title: '방사선과 물질의 상호작용 관련 학습 자료',
|
const [resource, setResource] = useState<Resource | null>(null);
|
||||||
date: '2025-06-28',
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
views: 1230,
|
const [loading, setLoading] = useState(true);
|
||||||
writer: '강민재',
|
const [error, setError] = useState<string | null>(null);
|
||||||
content: [
|
|
||||||
'방사선(Radiation)이 물질 속을 지나갈 때 발생하는 다양한 상호작용(Interaction)의 기본적인 원리를 이해합니다.',
|
|
||||||
'상호작용의 원리는 방사선 측정, 방사선 이용(의료, 산업), 방사선 차폐 등 방사선 관련 분야의 기본이 됨을 인식합니다.',
|
|
||||||
'방사선의 종류(광자, 하전입자, 중성자 등) 및 에너지에 따라 물질과의 상호작용 형태가 어떻게 달라지는지 학습합니다.',
|
|
||||||
],
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
name: '[PPT] 방사선-물질 상호작용의 3가지 유형.pptx',
|
|
||||||
size: '796.35 KB',
|
|
||||||
url: '#',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: '감마선과 베타선의 특성 및 차이 분석 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 594,
|
|
||||||
writer: '강민재',
|
|
||||||
content: [
|
|
||||||
'감마선과 베타선의 발생 원리, 물질과의 상호작용 차이를 비교합니다.',
|
|
||||||
'차폐 설계 시 고려해야 할 변수들을 사례와 함께 설명합니다.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['방사선량 단위 변환 및 예제 계산을 통해 실무 감각을 익힙니다.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '의료 영상 촬영 시 방사선 안전 수칙 가이드',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['촬영 환경에서의 방사선 안전 수칙을 체크리스트 형태로 정리합니다.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'X선 발생 원리 및 특성에 대한 이해 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['X선의 발생 원리와 에너지 스펙트럼의 특성을 개관합니다.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
content: ['방사선 기초 개념을 한눈에 정리한 입문용 자료입니다.'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function ResourceDetailPage({
|
useEffect(() => {
|
||||||
params,
|
async function fetchResource() {
|
||||||
}: {
|
if (!params?.id) return;
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
try {
|
||||||
const { id } = await params;
|
setLoading(true);
|
||||||
const numericId = Number(id);
|
setError(null);
|
||||||
const item = DATA.find((r) => r.id === numericId);
|
|
||||||
if (!item) return notFound();
|
const response = await apiService.getLibraryItem(params.id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResource: Resource = {
|
||||||
|
id: data.id || data.resourceId || Number(params.id),
|
||||||
|
title: data.title || '',
|
||||||
|
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: data.views || data.viewCount || 0,
|
||||||
|
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||||
|
content: data.content
|
||||||
|
? (Array.isArray(data.content)
|
||||||
|
? data.content
|
||||||
|
: typeof data.content === 'string'
|
||||||
|
? data.content.split('\n').filter((line: string) => line.trim())
|
||||||
|
: [String(data.content)])
|
||||||
|
: [],
|
||||||
|
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첨부파일 정보 처리
|
||||||
|
if (data.attachments && Array.isArray(data.attachments)) {
|
||||||
|
setAttachments(data.attachments.map((att: any) => ({
|
||||||
|
name: att.name || att.fileName || att.filename || '',
|
||||||
|
size: att.size || att.fileSize || '',
|
||||||
|
url: att.url || att.downloadUrl,
|
||||||
|
fileKey: att.fileKey || att.key || att.fileId,
|
||||||
|
})));
|
||||||
|
} else if (transformedResource.hasAttachment && data.attachment) {
|
||||||
|
// 단일 첨부파일인 경우
|
||||||
|
setAttachments([{
|
||||||
|
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||||
|
size: data.attachment.size || data.attachment.fileSize || '',
|
||||||
|
url: data.attachment.url || data.attachment.downloadUrl,
|
||||||
|
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transformedResource.title) {
|
||||||
|
throw new Error('학습 자료를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setResource(transformedResource);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('학습 자료 조회 오류:', err);
|
||||||
|
setError('학습 자료를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResource();
|
||||||
|
}, [params?.id]);
|
||||||
|
|
||||||
|
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||||
|
if (url) {
|
||||||
|
// URL이 있으면 직접 다운로드
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else if (fileKey) {
|
||||||
|
// fileKey가 있으면 API를 통해 다운로드
|
||||||
|
try {
|
||||||
|
const fileUrl = await apiService.getFile(fileKey);
|
||||||
|
if (fileUrl) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl;
|
||||||
|
link.download = fileName || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('파일 다운로드 실패:', err);
|
||||||
|
alert('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center justify-center px-8">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !resource) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-[1440px]">
|
||||||
|
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||||
|
<Link
|
||||||
|
href="/resources"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
학습 자료 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center px-8 pb-8">
|
||||||
|
<p className="text-[16px] font-medium text-[#333c47]">
|
||||||
|
{error || '학습 자료를 찾을 수 없습니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = resource;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -119,7 +189,11 @@ export default async function ResourceDetailPage({
|
|||||||
<span className="text-[#333C47]">{item.writer}</span>
|
<span className="text-[#333C47]">{item.writer}</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">게시일</span>
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
<span className="text-[#333C47]">{item.date}</span>
|
<span className="text-[#333C47]">
|
||||||
|
{item.date.includes('T')
|
||||||
|
? new Date(item.date).toISOString().split('T')[0]
|
||||||
|
: item.date}
|
||||||
|
</span>
|
||||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
<span className="text-[#8C95A1]">조회수</span>
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
||||||
@@ -132,21 +206,25 @@ export default async function ResourceDetailPage({
|
|||||||
{/* 본문 */}
|
{/* 본문 */}
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
||||||
{item.content.map((p, idx) => (
|
{item.content && item.content.length > 0 ? (
|
||||||
|
item.content.map((p, idx) => (
|
||||||
<p key={idx} className="m-0">
|
<p key={idx} className="m-0">
|
||||||
{p}
|
{p}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<p className="m-0 text-[#8C95A1]">내용이 없습니다.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 첨부 파일 */}
|
{/* 첨부 파일 */}
|
||||||
{item.attachments?.length ? (
|
{attachments.length > 0 && (
|
||||||
<div className="p-8 pt-0">
|
<div className="p-8 pt-0">
|
||||||
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
|
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
|
||||||
첨부 파일
|
첨부 파일
|
||||||
</div>
|
</div>
|
||||||
{item.attachments.map((f, idx) => (
|
{attachments.map((f, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
|
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
|
||||||
@@ -158,31 +236,24 @@ export default async function ResourceDetailPage({
|
|||||||
<span className="text-[15px] text-[#1B2027] truncate">
|
<span className="text-[15px] text-[#1B2027] truncate">
|
||||||
{f.name}
|
{f.name}
|
||||||
</span>
|
</span>
|
||||||
|
{f.size && (
|
||||||
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
|
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
|
||||||
{f.size}
|
{f.size}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<button
|
||||||
href={f.url}
|
type="button"
|
||||||
className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] no-underline"
|
onClick={() => handleDownload(f.fileKey, f.url, f.name)}
|
||||||
download
|
className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] cursor-pointer"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
|
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||||
<path
|
|
||||||
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
다운로드
|
다운로드
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,65 +1,97 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import PaperClipSvg from '../svgs/paperclipsvg';
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||||
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||||
type ResourceRow = {
|
import apiService from '../lib/apiService';
|
||||||
id: number;
|
import type { Resource } from '../admin/resources/mockData';
|
||||||
title: string;
|
|
||||||
date: string;
|
|
||||||
views: number;
|
|
||||||
writer: string;
|
|
||||||
hasAttachment?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows: ResourceRow[] = [
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: '방사선과 물질의 상호작용 관련 학습 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
hasAttachment: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: '감마선과 베타선의 특성 및 차이 분석 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 594,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '의료 영상 촬영 시 방사선 안전 수칙 가이드',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'X선 발생 원리 및 특성에 대한 이해 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료',
|
|
||||||
date: '2025-06-28',
|
|
||||||
views: 1230,
|
|
||||||
writer: '강민재',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ResourcesPage() {
|
export default function ResourcesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API에서 학습 자료 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchResources() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await apiService.getLibrary();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||||
|
let resourcesArray: any[] = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
resourcesArray = data;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
resourcesArray = data.items || data.resources || data.data || data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 데이터를 Resource 형식으로 변환
|
||||||
|
const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({
|
||||||
|
id: resource.id || resource.resourceId || 0,
|
||||||
|
title: resource.title || '',
|
||||||
|
date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0],
|
||||||
|
views: resource.views || resource.viewCount || 0,
|
||||||
|
writer: resource.writer || resource.author || resource.createdBy || '관리자',
|
||||||
|
content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined,
|
||||||
|
hasAttachment: resource.hasAttachment || resource.attachment || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(transformedResources);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('학습 자료 목록 조회 오류:', error);
|
||||||
|
// 에러 발생 시 빈 배열로 설정
|
||||||
|
setResources([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResources();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalCount = useMemo(() => resources.length, [resources]);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const sortedResources = useMemo(() => {
|
||||||
|
return [...resources].sort((a, b) => {
|
||||||
|
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
|
||||||
|
return b.date.localeCompare(a.date);
|
||||||
|
});
|
||||||
|
}, [resources]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedResources.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedResources = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
|
return sortedResources.slice(startIndex, endIndex);
|
||||||
|
}, [sortedResources, currentPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white">
|
<div className="w-full bg-white">
|
||||||
@@ -77,14 +109,28 @@ export default function ResourcesPage() {
|
|||||||
{/* 총 건수 */}
|
{/* 총 건수 */}
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
||||||
총 <span className="text-[#384FBF]">{rows.length}</span>건
|
총 <span className="text-[#384FBF]">{totalCount}</span>건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333C47]">
|
||||||
|
로딩 중...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : resources.length === 0 ? (
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
|
||||||
|
<p className="text-[16px] font-medium leading-[1.5] text-[#333C47] text-center">
|
||||||
|
등록된 학습 자료가 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* 표 */}
|
{/* 표 */}
|
||||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
<div className="grid grid-cols-[80px_1fr_140px_120px_120px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
||||||
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
번호
|
번호
|
||||||
</div>
|
</div>
|
||||||
@@ -97,50 +143,139 @@ export default function ResourcesPage() {
|
|||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
조회수
|
조회수
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 whitespace-nowrap">등록자</div>
|
<div className="flex items-center px-4 whitespace-nowrap">작성자</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 바디 */}
|
{/* 바디 */}
|
||||||
<div>
|
<div>
|
||||||
{rows.map((r) => (
|
{paginatedResources.map((resource, index) => {
|
||||||
|
// 번호는 전체 목록에서의 순서 (정렬된 목록 기준)
|
||||||
|
const resourceNumber = sortedResources.length - (currentPage - 1) * ITEMS_PER_PAGE - index;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={resource.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => router.push(`/resources/${r.id}`)}
|
onClick={() => router.push(`/resources/${resource.id}`)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
router.push(`/resources/${r.id}`);
|
router.push(`/resources/${resource.id}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
'grid grid-cols-[80px_1fr_140px_120px_120px] h-[48px] text-[13px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
{r.id}
|
{resourceNumber}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
title={r.title}
|
title={resource.title}
|
||||||
>
|
>
|
||||||
{r.title}
|
{resource.title}
|
||||||
{r.hasAttachment && (
|
{resource.hasAttachment && (
|
||||||
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
{r.date}
|
{formatDate(resource.date)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
{r.views.toLocaleString()}
|
{resource.views.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center px-4">{r.writer}</div>
|
<div className="flex items-center px-4 whitespace-nowrap">{resource.writer}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||||
|
{resources.length > ITEMS_PER_PAGE && (
|
||||||
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
|
{/* First (맨 앞으로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
aria-label="맨 앞 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
{(() => {
|
||||||
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||||
|
const startPage = pageGroup * 10 + 1;
|
||||||
|
const endPage = Math.min(startPage + 9, totalPages);
|
||||||
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
return visiblePages.map((n) => {
|
||||||
|
const active = n === currentPage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(n)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||||
|
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Last (맨 뒤로) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
aria-label="맨 뒤 페이지"
|
||||||
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||||
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user