diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1dcf3d26..8cd345a4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,8 +13,12 @@ import Login from './pages/Login'; import SignUp from './pages/SignUp'; import QuantTradingDashboard from './pages/QuantTradingDashboard'; import BacktestResult from './pages/BacktestResult.jsx'; +import AdminHome from './pages/AdminHome.jsx'; +import AdminMemberApproval from './pages/AdminMemberApproval.jsx'; +import AdminMemberManage from './pages/AdminMemberManage.jsx'; +import AdminExcelUpload from './pages/AdminExcelUpload.jsx'; -import CheckInPage from './components/attendancemanage/qrmanagement/CheckInPage.jsx'; +// import CheckInPage from './components/attendancemanage/qrmanagement/CheckInPage.jsx'; import QrRenderPage from './components/attendancemanage/qrmanagement/QrRenderPage.jsx'; import OAuthSuccess from './pages/OAuthSuccess.jsx'; @@ -30,6 +34,7 @@ import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import ProtectedRoute from './components/protectedRoute.jsx'; +import AdminRoute from './components/AdminRoute.jsx'; function App() { return ( @@ -66,8 +71,16 @@ function App() { } /> } /> } /> - } /> + {/* } /> */} + }> + } /> + } /> + } + /> + } /> + diff --git a/frontend/src/components/AdminExcelUpload/AdminExcelUpload.jsx b/frontend/src/components/AdminExcelUpload/AdminExcelUpload.jsx new file mode 100644 index 00000000..b2a32ec6 --- /dev/null +++ b/frontend/src/components/AdminExcelUpload/AdminExcelUpload.jsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { Upload, FileSpreadsheet, Loader2, Trash2, CheckCircle } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { uploadAdminUsersExcel } from '../../utils/adminUserApi'; +import AdminExcelUploadHeader from './AdminExcelUploadHeader'; +import styles from './AdminExcelUpload.module.css'; + +const ALLOWED_EXTENSIONS = ['.xlsx', '.xls']; + +const isExcelFile = (targetFile) => { + if (!targetFile) return false; + const lowerName = targetFile.name.toLowerCase(); + return ALLOWED_EXTENSIONS.some((ext) => lowerName.endsWith(ext)); +}; + +const AdminExcelUpload = () => { + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const [isDragOver, setIsDragOver] = useState(false); + + const handleSelectFile = (file) => { + if (!file) return; + + if (!isExcelFile(file)) { + toast.error('엑셀 파일(.xlsx, .xls)만 업로드할 수 있습니다.'); + return; + } + + setSelectedFile(file); + setUploadResult(null); + }; + + const handleInputChange = (event) => { + const file = event.target.files?.[0]; + handleSelectFile(file); + event.target.value = ''; + }; + + const handleDragOver = (event) => { + event.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = (event) => { + event.preventDefault(); + setIsDragOver(false); + }; + + const handleDrop = (event) => { + event.preventDefault(); + setIsDragOver(false); + const file = event.dataTransfer.files?.[0]; + handleSelectFile(file); + }; + + const handleUpload = async () => { + if (!selectedFile) { + toast.error('업로드할 파일을 먼저 선택해주세요.'); + return; + } + + setIsUploading(true); + try { + const result = await uploadAdminUsersExcel({ file: selectedFile }); + setUploadResult(result); + toast.success('엑셀 명단 업로드 및 동기화가 완료되었습니다.'); + } catch (error) { + toast.error(error?.response?.data?.message || error?.message || '엑셀 업로드에 실패했습니다.'); + } finally { + setIsUploading(false); + } + }; + + const resetFile = () => { + setSelectedFile(null); + setUploadResult(null); + }; + + const handleDownloadTemplate = () => { + toast.info('템플릿 다운로드 기능은 준비 중입니다.'); + }; + + return ( +
+ + +
+

엑셀 명단 업로드 및 동기화

+

+ 회원 엑셀 파일을 업로드하면 서버에서 전체 동기화를 진행합니다. +

+ +
+ +

.xlsx 또는 .xls 파일을 드래그 앤 드롭하거나 선택하세요.

+ + + + {selectedFile && ( +
+ {selectedFile.name} + +
+ )} + + +
+
+ + {uploadResult && ( +
+
+ +

업로드 결과

+
+
+            {JSON.stringify(uploadResult, null, 2)}
+          
+
+ )} +
+ ); +}; + +export default AdminExcelUpload; diff --git a/frontend/src/components/AdminExcelUpload/AdminExcelUpload.module.css b/frontend/src/components/AdminExcelUpload/AdminExcelUpload.module.css new file mode 100644 index 00000000..6796f446 --- /dev/null +++ b/frontend/src/components/AdminExcelUpload/AdminExcelUpload.module.css @@ -0,0 +1,205 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.panel { + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fff; + padding: 20px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 700; +} + +.description { + margin: 8px 0 0; + color: #6b7280; + font-size: 14px; +} + +.guideHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.guideTitle { + margin: 0; + font-size: 18px; + font-weight: 700; +} + +.guideGrid { + margin-top: 14px; + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.guideItem { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.guideStep { + width: 24px; + height: 24px; + border-radius: 999px; + background: #e5e7eb; + color: #111827; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; +} + +.guideItemTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #111827; +} + +.guideItemDesc { + margin: 4px 0 0; + font-size: 12px; + color: #6b7280; +} + +.guideActionRow { + margin-top: 12px; +} + +.uploadBox { + margin-top: 16px; + border: 2px dashed #d1d5db; + border-radius: 12px; + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: #6b7280; +} + +.uploadBoxDragOver { + border-color: #111827; + background: #f3f4f6; +} + +.uploadText { + margin: 0; + font-size: 14px; +} + +.fileLabel { + border: 1px solid #d1d5db; + background: #fff; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 14px; +} + +.fileInput { + display: none; +} + +.selectedRow { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #f3f4f6; + border-radius: 8px; + padding: 10px 12px; + background: #f9fafb; +} + +.fileName { + min-width: 0; + flex: 1; + font-size: 13px; + color: #111827; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ghostButton { + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + padding: 6px 10px; + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12px; + cursor: pointer; +} + +.uploadButton { + border: 1px solid #111827; + background: #111827; + color: #fff; + border-radius: 8px; + padding: 10px 14px; + display: inline-flex; + gap: 8px; + align-items: center; + justify-content: center; + font-size: 14px; + cursor: pointer; +} + +.uploadButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.resultTitleWrap { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.resultTitle { + margin: 0; + font-size: 16px; +} + +.resultBox { + margin: 0; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f9fafb; + padding: 12px; + overflow: auto; + font-size: 12px; + line-height: 1.5; +} + +@media (min-width: 768px) { + .guideGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/frontend/src/components/AdminExcelUpload/AdminExcelUploadHeader.jsx b/frontend/src/components/AdminExcelUpload/AdminExcelUploadHeader.jsx new file mode 100644 index 00000000..b85b1e2f --- /dev/null +++ b/frontend/src/components/AdminExcelUpload/AdminExcelUploadHeader.jsx @@ -0,0 +1,48 @@ +import { Download, Info } from 'lucide-react'; +import styles from './AdminExcelUpload.module.css'; + +const AdminExcelUploadHeader = ({ onDownloadTemplate }) => { + return ( +
+
+ +

사용 안내

+
+ +
+
+
1
+
+

템플릿 다운로드

+

아래 버튼으로 엑셀 템플릿을 다운로드하세요.

+
+
+ +
+
2
+
+

데이터 입력

+

템플릿에 맞춰 회원 정보를 입력하세요.

+
+
+ +
+
3
+
+

파일 업로드

+

완성된 파일을 업로드해 동기화를 진행하세요.

+
+
+
+ +
+ +
+
+ ); +}; + +export default AdminExcelUploadHeader; \ No newline at end of file diff --git a/frontend/src/components/AdminHome/AdminHeader.jsx b/frontend/src/components/AdminHome/AdminHeader.jsx new file mode 100644 index 00000000..48481184 --- /dev/null +++ b/frontend/src/components/AdminHome/AdminHeader.jsx @@ -0,0 +1,37 @@ +import { Bell, Search, User } from 'lucide-react'; +import styles from './AdminHeader.module.css'; + +const AdminHeader = ({ title }) => { + return ( +
+
+
+ {title &&

{title}

} +
+ +
+
+ + +
+ + + + +
+
+
+ ); +}; + +export default AdminHeader; diff --git a/frontend/src/components/AdminHome/AdminHeader.module.css b/frontend/src/components/AdminHome/AdminHeader.module.css new file mode 100644 index 00000000..435f806c --- /dev/null +++ b/frontend/src/components/AdminHome/AdminHeader.module.css @@ -0,0 +1,91 @@ +.header { + height: 56px; + border-bottom: 1px solid #e5e7eb; + background: #ffffff; + position: sticky; + top: 0; + z-index: 10; +} + +.inner { + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; +} + +.titleWrap { + display: flex; + align-items: center; +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 700; + color: #111827; +} + +.actions { + display: flex; + align-items: center; + gap: 10px; +} + +.searchWrap { + position: relative; + display: none; +} + +.searchIcon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; +} + +.searchInput { + width: 260px; + height: 36px; + padding: 0 10px 0 32px; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.searchInput:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; + border-color: #2563eb; +} + +.iconButton, +.userButton { + width: 36px; + height: 36px; + border: 1px solid #e5e7eb; + border-radius: 999px; + background: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; +} + +.dot { + position: absolute; + top: 8px; + right: 9px; + width: 7px; + height: 7px; + background: #ef4444; + border-radius: 999px; +} + +@media (min-width: 768px) { + .searchWrap { + display: block; + } +} diff --git a/frontend/src/components/AdminHome/AdminSidebar.jsx b/frontend/src/components/AdminHome/AdminSidebar.jsx new file mode 100644 index 00000000..c293929c --- /dev/null +++ b/frontend/src/components/AdminHome/AdminSidebar.jsx @@ -0,0 +1,138 @@ +import { Link, NavLink, useLocation } from 'react-router-dom'; +import { + Users, + FileText, + Calendar, + Star, + Gamepad2, + BarChart3, + Activity, + Server, + Database, + Settings, + Shield, + Upload, +} from 'lucide-react'; +import styles from './AdminSidebar.module.css'; + +const presidentMenuItems = [ + { + category: '회원', + items: [ + { label: '회원 관리', href: '/admin/members', icon: Users }, + { label: '가입 승인', href: '/admin/members/approval', icon: Shield }, + { label: '엑셀 업로드', href: '/admin/members/upload', icon: Upload }, + ], + }, + { + category: '콘텐츠', + items: [ + { label: '게시물 관리', href: '/admin/posts', icon: FileText, disabled: true }, + { label: '출석 관리', href: '/admin/attendance', icon: Calendar, disabled: true }, + { label: '포인트 관리', href: '/admin/points', icon: Star, disabled: true }, + ], + }, + { + category: '시스템', + items: [ + { label: '게임/툴 관리', href: '/admin/tools', icon: Gamepad2, disabled: true }, + { label: '통계 대시보드', href: '/admin/dashboard', icon: BarChart3, disabled: true }, + ], + }, +]; + +const devMenuItems = [ + { + category: '모니터링', + items: [ + { label: '실시간 로그', href: '/admin/dev/logs', icon: Activity }, + { label: '시스템 리소스', href: '/admin/dev/resources', icon: Server }, + { label: 'API 통계', href: '/admin/dev/api', icon: BarChart3 }, + { label: 'DB 상태', href: '/admin/dev/database', icon: Database }, + ], + }, +]; + +const AdminSidebar = () => { + const { pathname } = useLocation(); + const isDevSection = pathname.startsWith('/admin/dev'); + const currentSections = (isDevSection ? devMenuItems : presidentMenuItems) + .map((section) => ({ + ...section, + items: section.items.filter((item) => !item.disabled), + })) + .filter((section) => section.items.length > 0); + + return ( + + ); +}; + +export default AdminSidebar; diff --git a/frontend/src/components/AdminHome/AdminSidebar.module.css b/frontend/src/components/AdminHome/AdminSidebar.module.css new file mode 100644 index 00000000..1a69c204 --- /dev/null +++ b/frontend/src/components/AdminHome/AdminSidebar.module.css @@ -0,0 +1,150 @@ +.sidebar { + width: 240px; + min-height: 100vh; + border-right: 1px solid #e5e7eb; + background: #ffffff; + display: flex; + flex-direction: column; +} + +.logoSection { + height: 56px; + display: flex; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid #e5e7eb; +} + +.logoLink { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: #111827; +} + +.logoMark { + width: 28px; + height: 28px; + border-radius: 8px; + background: #111827; + display: flex; + align-items: center; + justify-content: center; +} + +.logoMarkText { + color: #ffffff; + font-size: 12px; + font-weight: 700; +} + +.logoText { + font-size: 14px; + font-weight: 600; +} + +.tabSection { + padding: 12px; + border-bottom: 1px solid #e5e7eb; +} + +.tabWrap { + display: flex; + gap: 4px; + background: #f3f4f6; + border-radius: 8px; + padding: 4px; +} + +.tabButton { + flex: 1; + text-align: center; + padding: 6px 0; + border-radius: 6px; + text-decoration: none; + color: #6b7280; + font-size: 12px; + font-weight: 600; +} + +.tabActive { + background: #111827; + color: #ffffff; +} + +.nav { + flex: 1; + overflow-y: auto; + padding: 16px 0; +} + +.section { + margin-bottom: 20px; +} + +.sectionTitleWrap { + padding: 0 16px; + margin-bottom: 8px; +} + +.sectionTitle { + font-size: 11px; + font-weight: 700; + color: #6b7280; +} + +.menuList { + list-style: none; + margin: 0; + padding: 0 8px; +} + +.menuLink { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + text-decoration: none; + color: #6b7280; + border-radius: 8px; + font-size: 14px; +} + +.menuLink:hover { + background: #f3f4f6; + color: #111827; +} + +.menuLinkActive { + background: #f3f4f6; + color: #111827; + font-weight: 600; +} + +.footer { + border-top: 1px solid #e5e7eb; + padding: 12px; +} + +.settingsLink { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: #6b7280; + padding: 9px 12px; + border-radius: 8px; + font-size: 14px; +} + +.settingsLink:hover { + background: #f3f4f6; + color: #111827; +} + +@media (max-width: 1023px) { + .sidebar { + display: none; + } +} diff --git a/frontend/src/components/AdminHome/DashboardStats.jsx b/frontend/src/components/AdminHome/DashboardStats.jsx new file mode 100644 index 00000000..4b2802de --- /dev/null +++ b/frontend/src/components/AdminHome/DashboardStats.jsx @@ -0,0 +1,15 @@ +const DashboardStats = ({ stats = [], styles }) => { + return ( +
+ {stats.map((stat) => ( +
+

{stat.title}

+

{stat.value}

+

{stat.description}

+
+ ))} +
+ ); +}; + +export default DashboardStats; diff --git a/frontend/src/components/AdminHome/MemberList.jsx b/frontend/src/components/AdminHome/MemberList.jsx new file mode 100644 index 00000000..77dddaf9 --- /dev/null +++ b/frontend/src/components/AdminHome/MemberList.jsx @@ -0,0 +1,30 @@ +const MemberList = ({ members = [] }) => { + if (members.length === 0) { + return
표시할 회원이 없습니다.
; + } + + return ( +
+ + + + + + + + + + {members.map((member) => ( + + + + + + ))} + +
이름권한상태
{member.name}{member.role}{member.status}
+
+ ); +}; + +export default MemberList; diff --git a/frontend/src/components/AdminHome/MembersPanel.jsx b/frontend/src/components/AdminHome/MembersPanel.jsx new file mode 100644 index 00000000..bad5434d --- /dev/null +++ b/frontend/src/components/AdminHome/MembersPanel.jsx @@ -0,0 +1,14 @@ +import MemberList from './MemberList'; + +const MembersPanel = ({ members = [], styles }) => { + return ( +
+
+

회원 목록

+
+ +
+ ); +}; + +export default MembersPanel; diff --git a/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx b/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx new file mode 100644 index 00000000..d26d863f --- /dev/null +++ b/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx @@ -0,0 +1,73 @@ +import { Link } from 'react-router-dom'; +import { + approvePendingMember, + rejectPendingMember, +} from '../../utils/adminMemberManageData'; + +const PendingApprovalsPanel = ({ members = [], styles, onChanged }) => { + const handleApprove = async (member) => { + if (!window.confirm(`${member.name}님의 가입을 승인하시겠습니까?`)) { + return; + } + + try { + await approvePendingMember({ userId: member.id }); + await onChanged?.(); + } catch (error) { + window.alert(error?.message || '가입 승인 처리에 실패했습니다.'); + } + }; + + const handleReject = async (member) => { + if (!window.confirm(`${member.name}님의 가입을 거절하시겠습니까?`)) { + return; + } + + try { + await rejectPendingMember({ userId: member.id }); + await onChanged?.(); + } catch (error) { + window.alert(error?.message || '가입 거절 처리에 실패했습니다.'); + } + }; + + return ( +
+
+

가입 승인 대기

+ + 전체 보기 + +
+
    + {members.map((member) => ( +
  • +
    +

    {member.name}

    +

    {member.email}

    +
    +
    + {member.requestedAt} + + +
    +
  • + ))} +
+
+ ); +}; + +export default PendingApprovalsPanel; diff --git a/frontend/src/components/AdminHome/QuickActionsPanel.jsx b/frontend/src/components/AdminHome/QuickActionsPanel.jsx new file mode 100644 index 00000000..9fe6f58e --- /dev/null +++ b/frontend/src/components/AdminHome/QuickActionsPanel.jsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router-dom'; + +const QuickActionsPanel = ({ actions = [], styles }) => { + return ( +
+
+

빠른 작업

+
+
+ {actions.map((action) => ( + + {action.label} + + ))} +
+
+ ); +}; + +export default QuickActionsPanel; diff --git a/frontend/src/components/AdminHome/RecentActivitiesPanel.jsx b/frontend/src/components/AdminHome/RecentActivitiesPanel.jsx new file mode 100644 index 00000000..08b4ed8a --- /dev/null +++ b/frontend/src/components/AdminHome/RecentActivitiesPanel.jsx @@ -0,0 +1,19 @@ +const RecentActivitiesPanel = ({ activities = [], styles }) => { + return ( +
+
+

최근 활동

+
+
    + {activities.map((activity) => ( +
  • +

    {activity.message}

    +

    {activity.time}

    +
  • + ))} +
+
+ ); +}; + +export default RecentActivitiesPanel; diff --git a/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx new file mode 100644 index 00000000..725b998a --- /dev/null +++ b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx @@ -0,0 +1,365 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Search, + CheckCircle, + XCircle, + Clock, + AlertCircle, + Check, + X, +} from 'lucide-react'; +import styles from './AdminMemberApproval.module.css'; +import { + approvePendingMember, + approvePendingMembersBulk, + getAdminMemberManageData, + rejectPendingMember, + rejectPendingMembersBulk, +} from '../../utils/adminMemberManageData'; + +const AdminMemberApprovalList = () => { + // 검색/선택/모달 제어 상태 + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + const [rejectDialogOpen, setRejectDialogOpen] = useState(false); + const [approveDialogOpen, setApproveDialogOpen] = useState(false); + const [actionTarget, setActionTarget] = useState('single'); + const [targetMember, setTargetMember] = useState(null); + + // 데이터 상태 + const [pendingMembers, setPendingMembers] = useState([]); + const [monthlyApprovedCount, setMonthlyApprovedCount] = useState(0); + const [monthlyRejectedCount, setMonthlyRejectedCount] = useState(0); + + // 가입 승인 대기 목록/통계 조회 + const loadPendingMembers = async ({ keyword } = {}) => { + try { + const data = await getAdminMemberManageData({ keyword }); + setPendingMembers(data.pendingMembers || []); + setMonthlyApprovedCount((prev) => + data.monthlyApprovedCount != null + ? Math.max(prev, data.monthlyApprovedCount) + : prev + ); + setMonthlyRejectedCount((prev) => + data.monthlyRejectedCount != null + ? Math.max(prev, data.monthlyRejectedCount) + : prev + ); + } catch (error) { + window.alert(error?.message || '가입 승인 대기 회원을 불러오지 못했습니다.'); + setPendingMembers([]); + } + }; + + useEffect(() => { + loadPendingMembers(); + }, []); + + // 검색어 기준 클라이언트 필터링 + const filteredMembers = useMemo(() => { + const normalizedQuery = searchQuery.trim().toLowerCase(); + if (!normalizedQuery) return pendingMembers; + + return pendingMembers.filter( + (member) => + member.name.toLowerCase().includes(normalizedQuery) || + member.email.toLowerCase().includes(normalizedQuery) || + member.studentId.includes(normalizedQuery) + ); + }, [pendingMembers, searchQuery]); + + const toggleSelect = (id) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id] + ); + }; + + // 현재 필터된 목록 전체 선택/해제 + const toggleSelectAll = () => { + if (selectedIds.length === filteredMembers.length) { + setSelectedIds([]); + return; + } + setSelectedIds(filteredMembers.map((member) => member.id)); + }; + + const handleApprove = (member) => { + if (member) { + setTargetMember(member); + setActionTarget('single'); + } else { + setActionTarget('bulk'); + } + setApproveDialogOpen(true); + }; + + const handleReject = (member) => { + if (member) { + setTargetMember(member); + setActionTarget('single'); + } else { + setActionTarget('bulk'); + } + setRejectDialogOpen(true); + }; + + // 승인 확정 처리 (단건/일괄) + const confirmApprove = async () => { + try { + if (actionTarget === 'single' && targetMember) { + await approvePendingMember({ userId: targetMember.id }); + setMonthlyApprovedCount((prev) => prev + 1); + } else { + await approvePendingMembersBulk({ userIds: selectedIds }); + setMonthlyApprovedCount((prev) => prev + selectedIds.length); + } + + setApproveDialogOpen(false); + setSelectedIds([]); + setTargetMember(null); + await loadPendingMembers({ keyword: searchQuery.trim() || undefined }); + } catch (error) { + window.alert(error?.message || '가입 승인 처리에 실패했습니다.'); + } + }; + + // 거절 확정 처리 (단건/일괄) + const confirmReject = async () => { + try { + if (actionTarget === 'single' && targetMember) { + await rejectPendingMember({ userId: targetMember.id }); + setMonthlyRejectedCount((prev) => prev + 1); + } else { + await rejectPendingMembersBulk({ userIds: selectedIds }); + setMonthlyRejectedCount((prev) => prev + selectedIds.length); + } + + setRejectDialogOpen(false); + setSelectedIds([]); + setTargetMember(null); + await loadPendingMembers({ keyword: searchQuery.trim() || undefined }); + } catch (error) { + window.alert(error?.message || '가입 거절 처리에 실패했습니다.'); + } + }; + + return ( +
+
+
+
+ +
+
+

{pendingMembers.length}

+

대기 중

+
+
+ +
+
+ +
+
+

{monthlyApprovedCount}

+

이번 달 승인

+
+
+ +
+
+ +
+
+

{monthlyRejectedCount}

+

이번 달 거절

+
+
+
+ +
+
+ + setSearchQuery(event.target.value)} + /> +
+ + {selectedIds.length > 0 && ( +
+ {selectedIds.length}명 선택됨 + + +
+ )} +
+ +
+ + + + + + + + + + + + + + + {filteredMembers.map((member) => ( + + + + + + + + + + + ))} + +
+ 0 + } + onChange={toggleSelectAll} + /> + 신청자학번학과연락처신청일시가입 메시지작업
+ toggleSelect(member.id)} + /> + +
+
{member.name?.[0] || '?'}
+
+

{member.name}

+

{member.email}

+
+
+
{member.studentId}{member.department}{member.phoneNumber || '-'} +
+ + - +
+
+ {member.message ? ( +

+ {member.message} +

+ ) : ( + - + )} +
+
+ + +
+
+ + {filteredMembers.length === 0 && ( +
+ +

대기 중인 가입 신청이 없습니다.

+
+ )} +
+ + {approveDialogOpen && ( +
+
+

가입 승인 확인

+

+ {actionTarget === 'single' && targetMember + ? `${targetMember.name}님의 가입을 승인하시겠습니까?` + : `${selectedIds.length}명의 가입을 일괄 승인하시겠습니까?`} +

+
+ + +
+
+
+ )} + + {rejectDialogOpen && ( +
+
+

가입 거절 확인

+

+ {actionTarget === 'single' && targetMember + ? `${targetMember.name}님의 가입을 거절하시겠습니까?` + : `${selectedIds.length}명의 가입을 일괄 거절하시겠습니까?`} +

+
+ + +
+
+
+ )} +
+ ); +}; + +export default AdminMemberApprovalList; diff --git a/frontend/src/components/AdminMemberApproval/AdminMemberApproval.module.css b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.module.css new file mode 100644 index 00000000..d2580f6a --- /dev/null +++ b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.module.css @@ -0,0 +1,291 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 12px; +} + +.statCard { + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fff; + padding: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.iconBox { + width: 44px; + height: 44px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.iconWarning { + background: #fef3c7; + color: #d97706; +} + +.iconSuccess { + background: #dcfce7; + color: #16a34a; +} + +.iconDanger { + background: #fee2e2; + color: #dc2626; +} + +.statValue { + margin: 0; + font-size: 26px; + font-weight: 700; +} + +.statLabel { + margin: 0; + font-size: 13px; + color: #6b7280; +} + +.searchRow { + display: flex; + flex-direction: column; + gap: 12px; +} + +.searchWrap { + position: relative; + width: 100%; + max-width: 360px; +} + +.searchIcon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; +} + +.searchInput { + width: 100%; + height: 38px; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 0 10px 0 34px; + box-sizing: border-box; +} + +.bulkActions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.selectedBadge { + font-size: 12px; + border: 1px solid #d1d5db; + border-radius: 999px; + padding: 4px 10px; +} + +.tableWrap { + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; + background: #fff; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + border-bottom: 1px solid #f3f4f6; + padding: 12px; + font-size: 13px; + text-align: left; + vertical-align: middle; +} + +.table th { + background: #f9fafb; + color: #6b7280; + font-weight: 600; +} + +.checkboxCell { + width: 40px; +} + +.memberInfo { + display: flex; + align-items: center; + gap: 10px; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #fef3c7; + color: #d97706; + font-size: 13px; + font-weight: 600; +} + +.memberName { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.memberEmail { + margin: 0; + font-size: 12px; + color: #6b7280; +} + +.dateBox { + display: inline-flex; + align-items: center; + gap: 4px; + color: #6b7280; +} + +.message { + margin: 0; + max-width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.emptyText { + color: #9ca3af; +} + +.rightAlign { + text-align: right !important; +} + +.rowActions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.actionButton { + border: 1px solid #d1d5db; + background: #fff; + border-radius: 8px; + padding: 6px 10px; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.approveButton { + background: #16a34a; + border-color: #16a34a; + color: #fff; +} + +.rejectButton { + background: #dc2626; + border-color: #dc2626; + color: #fff; +} + +.emptyArea { + padding: 40px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: #9ca3af; +} + +.modalOverlay { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modalCard { + width: min(420px, calc(100% - 24px)); + border-radius: 12px; + background: #fff; + padding: 20px; + box-sizing: border-box; +} + +.modalCard h3 { + margin: 0 0 8px; +} + +.modalCard p { + margin: 0; + color: #4b5563; + font-size: 14px; +} + +.modalActions { + margin-top: 18px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.modalCancel, +.modalConfirm { + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + padding: 8px 12px; + cursor: pointer; +} + +.modalConfirm { + background: #16a34a; + border-color: #16a34a; + color: #fff; +} + +.modalReject { + background: #dc2626; + border-color: #dc2626; +} + +@media (min-width: 768px) { + .statsGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .searchRow { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx new file mode 100644 index 00000000..a55a73d0 --- /dev/null +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx @@ -0,0 +1,443 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Search, Star } from 'lucide-react'; +import styles from './AdminMemberManage.module.css'; +import { + changeAdminMemberRole, + changeAdminMemberStatus, + deleteAdminMember, + getAdminMembersData, + promoteAdminMemberSenior, +} from '../../utils/adminMembersData'; + +const ROLE_LABELS = { + PRESIDENT: '회장', + VICE_PRESIDENT: '부회장', + TEAM_LEADER: '팀장', + TEAM_MEMBER: '일반', + PENDING_MEMBER: '대기회원', +}; + +const ROLE_OPTIONS = [ + 'PRESIDENT', + 'VICE_PRESIDENT', + 'TEAM_LEADER', + 'TEAM_MEMBER', + 'PENDING_MEMBER', +]; + +const STATUS_LABELS = { + ACTIVE: '활성', + INACTIVE: '비활성', + GRADUATED: '졸업', +}; + +const STATUS_OPTIONS = ['ACTIVE', 'INACTIVE', 'GRADUATED']; + +const getRoleClassName = (role) => { + if (role === '회장') return styles.rolePresident; + if (role === '부회장') return styles.roleManager; + if (role === '팀장') return styles.roleLeader; + if (role === '대기회원') return styles.roleLeader; + return styles.roleNormal; +}; + +const getStatusClassName = (status) => { + if (status === '활성') return styles.statusActive; + if (status === '비활성' || status === '졸업') return styles.statusInactive; + return styles.statusPending; +}; + +const AdminMemberManage = () => { + // 필터/검색 상태 + const [searchQuery, setSearchQuery] = useState(''); + const [roleFilter, setRoleFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + + // 회원 목록 데이터 상태 + const [members, setMembers] = useState([]); + const [isPromotingById, setIsPromotingById] = useState({}); + const [isDeletingById, setIsDeletingById] = useState({}); + const [isChangingById, setIsChangingById] = useState({}); + const [changeDialog, setChangeDialog] = useState({ + open: false, + type: 'role', + member: null, + value: '', + }); + const latestRequestIdRef = useRef(0); + + // 회원 목록 조회 (필요한 필터만 백엔드로 전달) + const loadMembers = async ({ keyword, role, status, requestId } = {}) => { + try { + const data = await getAdminMembersData({ keyword, role, status }); + + if (requestId != null && requestId !== latestRequestIdRef.current) { + return; + } + + const nextMembers = data.members || []; + setMembers(nextMembers); + } catch (error) { + if (requestId != null && requestId !== latestRequestIdRef.current) { + return; + } + + window.alert(error?.message || '회원 목록을 불러오지 못했습니다.'); + setMembers([]); + } + }; + + // 필터/검색 조건 변경 시 목록 재조회 + useEffect(() => { + const requestId = ++latestRequestIdRef.current; + const backendRole = roleFilter === 'all' ? undefined : roleFilter; + const backendStatus = statusFilter === 'all' ? undefined : statusFilter; + loadMembers({ + keyword: searchQuery.trim() || undefined, + role: backendRole, + status: backendStatus, + requestId, + }); + + return () => { + if (latestRequestIdRef.current === requestId) { + latestRequestIdRef.current += 1; + } + }; + }, [roleFilter, searchQuery, statusFilter]); + + const filteredMembers = useMemo(() => { + return members.map((member) => ({ + ...member, + displayRole: ROLE_LABELS[member.role] || member.role, + displayStatus: STATUS_LABELS[member.status] || member.status, + })); + }, [members]); + + const openRoleDialog = (member) => { + setChangeDialog({ + open: true, + type: 'role', + member, + value: member.role, + }); + }; + + const openStatusDialog = (member) => { + setChangeDialog({ + open: true, + type: 'status', + member, + value: member.status, + }); + }; + + const closeChangeDialog = () => { + setChangeDialog({ open: false, type: 'role', member: null, value: '' }); + }; + + const confirmChangeDialog = async () => { + const { type, member, value } = changeDialog; + if (!member) return; + + if (isChangingById[member.id]) { + return; + } + + setIsChangingById((prev) => ({ + ...prev, + [member.id]: true, + })); + + try { + if (type === 'role') { + if (!ROLE_OPTIONS.includes(value)) { + window.alert('유효하지 않은 권한입니다.'); + return; + } + await changeAdminMemberRole({ userId: member.id, role: value }); + } else { + if (!STATUS_OPTIONS.includes(value)) { + window.alert('유효하지 않은 상태입니다.'); + return; + } + await changeAdminMemberStatus({ userId: member.id, status: value }); + } + + closeChangeDialog(); + await loadMembers({ + keyword: searchQuery.trim() || undefined, + role: roleFilter === 'all' ? undefined : roleFilter, + status: statusFilter === 'all' ? undefined : statusFilter, + }); + } catch (error) { + window.alert( + error?.message || + (type === 'role' ? '권한 변경에 실패했습니다.' : '상태 변경에 실패했습니다.') + ); + } finally { + setIsChangingById((prev) => ({ + ...prev, + [member.id]: false, + })); + } + }; + + // 단일 회원 선배 전환 + const handlePromoteSenior = async (member) => { + if (isPromotingById[member.id]) { + return; + } + + if (!window.confirm(`${member.name}님을 선배(SENIOR)로 전환하시겠습니까?`)) { + return; + } + + setIsPromotingById((prev) => ({ + ...prev, + [member.id]: true, + })); + + try { + await promoteAdminMemberSenior({ userId: member.id }); + await loadMembers({ + keyword: searchQuery.trim() || undefined, + role: roleFilter === 'all' ? undefined : roleFilter, + status: statusFilter === 'all' ? undefined : statusFilter, + }); + } catch (error) { + window.alert(error?.message || '선배 전환에 실패했습니다.'); + } finally { + setIsPromotingById((prev) => ({ + ...prev, + [member.id]: false, + })); + } + }; + + // 단일 회원 삭제 + const handleDelete = async (member) => { + if (isDeletingById[member.id]) { + return; + } + + if (!window.confirm(`${member.name}님을 강제 탈퇴 처리하시겠습니까?`)) { + return; + } + + setIsDeletingById((prev) => ({ + ...prev, + [member.id]: true, + })); + + try { + await deleteAdminMember({ userId: member.id }); + await loadMembers({ + keyword: searchQuery.trim() || undefined, + role: roleFilter === 'all' ? undefined : roleFilter, + status: statusFilter === 'all' ? undefined : statusFilter, + }); + } catch (error) { + window.alert(error?.message || '회원 삭제에 실패했습니다.'); + } finally { + setIsDeletingById((prev) => ({ + ...prev, + [member.id]: false, + })); + } + }; + + return ( +
+
+
+ + setSearchQuery(event.target.value)} + /> +
+ + + + +
+ +
+ + + + + + + + + + + + + + + {filteredMembers.map((member) => ( + + + + + + + + + + + ))} + +
회원학번소속팀권한포인트상태기수작업
+
+
{member.name?.[0] ?? '?'}
+
+

{member.name}

+

{member.email}

+
+
+
{member.studentId}{member.teamName || '-'} + + {member.displayRole} + + + + + {(member.point || 0).toLocaleString()} + + + + {member.displayStatus} + + {member.generation ? `${member.generation}기` : '-'} +
+ + + + +
+
+
+ +
+

총 {filteredMembers.length}명의 회원

+
+ + +
+
+ + {changeDialog.open && changeDialog.member && ( +
+
+

+ {changeDialog.type === 'role' ? '권한 변경' : '상태 변경'} +

+

+ {changeDialog.member.name}님의 + {changeDialog.type === 'role' ? ' 권한' : ' 상태'}을 선택하세요. +

+ +
+ + +
+
+
+ )} +
+ ); +}; + +export default AdminMemberManage; diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css b/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css new file mode 100644 index 00000000..8db6587f --- /dev/null +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css @@ -0,0 +1,219 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.filterRow { + display: flex; + flex-direction: column; + gap: 10px; +} + +.searchWrap { + position: relative; + flex: 1; +} + +.searchIcon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; +} + +.searchInput, +.filterSelect { + width: 100%; + height: 38px; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 0 10px; + box-sizing: border-box; + background: #fff; +} + +.searchInput { + padding-left: 34px; +} + +.tableWrap { + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow-x: auto; + overflow-y: hidden; + background: #fff; +} + +.table { + width: 100%; + min-width: 1080px; + border-collapse: collapse; +} + +.table th, +.table td { + border-bottom: 1px solid #f3f4f6; + padding: 12px; + font-size: 13px; + text-align: left; + vertical-align: middle; +} + +.table th { + background: #f9fafb; + color: #6b7280; + font-weight: 600; +} + +.memberInfo { + display: flex; + align-items: center; + gap: 10px; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 999px; + background: #e5e7eb; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; +} + +.memberName { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.memberEmail { + margin: 0; + font-size: 12px; + color: #6b7280; +} + +.badge { + border: 1px solid #d1d5db; + border-radius: 999px; + padding: 3px 8px; + font-size: 12px; +} + +.rolePresident { + background: #dbeafe; + color: #1d4ed8; +} + +.roleManager { + background: #dcfce7; + color: #15803d; +} + +.roleLeader { + background: #fef3c7; + color: #b45309; +} + +.roleNormal { + background: #f3f4f6; + color: #4b5563; +} + +.statusActive { + background: #dcfce7; + color: #15803d; +} + +.statusInactive { + background: #f3f4f6; + color: #4b5563; +} + +.statusPending { + background: #fef3c7; + color: #b45309; +} + +.points { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.rightAlign { + text-align: right !important; +} + +.rowActions { + display: inline-flex; + gap: 6px; +} + +.actionButton { + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; +} + +.footerRow { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: #6b7280; +} + +.paging { + display: flex; + gap: 8px; +} + +.modalOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modalCard { + width: min(92vw, 420px); + background: #fff; + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modalCard h3, +.modalCard p { + margin: 0; +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +@media (min-width: 768px) { + .filterRow { + flex-direction: row; + align-items: center; + } + + .filterSelect { + width: 160px; + } +} diff --git a/frontend/src/components/AdminRoute.jsx b/frontend/src/components/AdminRoute.jsx new file mode 100644 index 00000000..cfc4d980 --- /dev/null +++ b/frontend/src/components/AdminRoute.jsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { api } from '../utils/axios'; + +const isAdminRole = (role) => { + const normalizedRole = String(role || '').trim().toUpperCase(); + return normalizedRole === 'PRESIDENT'; // 추후 관리자 role 이름을 넣으면 됨 +}; + +const AdminRoute = () => { + const [loading, setLoading] = useState(true); + const [isAuthorized, setIsAuthorized] = useState(false); + const [redirectPath, setRedirectPath] = useState(null); + + useEffect(() => { + const checkAdmin = async () => { + try { + const { data } = await api.get('/api/user/details'); + setIsAuthorized(isAdminRole(data?.role)); + } catch (error) { + setIsAuthorized(false); + setRedirectPath(error?.status === 401 ? '/login' : '/'); + } finally { + setLoading(false); + } + }; + + checkAdmin(); + }, []); + + if (loading) return
로딩 중...
; + if (redirectPath) return ; + if (!isAuthorized) return ; + + return ; +}; + +export default AdminRoute; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 4f479d32..5f9ab057 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import styles from './Sidebar.module.css'; import { useState, useEffect } from 'react'; @@ -27,6 +28,26 @@ const Sidebar = ({ isOpen, isRoot, onClose }) => { currentBoard?.name || '전체 게시판' ); const { isLoggedIn, logout } = useAuth(); + const [isPresident, setIsPresident] = useState(false); + + useEffect(() => { + const checkAdminRole = async () => { + if (!isLoggedIn) { + setIsPresident(false); + return; + } + + try { + const { data } = await api.get('/api/user/details'); + const normalizedRole = String(data?.role || '').trim().toUpperCase(); + setIsPresident(normalizedRole === 'PRESIDENT'); + } catch { + setIsPresident(false); + } + }; + + checkAdminRole(); + }, [isLoggedIn]); const handleLogout = async () => { await logout(); @@ -164,6 +185,20 @@ const Sidebar = ({ isOpen, isRoot, onClose }) => { + {isLoggedIn && isPresident && ( +
  • + + isActive ? styles['active-link'] : styles['inactive-link'] + } + onClick={handleNavLinkClick} + > + 관리자 + +
  • + )} + {isLoggedIn ? (
  • { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchAttendance = async () => { + try { + const data = await attendanceList(); + const normalizedSessions = Array.isArray(data) + ? data.filter((item) => item && typeof item === 'object') + : []; + setSessions(normalizedSessions); + } catch (err) { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + setSessions([]); + } finally { + setLoading(false); + } + }; + + fetchAttendance(); + }, []); + + if (loading) return
    로딩 중...
    ; + if (error) return
    {error}
    ; + return (
    세션 관리
    + @@ -22,13 +50,13 @@ const SessionManage = () => { - {attendanceList.map((s) => ( - - - - - - + {(Array.isArray(sessions) ? sessions : []).map((s) => ( + + + + + + @@ -40,4 +68,4 @@ const SessionManage = () => { ); }; -export default SessionManage; +export default SessionManage; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 2dabd373..a74bd7cf 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useState } from 'react'; import { api } from '../utils/axios'; diff --git a/frontend/src/pages/AdminExcelUpload.jsx b/frontend/src/pages/AdminExcelUpload.jsx new file mode 100644 index 00000000..63e9d9c4 --- /dev/null +++ b/frontend/src/pages/AdminExcelUpload.jsx @@ -0,0 +1,21 @@ +import AdminHeader from '../components/AdminHome/AdminHeader'; +import AdminSidebar from '../components/AdminHome/AdminSidebar'; +import AdminExcelUploadView from '../components/AdminExcelUpload/AdminExcelUpload'; +import styles from './AdminExcelUpload.module.css'; + +const AdminExcelUpload = () => { + return ( +
    + + +
    + +
    + +
    +
    +
    + ); +}; + +export default AdminExcelUpload; diff --git a/frontend/src/pages/AdminExcelUpload.module.css b/frontend/src/pages/AdminExcelUpload.module.css new file mode 100644 index 00000000..91dab84c --- /dev/null +++ b/frontend/src/pages/AdminExcelUpload.module.css @@ -0,0 +1,33 @@ +.layout { + display: flex; + width: 100%; + min-height: 100vh; + background: #f9fafb; +} + +.mainArea { + flex: 1; + min-width: 0; +} + +.contentArea { + padding: 20px; +} + +@media (min-width: 768px) { + .contentArea { + padding: 32px; + } +} + +@media (min-width: 1024px) { + .contentArea { + padding: 48px; + } +} + +@media (max-width: 1023px) { + .layout { + display: block; + } +} diff --git a/frontend/src/pages/AdminHome.jsx b/frontend/src/pages/AdminHome.jsx new file mode 100644 index 00000000..b104e90b --- /dev/null +++ b/frontend/src/pages/AdminHome.jsx @@ -0,0 +1,67 @@ +import styles from './AdminHome.module.css'; +import { useEffect, useState } from 'react'; +import AdminHeader from '../components/AdminHome/AdminHeader'; +import AdminSidebar from '../components/AdminHome/AdminSidebar'; +import DashboardStats from '../components/AdminHome/DashboardStats'; +import PendingApprovalsPanel from '../components/AdminHome/PendingApprovalsPanel'; +import RecentActivitiesPanel from '../components/AdminHome/RecentActivitiesPanel'; +import QuickActionsPanel from '../components/AdminHome/QuickActionsPanel'; +import MembersPanel from '../components/AdminHome/MembersPanel'; +import { getAdminHomeData } from '../utils/adminHomeData'; + +const initialAdminHomeData = { + dashboardStats: [], + pendingApprovals: [], + recentActivities: [], + quickActions: [], + members: [], +}; + +const AdminHome = () => { + const [data, setData] = useState(initialAdminHomeData); + const [error, setError] = useState(null); + + useEffect(() => { + loadAdminHomeData(); + }, []); + + const loadAdminHomeData = async () => { + try { + const adminHomeData = await getAdminHomeData(); + setData(adminHomeData); + setError(null); + } catch (loadError) { + console.error('관리자 홈 데이터 조회 실패:', loadError); + setError('관리자 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'); + setData(initialAdminHomeData); + } + }; + + return ( +
    + + +
    + +
    + {error &&

    {error}

    } + + +
    + + +
    + + + +
    +
    +
    + ); +}; + +export default AdminHome; diff --git a/frontend/src/pages/AdminHome.module.css b/frontend/src/pages/AdminHome.module.css new file mode 100644 index 00000000..eb2fe752 --- /dev/null +++ b/frontend/src/pages/AdminHome.module.css @@ -0,0 +1,185 @@ +.layout { + display: flex; + width: 100%; + min-height: 100vh; + background: #f9fafb; +} + +.mainArea { + flex: 1; + min-width: 0; +} + +.container { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + padding: 20px; + box-sizing: border-box; +} + +.title { + font-size: 28px; + font-weight: 700; +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 12px; +} + +.gridTwoColumns { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 16px; +} + +.card, +.panel { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + background: #ffffff; +} + +.cardTitle { + font-size: 14px; + margin: 0; +} + +.cardValue { + font-size: 28px; + font-weight: 700; + margin: 8px 0 4px; +} + +.cardDescription { + font-size: 13px; + margin: 0; +} + +.panelHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.panelTitle { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.linkButton { + font-size: 13px; + text-decoration: none; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.listItem { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border: 1px solid #f3f4f6; + border-radius: 10px; + padding: 12px; +} + +.listItemColumn { + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid #f3f4f6; + border-radius: 10px; + padding: 12px; +} + +.memberName { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.memberMeta { + margin: 0; + font-size: 12px; +} + +.memberActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.badge { + border: 1px solid #d1d5db; + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; +} + +.actionPrimary, +.actionSecondary, +.quickActionButton { + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 6px 10px; + font-size: 13px; + background: #ffffff; + cursor: pointer; + text-decoration: none; +} + +.quickActionWrap { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.activityMessage { + margin: 0; + font-size: 14px; +} + +@media (min-width: 768px) { + .container { + padding: 40px; + } + + .statsGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .container { + padding: 60px; + } + + .statsGrid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .gridTwoColumns { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1023px) { + .layout { + display: block; + } +} diff --git a/frontend/src/pages/AdminMemberApproval.jsx b/frontend/src/pages/AdminMemberApproval.jsx new file mode 100644 index 00000000..5bfb5bc3 --- /dev/null +++ b/frontend/src/pages/AdminMemberApproval.jsx @@ -0,0 +1,21 @@ +import AdminHeader from '../components/AdminHome/AdminHeader'; +import AdminSidebar from '../components/AdminHome/AdminSidebar'; +import AdminMemberApprovalList from '../components/AdminMemberApproval/AdminMemberApproval'; +import styles from './AdminMemberApproval.module.css'; + +const AdminMemberApproval = () => { + return ( +
    + + +
    + +
    + +
    +
    +
    + ); +}; + +export default AdminMemberApproval; diff --git a/frontend/src/pages/AdminMemberApproval.module.css b/frontend/src/pages/AdminMemberApproval.module.css new file mode 100644 index 00000000..91dab84c --- /dev/null +++ b/frontend/src/pages/AdminMemberApproval.module.css @@ -0,0 +1,33 @@ +.layout { + display: flex; + width: 100%; + min-height: 100vh; + background: #f9fafb; +} + +.mainArea { + flex: 1; + min-width: 0; +} + +.contentArea { + padding: 20px; +} + +@media (min-width: 768px) { + .contentArea { + padding: 32px; + } +} + +@media (min-width: 1024px) { + .contentArea { + padding: 48px; + } +} + +@media (max-width: 1023px) { + .layout { + display: block; + } +} diff --git a/frontend/src/pages/AdminMemberManage.jsx b/frontend/src/pages/AdminMemberManage.jsx new file mode 100644 index 00000000..fb295460 --- /dev/null +++ b/frontend/src/pages/AdminMemberManage.jsx @@ -0,0 +1,21 @@ +import AdminHeader from '../components/AdminHome/AdminHeader'; +import AdminSidebar from '../components/AdminHome/AdminSidebar'; +import AdminMemberManageView from '../components/AdminMemberManage/AdminMemberManage'; +import styles from './AdminMemberManage.module.css'; + +const AdminMemberManage = () => { + return ( +
    + + +
    + +
    + +
    +
    +
    + ); +}; + +export default AdminMemberManage; diff --git a/frontend/src/pages/AdminMemberManage.module.css b/frontend/src/pages/AdminMemberManage.module.css new file mode 100644 index 00000000..91dab84c --- /dev/null +++ b/frontend/src/pages/AdminMemberManage.module.css @@ -0,0 +1,33 @@ +.layout { + display: flex; + width: 100%; + min-height: 100vh; + background: #f9fafb; +} + +.mainArea { + flex: 1; + min-width: 0; +} + +.contentArea { + padding: 20px; +} + +@media (min-width: 768px) { + .contentArea { + padding: 32px; + } +} + +@media (min-width: 1024px) { + .contentArea { + padding: 48px; + } +} + +@media (max-width: 1023px) { + .layout { + display: block; + } +} diff --git a/frontend/src/pages/Attendance.jsx b/frontend/src/pages/Attendance.jsx index 7afa35d1..baea9748 100644 --- a/frontend/src/pages/Attendance.jsx +++ b/frontend/src/pages/Attendance.jsx @@ -1,6 +1,6 @@ import styles from './Attendance.module.css'; import SessionSelectBox from '../components/attendance/SessionSelectBox'; -import ExcusedTime from '../components/attendance/ExcusedTime'; +// import ExcusedTime from '../components/attendance/ExcusedTime'; import SessionManage from '../components/attendance/SessionManage'; import { useAuthGuard } from '../hooks/useAuthGuard'; @@ -13,7 +13,7 @@ const Attendance = () => {

    출석하기

    - + {/* */}
    diff --git a/frontend/src/utils/adminHomeData.js b/frontend/src/utils/adminHomeData.js new file mode 100644 index 00000000..5817c733 --- /dev/null +++ b/frontend/src/utils/adminHomeData.js @@ -0,0 +1,62 @@ +import { api } from './axios'; + +const recentActivities = [ + { id: 1, message: '출석 체크 세션이 생성되었습니다.', time: '10분 전' }, + { id: 2, message: '공지사항이 등록되었습니다.', time: '35분 전' }, + { id: 3, message: '회원 3명이 가입 신청했습니다.', time: '1시간 전' }, + { id: 4, message: '포인트 규칙이 업데이트되었습니다.', time: '어제' }, +]; + +const quickActions = [ + { id: 'upload', label: '엑셀로 회원 등록', to: '/admin/members/upload' }, + { id: 'attendance', label: '출석 체크 설정', to: '/admin/attendance' }, + { id: 'notice', label: '공지사항 작성', to: '/admin/posts' }, + { id: 'points', label: '포인트 규칙 설정', to: '/admin/points' }, +]; + +export const getAdminHomeData = async () => { + const response = await api.get('/api/admin/users'); + const users = Array.isArray(response.data) + ? response.data + : Array.isArray(response.data?.users) + ? response.data.users + : Array.isArray(response.data?.payload) + ? response.data.payload + : []; + + const pendingApprovals = users.filter((user) => user.role === 'PENDING_MEMBER'); + const members = users.filter((user) => user.role !== 'PENDING_MEMBER'); + + return { + dashboardStats: [ + { + id: 'members', + title: '총 회원 수', + value: String(members.length), + description: '승인 회원', + }, + { + id: 'visitors', + title: '금일 방문자', + value: '-', + description: '집계 준비 중', + }, + { + id: 'attendance', + title: '주간 출석률', + value: '-', + description: '집계 준비 중', + }, + { + id: 'pending', + title: '승인 대기', + value: String(pendingApprovals.length), + description: '가입 신청', + }, + ], + pendingApprovals, + recentActivities, + quickActions, + members, + }; +}; diff --git a/frontend/src/utils/adminMemberManageData.js b/frontend/src/utils/adminMemberManageData.js new file mode 100644 index 00000000..32c4e322 --- /dev/null +++ b/frontend/src/utils/adminMemberManageData.js @@ -0,0 +1,54 @@ +import { api } from './axios'; + +// 가입 승인 대기 회원 목록 조회 +export const getAdminMemberManageData = async ({ keyword } = {}) => { + const params = { + role: 'PENDING_MEMBER', + }; + + if (keyword) params.keyword = keyword; + + const response = await api.get('/api/admin/users', { params }); + + return { + pendingMembers: response.data || [], + monthlyApprovedCount: 0, + monthlyRejectedCount: 0, + }; +}; + +// 단일 회원 가입 승인 (대기 -> 일반 회원) +export const approvePendingMember = async ({ userId }) => { + await api.patch(`/api/admin/users/${userId}/role`, null, { + params: { role: 'TEAM_MEMBER' }, + }); +}; + +// 단일 회원 가입 거절 (상태 -> OUT) +export const rejectPendingMember = async ({ userId }) => { + await api.patch(`/api/admin/users/${userId}/status`, null, { + params: { status: 'OUT' }, + }); +}; + +// 다중 회원 일괄 가입 승인 +export const approvePendingMembersBulk = async ({ userIds }) => { + await Promise.all( + userIds.map((userId) => + api.patch(`/api/admin/users/${userId}/role`, null, { + params: { role: 'TEAM_MEMBER' }, + }) + ) + ); +}; + +// 다중 회원 일괄 가입 거절 +export const rejectPendingMembersBulk = async ({ userIds }) => { + await Promise.all( + userIds.map((userId) => + api.patch(`/api/admin/users/${userId}/status`, null, { + params: { status: 'OUT' }, + }) + ) + ); +}; diff --git a/frontend/src/utils/adminMembersData.js b/frontend/src/utils/adminMembersData.js new file mode 100644 index 00000000..a3213a79 --- /dev/null +++ b/frontend/src/utils/adminMembersData.js @@ -0,0 +1,48 @@ +import { api } from './axios'; + +const ensureUserId = (userId) => { + if (userId === undefined || userId === null || userId === '') { + throw new Error('userId is required'); + } +}; + +// 관리자 회원 목록 조회 (검색/권한/상태/기수 필터) +export const getAdminMembersData = async ({ keyword, role, status, generation } = {}) => { + const params = {}; + + if (keyword) params.keyword = keyword; + if (role) params.role = role; + if (status) params.status = status; + if (generation) params.generation = generation; + + const response = await api.get('/api/admin/users', { params }); + return { members: response.data || [] }; +}; + +// 회원 권한 변경 +export const changeAdminMemberRole = async ({ userId, role }) => { + ensureUserId(userId); + await api.patch(`/api/admin/users/${userId}/role`, null, { + params: { role }, + }); +}; + +// 회원 상태 변경 +export const changeAdminMemberStatus = async ({ userId, status }) => { + ensureUserId(userId); + await api.patch(`/api/admin/users/${userId}/status`, null, { + params: { status }, + }); +}; + +// 회원을 선배(SENIOR)로 전환 +export const promoteAdminMemberSenior = async ({ userId }) => { + ensureUserId(userId); + await api.patch(`/api/admin/users/${userId}/senior`); +}; + +// 회원 강제 삭제 +export const deleteAdminMember = async ({ userId }) => { + ensureUserId(userId); + await api.delete(`/api/admin/users/${userId}`); +}; diff --git a/frontend/src/utils/adminUserApi.js b/frontend/src/utils/adminUserApi.js new file mode 100644 index 00000000..7754f3fa --- /dev/null +++ b/frontend/src/utils/adminUserApi.js @@ -0,0 +1,70 @@ +import { api } from './axios'; + +const ensureUserId = (userId) => { + const isNumber = typeof userId === 'number' && Number.isFinite(userId); + const isNonEmptyString = typeof userId === 'string' && userId.trim() !== ''; + + if (!isNumber && !isNonEmptyString) { + throw new Error('userId is required'); + } +}; + +// 관리자 사용자 목록 조회 +export const getAdminUsers = async ({ keyword, generation, role, status } = {}) => { + const params = {}; + + if (keyword) params.keyword = keyword; + if (generation) params.generation = generation; + if (role) params.role = role; + if (status) params.status = status; + + const response = await api.get('/api/admin/users', { params }); + return response.data; +}; + +// 사용자 권한 변경 +export const updateAdminUserRole = async ({ userId, role }) => { + ensureUserId(userId); + await api.patch(`/api/admin/users/${userId}/role`, null, { + params: { role }, + }); +}; + +// 사용자 상태 변경 +export const updateAdminUserStatus = async ({ userId, status }) => { + ensureUserId(userId); + await api.patch(`/api/admin/users/${userId}/status`, null, { + params: { status }, + }); +}; + +// 사용자를 선배(SENIOR)로 전환 +export const promoteAdminUserSenior = async ({ userId }) => { + ensureUserId(userId); + await api.patch(`/api/admin/users/${userId}/senior`); +}; + +// 사용자 삭제 +export const deleteAdminUser = async ({ userId }) => { + ensureUserId(userId); + await api.delete(`/api/admin/users/${userId}`); +}; + +// 관리자용 엑셀 업로드 API +export const uploadAdminUsersExcel = async ({ file }) => { + const isValidFile = file instanceof File || file instanceof Blob; + if (!isValidFile) { + throw new Error('file is required'); + } + + const formData = new FormData(); + formData.append('file', file); + + const response = await api.post('/api/admin/users/upload-excel', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response.data; +}; diff --git a/frontend/src/utils/attendanceList.js b/frontend/src/utils/attendanceList.js index 80a42bb4..9d2b883e 100644 --- a/frontend/src/utils/attendanceList.js +++ b/frontend/src/utils/attendanceList.js @@ -1,37 +1,36 @@ -export const attendanceList = [ - { - date: '25-09-01', - startTime: '18:00:00', - available: 30, - round: 1, - name: '김성원', - }, - { - date: '25-09-08', - startTime: '18:00:00', - available: 10, - round: 2, - name: '김성원', - }, - { - date: '25-09-15', - startTime: '18:00:00', - available: 20, - round: 3, - name: '김성원', - }, - { - date: '25-09-22', - startTime: '18:00:00', - available: 30, - round: 4, - name: '김성원', - }, - { - date: '25-09-29', - startTime: '18:00:00', - available: 30, - round: 5, - name: '김성원', - }, -]; +import { api } from './axios.js'; + +export const attendanceList = async () => { + try { + const res = await api.get('/api/attendance/me'); + // console.log('API BASE URL:', import.meta.env.VITE_API_URL); + return res.data; + } catch (err) { + const safeMessage = err?.message || '알 수 없는 오류'; + console.error('출석 세션 불러오기 중 오류 발생:', safeMessage); + if (import.meta.env.DEV) { + console.debug('attendanceList error (dev only):', { + name: err?.name, + message: safeMessage, + }); + } + throw err; + } +}; + + +// [ +// { +// "attendanceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", +// "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", +// "userName": "string", +// "roundId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", +// "attendanceStatus": "string", +// "checkedAt": "2026-02-24T14:33:43.581Z", +// "note": "string", +// "checkInLatitude": 0.1, +// "checkInLongitude": 0.1, +// "createdAt": "2026-02-24T14:33:43.581Z", +// "updatedAt": "2026-02-24T14:33:43.581Z" +// } +// ]
    {s.date}{s.startTime}{s.available}분{s.round}회차{s.name}
    {new Date(s.createdAt).toLocaleDateString()}{new Date(s.checkedAt).toLocaleTimeString()}30분{s.roundId}{s.userName}