From e768f267d378864b33718e3e666ea7b3f27c5580 Mon Sep 17 00:00:00 2001 From: wallace Date: Wed, 19 Nov 2025 01:41:27 +0900 Subject: [PATCH] =?UTF-8?q?admin=20=EA=B6=8C=ED=95=9C=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C,=20=EA=B5=90=EC=9C=A1=EA=B3=BC=EC=A0=95=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=A4=911?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/NavBar.tsx | 2 +- src/app/admin/certificates/page.tsx | 37 +- .../admin/courses/CourseRegistrationModal.tsx | 225 ++++++++ src/app/admin/courses/page.tsx | 72 ++- src/app/admin/id/page.tsx | 522 +++++++++++++++++- src/app/admin/lessons/page.tsx | 37 +- src/app/admin/logs/page.tsx | 37 +- src/app/admin/notices/page.tsx | 37 +- src/app/admin/questions/page.tsx | 37 +- src/app/admin/resources/page.tsx | 37 +- src/app/components/AdminSidebar.tsx | 2 +- src/app/svgs/dropdownicon.tsx | 35 ++ 12 files changed, 962 insertions(+), 118 deletions(-) create mode 100644 src/app/admin/courses/CourseRegistrationModal.tsx create mode 100644 src/app/svgs/dropdownicon.tsx diff --git a/src/app/NavBar.tsx b/src/app/NavBar.tsx index d9335df..7333835 100644 --- a/src/app/NavBar.tsx +++ b/src/app/NavBar.tsx @@ -18,7 +18,7 @@ export default function NavBar() { const userMenuRef = useRef(null); const userButtonRef = useRef(null); const hideCenterNav = /^\/[^/]+\/review$/.test(pathname); - const isAdminPage = pathname.startsWith('/admin-id'); + const isAdminPage = pathname.startsWith('/admin'); useEffect(() => { if (!isUserMenuOpen) return; diff --git a/src/app/admin/certificates/page.tsx b/src/app/admin/certificates/page.tsx index a3b61dc..87a64db 100644 --- a/src/app/admin/certificates/page.tsx +++ b/src/app/admin/certificates/page.tsx @@ -5,21 +5,30 @@ import AdminSidebar from "@/app/components/AdminSidebar"; export default function AdminCertificatesPage() { return (
-
- - -
-
-
-

- 수료증 발급/검증키 관리 -

-
- -
-
+ {/* 메인 레이아웃 */} +
+
+ {/* 사이드바 */} +
+
-
+ + {/* 메인 콘텐츠 */} +
+
+ {/* 제목 영역 */} +
+

+ 수료증 발급/검증키 관리 +

+
+ + {/* 콘텐츠 영역 */} +
+
+
+
+
); diff --git a/src/app/admin/courses/CourseRegistrationModal.tsx b/src/app/admin/courses/CourseRegistrationModal.tsx new file mode 100644 index 0000000..cc0d765 --- /dev/null +++ b/src/app/admin/courses/CourseRegistrationModal.tsx @@ -0,0 +1,225 @@ +"use client"; + +import React, { useState, useRef, useEffect, useMemo } from "react"; +import ModalCloseSvg from "@/app/svgs/closexsvg"; +import DropdownIcon from "@/app/svgs/dropdownicon"; +import { getInstructors, type UserRow } from "@/app/admin/id/page"; + +type Props = { + open: boolean; + onClose: () => void; +}; + +export default function CourseRegistrationModal({ open, onClose }: Props) { + const [courseName, setCourseName] = useState(""); + const [instructorId, setInstructorId] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + const modalRef = useRef(null); + + // 강사 목록 가져오기 + // TODO: 나중에 DB에서 가져오도록 변경 시 async/await 사용 + // 예: const [instructors, setInstructors] = useState([]); + // useEffect(() => { getInstructors().then(setInstructors); }, []); + const instructors = useMemo(() => getInstructors(), []); + + // 선택된 강사 정보 + const selectedInstructor = useMemo(() => { + return instructors.find(inst => inst.id === instructorId); + }, [instructors, instructorId]); + + // 외부 클릭 시 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsDropdownOpen(false); + } + }; + + if (isDropdownOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isDropdownOpen]); + + // 모달 클릭 시 이벤트 전파 방지 + const handleModalClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + if (!open) return null; + + return ( +
+ + ); +} + diff --git a/src/app/admin/courses/page.tsx b/src/app/admin/courses/page.tsx index 461b121..e971770 100644 --- a/src/app/admin/courses/page.tsx +++ b/src/app/admin/courses/page.tsx @@ -1,27 +1,67 @@ 'use client'; +import { useState } from "react"; import AdminSidebar from "@/app/components/AdminSidebar"; +import CourseRegistrationModal from "./CourseRegistrationModal"; export default function AdminCoursesPage() { + const [totalCount, setTotalCount] = useState(0); + const [isModalOpen, setIsModalOpen] = useState(false); + return (
-
- - -
-
-
-

- 교육과정 관리 -

-
- -
-
+ {/* 메인 레이아웃 */} +
+
+ {/* 사이드바 */} +
+
-
+ + {/* 메인 콘텐츠 */} +
+
+ {/* 제목 영역 */} +
+

+ 교육과정 관리 +

+
+ + {/* 헤더 영역 (제목과 콘텐츠 사이) */} +
+

+ 총 {totalCount}건 +

+ +
+ + {/* 콘텐츠 영역 */} +
+
+

+ + 등록된 교육과정이 없습니다. +
+ 과목을 등록해주세요. +
+

+
+
+
+
+
+ setIsModalOpen(false)} + />
); -} - +} \ No newline at end of file diff --git a/src/app/admin/id/page.tsx b/src/app/admin/id/page.tsx index fd9604e..8a47af7 100644 --- a/src/app/admin/id/page.tsx +++ b/src/app/admin/id/page.tsx @@ -1,32 +1,237 @@ 'use client'; -import { useState } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import AdminSidebar from "@/app/components/AdminSidebar"; +import DropdownIcon from "@/app/svgs/dropdownicon"; +import ChevronDownSvg from "@/app/svgs/chevrondownsvg"; type TabType = 'all' | 'learner' | 'instructor' | 'admin'; +type RoleType = 'learner' | 'instructor' | 'admin'; +type AccountStatus = 'active' | 'inactive'; + +export type UserRow = { + id: string; + joinDate: string; + name: string; + email: string; + role: RoleType; + status: AccountStatus; +}; + +// 랜덤 데이터 생성 함수 +function generateRandomUsers(count: number): UserRow[] { + const surnames = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임', '한', '오', '서', '신', '권', '황', '안', '송', '전', '홍']; + const givenNames = ['민준', '서준', '도윤', '예준', '시우', '하준', '주원', '지호', '준서', '건우', '서연', '서윤', '지우', '서현', '민서', '하은', '예은', '윤서', '채원', '지원']; + const roles: RoleType[] = ['learner', 'instructor', 'admin']; + const statuses: AccountStatus[] = ['active', 'inactive']; + + const users: UserRow[] = []; + const startDate = new Date('2024-01-01'); + const endDate = new Date('2025-03-01'); + + for (let i = 1; i <= count; i++) { + const surname = surnames[Math.floor(Math.random() * surnames.length)]; + const givenName = givenNames[Math.floor(Math.random() * givenNames.length)]; + const name = `${surname}${givenName}`; + const email = `user${i}@example.com`; + + // 랜덤 날짜 생성 + const randomTime = startDate.getTime() + Math.random() * (endDate.getTime() - startDate.getTime()); + const randomDate = new Date(randomTime); + const joinDate = randomDate.toISOString().split('T')[0]; + + const role = roles[Math.floor(Math.random() * roles.length)]; + const status = statuses[Math.floor(Math.random() * statuses.length)]; + + users.push({ + id: String(i), + joinDate, + name, + email, + role, + status, + }); + } + + return users; +} + +const mockUsers: UserRow[] = generateRandomUsers(111); + +// 강사 목록 가져오기 함수 export +// TODO: 나중에 DB에서 가져오도록 변경 예정 +// 예: export async function getInstructors(): Promise { +// const response = await fetch('/api/instructors'); +// return response.json(); +// } +export function getInstructors(): UserRow[] { + // 현재는 mock 데이터 사용, 나중에 DB에서 가져오도록 변경 + return mockUsers.filter(user => user.role === 'instructor' && user.status === 'active'); +} + +const roleLabels: Record = { + learner: '학습자', + instructor: '강사', + admin: '관리자', +}; + +const statusLabels: Record = { + active: '활성화', + inactive: '비활성화', +}; export default function AdminIdPage() { const [activeTab, setActiveTab] = useState('all'); + const [users, setUsers] = useState(mockUsers); + const [openDropdownId, setOpenDropdownId] = useState(null); + const [isActivateModalOpen, setIsActivateModalOpen] = useState(false); + const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false); + const [selectedUserId, setSelectedUserId] = useState(null); + const [showToast, setShowToast] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const dropdownRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + + const ITEMS_PER_PAGE = 10; + + const filteredUsers = useMemo(() => { + return activeTab === 'all' + ? users + : users.filter(user => user.role === activeTab); + }, [activeTab, users]); + + const totalPages = Math.ceil(filteredUsers.length / ITEMS_PER_PAGE); + const paginatedUsers = useMemo(() => { + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + return filteredUsers.slice(startIndex, endIndex); + }, [filteredUsers, currentPage]); + + // 탭 변경 시 첫 페이지로 리셋 + useEffect(() => { + setCurrentPage(1); + }, [activeTab]); + + // 외부 클릭 시 드롭다운 닫기 + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (openDropdownId) { + const dropdownElement = dropdownRefs.current[openDropdownId]; + if (dropdownElement && !dropdownElement.contains(event.target as Node)) { + setOpenDropdownId(null); + } + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [openDropdownId]); + + function openActivateModal(userId: string) { + setSelectedUserId(userId); + setIsActivateModalOpen(true); + } + + function handleActivateConfirm() { + if (selectedUserId) { + setUsers(prevUsers => + prevUsers.map(user => + user.id === selectedUserId + ? { ...user, status: 'active' } + : user + ) + ); + setToastMessage('계정을 활성화했습니다.'); + setShowToast(true); + setTimeout(() => { + setShowToast(false); + }, 3000); + } + setIsActivateModalOpen(false); + setSelectedUserId(null); + } + + function handleActivateCancel() { + setIsActivateModalOpen(false); + setSelectedUserId(null); + } + + function openDeactivateModal(userId: string) { + setSelectedUserId(userId); + setIsDeactivateModalOpen(true); + } + + function handleDeactivateConfirm() { + if (selectedUserId) { + setUsers(prevUsers => + prevUsers.map(user => + user.id === selectedUserId + ? { ...user, status: 'inactive' } + : user + ) + ); + setToastMessage('계정을 비활성화했습니다.'); + setShowToast(true); + setTimeout(() => { + setShowToast(false); + }, 3000); + } + setIsDeactivateModalOpen(false); + setSelectedUserId(null); + } + + function handleDeactivateCancel() { + setIsDeactivateModalOpen(false); + setSelectedUserId(null); + } + + function toggleAccountStatus(userId: string) { + const user = users.find(u => u.id === userId); + if (user && user.status === 'inactive') { + openActivateModal(userId); + } else if (user && user.status === 'active') { + openDeactivateModal(userId); + } + } + + function toggleDropdown(userId: string) { + setOpenDropdownId(openDropdownId === userId ? null : userId); + } + + function changeUserRole(userId: string, newRole: RoleType) { + setUsers(prevUsers => + prevUsers.map(user => + user.id === userId + ? { ...user, role: newRole } + : user + ) + ); + setOpenDropdownId(null); + } return (
{/* 메인 레이아웃 */} -
- {/* 사이드바 */} - +
+
+ {/* 사이드바 */} +
+ +
- {/* 메인 콘텐츠 */} -
-
+ {/* 메인 콘텐츠 */} +
+
{/* 제목 영역 */} -
+

권한 설정

- {/* 탭 네비게이션 */} -
+
{[ { id: 'all' as TabType, label: '전체' }, @@ -55,16 +260,301 @@ export default function AdminIdPage() {
{/* 콘텐츠 영역 */} -
-
-

- 현재 관리할 수 있는 회원 계정이 없습니다. -

-
+
+ {filteredUsers.length === 0 ? ( +
+

+ 현재 관리할 수 있는 회원 계정이 없습니다. +

+
+ ) : ( + <> +
+
+ + + + + + + + + + + + + + + + + + + + + {paginatedUsers.map((user) => ( + + + + + + + + + ))} + +
가입일성명아이디(이메일)권한설정계정상태계정관리
+ {user.joinDate} + + {user.name} + + {user.email} + +
+ {roleLabels[user.role]} +
{ dropdownRefs.current[user.id] = el; }} + className="relative" + > + + {openDropdownId === user.id && ( +
+ {(['learner', 'instructor', 'admin'] as RoleType[]).map((role) => ( + + ))} +
+ )} +
+
+
+ {user.status === 'active' ? ( +
+ + {statusLabels[user.status]} + +
+ ) : ( +
+ + {statusLabels[user.status]} + +
+ )} +
+ +
+
+
+ + {/* 페이지네이션 - 10개 초과일 때만 표시 */} + {filteredUsers.length > ITEMS_PER_PAGE && (() => { + // 페이지 번호를 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 ( +
+
+ {/* First (맨 앞으로) */} + + + {/* Prev */} + + + {/* Numbers */} + {visiblePages.map((n) => { + const active = n === currentPage; + return ( + + ); + })} + + {/* Next */} + + + {/* Last (맨 뒤로) */} + +
+
+ ); + })()} + + )}
+
+ + {/* 활성화 확인 모달 */} + {isActivateModalOpen && ( +
+ + )} + + {/* 비활성화 확인 모달 */} + {isDeactivateModalOpen && ( +
+ + )} + + {/* 활성화 완료 토스트 */} + {showToast && ( +
+
+
+ + + + +
+

+ {toastMessage} +

+
+
+ )}
); } diff --git a/src/app/admin/lessons/page.tsx b/src/app/admin/lessons/page.tsx index 6672f51..92a19e4 100644 --- a/src/app/admin/lessons/page.tsx +++ b/src/app/admin/lessons/page.tsx @@ -5,21 +5,30 @@ import AdminSidebar from "@/app/components/AdminSidebar"; export default function AdminLessonsPage() { return (
-
- - -
-
-
-

- 강좌 관리 -

-
- -
-
+ {/* 메인 레이아웃 */} +
+
+ {/* 사이드바 */} +
+
-
+ + {/* 메인 콘텐츠 */} +
+
+ {/* 제목 영역 */} +
+

+ 강좌 관리 +

+
+ + {/* 콘텐츠 영역 */} +
+
+
+
+
); diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx index 6200b3d..2b11930 100644 --- a/src/app/admin/logs/page.tsx +++ b/src/app/admin/logs/page.tsx @@ -5,21 +5,30 @@ import AdminSidebar from "@/app/components/AdminSidebar"; export default function AdminLogsPage() { return (
-
- - -
-
-
-

- 로그/접속 기록 -

-
- -
-
+ {/* 메인 레이아웃 */} +
+
+ {/* 사이드바 */} +
+
-
+ + {/* 메인 콘텐츠 */} +
+
+ {/* 제목 영역 */} +
+

+ 로그/접속 기록 +

+
+ + {/* 콘텐츠 영역 */} +
+
+
+
+
); diff --git a/src/app/admin/notices/page.tsx b/src/app/admin/notices/page.tsx index d10eaad..0b02d74 100644 --- a/src/app/admin/notices/page.tsx +++ b/src/app/admin/notices/page.tsx @@ -5,21 +5,30 @@ import AdminSidebar from "@/app/components/AdminSidebar"; export default function AdminNoticesPage() { return (
-
- - -
-
-
-

- 공지사항 -

-
- -
-
+ {/* 메인 레이아웃 */} +
+
+ {/* 사이드바 */} +
+
-
+ + {/* 메인 콘텐츠 */} +
+
+ {/* 제목 영역 */} +
+

+ 공지사항 +

+
+ + {/* 콘텐츠 영역 */} +
+
+
+
+
); diff --git a/src/app/admin/questions/page.tsx b/src/app/admin/questions/page.tsx index 1535b53..3dd1785 100644 --- a/src/app/admin/questions/page.tsx +++ b/src/app/admin/questions/page.tsx @@ -5,21 +5,30 @@ import AdminSidebar from "@/app/components/AdminSidebar"; export default function AdminQuestionsPage() { return (
-
- - -
-
-
-

- 문제 은행 -

-
- -
-
+ {/* 메인 레이아웃 */} +
+
+ {/* 사이드바 */} +
+
-
+ + {/* 메인 콘텐츠 */} +
+
+ {/* 제목 영역 */} +
+

+ 문제 은행 +

+
+ + {/* 콘텐츠 영역 */} +
+
+
+
+
); diff --git a/src/app/admin/resources/page.tsx b/src/app/admin/resources/page.tsx index 2053594..f0a0b85 100644 --- a/src/app/admin/resources/page.tsx +++ b/src/app/admin/resources/page.tsx @@ -5,21 +5,30 @@ import AdminSidebar from "@/app/components/AdminSidebar"; export default function AdminResourcesPage() { return (
-
- - -
-
-
-

- 학습 자료실 -

-
- -
-
+ {/* 메인 레이아웃 */} +
+
+ {/* 사이드바 */} +
+
-
+ + {/* 메인 콘텐츠 */} +
+
+ {/* 제목 영역 */} +
+

+ 학습 자료실 +

+
+ + {/* 콘텐츠 영역 */} +
+
+
+
+
); diff --git a/src/app/components/AdminSidebar.tsx b/src/app/components/AdminSidebar.tsx index 0960f84..3553a1e 100644 --- a/src/app/components/AdminSidebar.tsx +++ b/src/app/components/AdminSidebar.tsx @@ -23,7 +23,7 @@ export default function AdminSidebar() { const pathname = usePathname(); return ( -