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 (
+
+
+
가입 승인 대기
+
+ 전체 보기
+
+
+
+
+ );
+};
+
+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}명 선택됨
+
+
+
+ )}
+
+
+
+
+
+
+ |
+ 0
+ }
+ onChange={toggleSelectAll}
+ />
+ |
+ 신청자 |
+ 학번 |
+ 학과 |
+ 연락처 |
+ 신청일시 |
+ 가입 메시지 |
+ 작업 |
+
+
+
+ {filteredMembers.map((member) => (
+
+ |
+ 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) => (
-
- | {s.date} |
- {s.startTime} |
- {s.available}분 |
- {s.round}회차 |
- {s.name} |
+ {(Array.isArray(sessions) ? sessions : []).map((s) => (
+
+ | {new Date(s.createdAt).toLocaleDateString()} |
+ {new Date(s.checkedAt).toLocaleTimeString()} |
+ 30분 |
+ {s.roundId} |
+ {s.userName} |
|
@@ -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"
+// }
+// ]