From 7b7bbbde5c85c115735c26666c8c852a34877cee Mon Sep 17 00:00:00 2001 From: sangkyu Date: Wed, 25 Feb 2026 21:19:00 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[FE]=20=EC=B6=9C=EC=84=9D=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/attendance/SessionManage.jsx | 41 +++++++++--- frontend/src/pages/Attendance.jsx | 6 +- frontend/src/utils/attendanceList.js | 66 ++++++++----------- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/frontend/src/components/attendance/SessionManage.jsx b/frontend/src/components/attendance/SessionManage.jsx index cee1a1e6..cb6aaca3 100644 --- a/frontend/src/components/attendance/SessionManage.jsx +++ b/frontend/src/components/attendance/SessionManage.jsx @@ -1,14 +1,39 @@ +import { useEffect, useState } from 'react'; import styles from './SessionManage.module.css'; import { ClipboardCheck } from 'lucide-react'; import { attendanceList } from '../../utils/attendanceList'; const SessionManage = () => { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchAttendance = async () => { + try { + const data = await attendanceList(); + setSessions(data); + console.log(data); + } catch (err) { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; + + fetchAttendance(); + }, []); + + if (loading) return
로딩 중...
; + if (error) return
{error}
; + return (
세션 관리
+ @@ -22,13 +47,13 @@ const SessionManage = () => { - {attendanceList.map((s) => ( - - - - - - + {sessions.map((s) => ( + + + + + + @@ -40,4 +65,4 @@ const SessionManage = () => { ); }; -export default SessionManage; +export default SessionManage; \ No newline at end of file diff --git a/frontend/src/pages/Attendance.jsx b/frontend/src/pages/Attendance.jsx index 7afa35d1..7c9f9735 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'; @@ -10,10 +10,10 @@ const Attendance = () => { return (
-

출석하기

+

출석조회

- + {/* */}
diff --git a/frontend/src/utils/attendanceList.js b/frontend/src/utils/attendanceList.js index 80a42bb4..2cfe565d 100644 --- a/frontend/src/utils/attendanceList.js +++ b/frontend/src/utils/attendanceList.js @@ -1,37 +1,29 @@ -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) { + console.error('출석 세션 불러오기 중 오류 발생: ', err); + 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" +// } +// ] From e379ab0c0291cb20eb57f6f8b70d891c490be2f9 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Wed, 25 Feb 2026 23:35:11 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[FE]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20main,=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC,=20=EA=B0=80=EC=9E=85=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 15 +- .../src/components/AdminHome/AdminHeader.jsx | 36 ++ .../AdminHome/AdminHeader.module.css | 86 +++++ .../src/components/AdminHome/AdminSidebar.jsx | 132 +++++++ .../AdminHome/AdminSidebar.module.css | 150 ++++++++ .../src/components/AdminHome/MemberList.jsx | 30 ++ .../AdminMemberApproval.jsx | 357 ++++++++++++++++++ .../AdminMemberApproval.module.css | 291 ++++++++++++++ .../AdminMemberManage/AdminMemberManage.jsx | 297 +++++++++++++++ .../AdminMemberManage.module.css | 186 +++++++++ frontend/src/components/AdminRoute.jsx | 38 ++ frontend/src/pages/AdminHome.jsx | 118 ++++++ frontend/src/pages/AdminHome.module.css | 185 +++++++++ frontend/src/pages/AdminMemberApproval.jsx | 21 ++ .../src/pages/AdminMemberApproval.module.css | 33 ++ frontend/src/pages/AdminMemberManage.jsx | 21 ++ .../src/pages/AdminMemberManage.module.css | 33 ++ frontend/src/utils/adminHomeData.js | 35 ++ frontend/src/utils/adminMemberManageData.js | 49 +++ frontend/src/utils/adminMembersData.js | 33 ++ frontend/src/utils/adminUserApi.js | 46 +++ 21 files changed, 2190 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/AdminHome/AdminHeader.jsx create mode 100644 frontend/src/components/AdminHome/AdminHeader.module.css create mode 100644 frontend/src/components/AdminHome/AdminSidebar.jsx create mode 100644 frontend/src/components/AdminHome/AdminSidebar.module.css create mode 100644 frontend/src/components/AdminHome/MemberList.jsx create mode 100644 frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx create mode 100644 frontend/src/components/AdminMemberApproval/AdminMemberApproval.module.css create mode 100644 frontend/src/components/AdminMemberManage/AdminMemberManage.jsx create mode 100644 frontend/src/components/AdminMemberManage/AdminMemberManage.module.css create mode 100644 frontend/src/components/AdminRoute.jsx create mode 100644 frontend/src/pages/AdminHome.jsx create mode 100644 frontend/src/pages/AdminHome.module.css create mode 100644 frontend/src/pages/AdminMemberApproval.jsx create mode 100644 frontend/src/pages/AdminMemberApproval.module.css create mode 100644 frontend/src/pages/AdminMemberManage.jsx create mode 100644 frontend/src/pages/AdminMemberManage.module.css create mode 100644 frontend/src/utils/adminHomeData.js create mode 100644 frontend/src/utils/adminMemberManageData.js create mode 100644 frontend/src/utils/adminMembersData.js create mode 100644 frontend/src/utils/adminUserApi.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1dcf3d26..2d350a23 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,8 +13,11 @@ 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 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 +33,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 +70,15 @@ function App() { } /> } /> } /> - } /> + {/* } /> */} + }> + } /> + } /> + } + /> + diff --git a/frontend/src/components/AdminHome/AdminHeader.jsx b/frontend/src/components/AdminHome/AdminHeader.jsx new file mode 100644 index 00000000..bb326118 --- /dev/null +++ b/frontend/src/components/AdminHome/AdminHeader.jsx @@ -0,0 +1,36 @@ +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..1a55d452 --- /dev/null +++ b/frontend/src/components/AdminHome/AdminHeader.module.css @@ -0,0 +1,86 @@ +.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; + outline: none; +} + +.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..cfb400a1 --- /dev/null +++ b/frontend/src/components/AdminHome/AdminSidebar.jsx @@ -0,0 +1,132 @@ +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 }, + { label: '출석 관리', href: '/admin/attendance', icon: Calendar }, + { label: '포인트 관리', href: '/admin/points', icon: Star }, + ], + }, + { + category: '시스템', + items: [ + { label: '게임/툴 관리', href: '/admin/tools', icon: Gamepad2 }, + { label: '통계 대시보드', href: '/admin/dashboard', icon: BarChart3 }, + ], + }, +]; + +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'); + + 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/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 ( +
+
{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}
+ + + + + + + + + {members.map((member) => ( + + + + + + ))} + +
이름권한상태
{member.name}{member.role}{member.status}
+
+ ); +}; + +export default MemberList; diff --git a/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx new file mode 100644 index 00000000..567997f8 --- /dev/null +++ b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx @@ -0,0 +1,357 @@ +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(data.monthlyApprovedCount || 0); + setMonthlyRejectedCount(data.monthlyRejectedCount || 0); + } 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 = () => { + const approveAction = 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 || '가입 승인 처리에 실패했습니다.'); + } + }; + + approveAction(); + }; + + const confirmReject = () => { + const rejectAction = 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 || '가입 거절 처리에 실패했습니다.'); + } + }; + + rejectAction(); + }; + + 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..413860f6 --- /dev/null +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx @@ -0,0 +1,297 @@ +import { useEffect, useMemo, 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 = { + SYSTEM_ADMIN: '시스템관리자', + PRESIDENT: '회장', + VICE_PRESIDENT: '부회장', + TEAM_LEADER: '팀장', + TEAM_MEMBER: '일반', + PENDING_MEMBER: '대기회원', +}; + +const STATUS_LABELS = { + ACTIVE: '활성', + INACTIVE: '비활성', + GRADUATED: '졸업', + OUT: '탈퇴', +}; + +const getRoleClassName = (role) => { + if (role === '회장') return styles.rolePresident; + if (role === '시스템관리자') return styles.rolePresident; + if (role === '부회장') return styles.roleManager; + 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 === '졸업' || 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 loadMembers = async ({ keyword, role, status } = {}) => { + try { + const data = await getAdminMembersData({ keyword, role, status }); + setMembers(data.members || []); + } catch (error) { + window.alert(error?.message || '회원 목록을 불러오지 못했습니다.'); + setMembers([]); + } + }; + + useEffect(() => { + loadMembers(); + }, []); + + useEffect(() => { + const backendRole = roleFilter === 'all' ? undefined : roleFilter; + const backendStatus = statusFilter === 'all' ? undefined : statusFilter; + loadMembers({ + keyword: searchQuery.trim() || undefined, + role: backendRole, + status: backendStatus, + }); + }, [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 handleRoleChange = async (member) => { + const roleInput = window.prompt( + '변경할 권한을 입력하세요.\n예: TEAM_MEMBER, TEAM_LEADER, VICE_PRESIDENT, PRESIDENT, SYSTEM_ADMIN, PENDING_MEMBER', + member.role + ); + + if (!roleInput) return; + + try { + await changeAdminMemberRole({ userId: member.id, role: roleInput }); + await loadMembers({ + keyword: searchQuery.trim() || undefined, + role: roleFilter === 'all' ? undefined : roleFilter, + status: statusFilter === 'all' ? undefined : statusFilter, + }); + } catch (error) { + window.alert(error?.message || '권한 변경에 실패했습니다.'); + } + }; + + const handleStatusChange = async (member) => { + const statusInput = window.prompt( + '변경할 상태를 입력하세요.\n예: ACTIVE, INACTIVE, GRADUATED, OUT', + member.status + ); + + if (!statusInput) return; + + try { + await changeAdminMemberStatus({ userId: member.id, status: statusInput }); + await loadMembers({ + keyword: searchQuery.trim() || undefined, + role: roleFilter === 'all' ? undefined : roleFilter, + status: statusFilter === 'all' ? undefined : statusFilter, + }); + } catch (error) { + window.alert(error?.message || '상태 변경에 실패했습니다.'); + } + }; + + const handlePromoteSenior = async (member) => { + if (!window.confirm(`${member.name}님을 선배(SENIOR)로 전환하시겠습니까?`)) { + return; + } + + 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 || '선배 전환에 실패했습니다.'); + } + }; + + const handleDelete = async (member) => { + if (!window.confirm(`${member.name}님을 강제 탈퇴 처리하시겠습니까?`)) { + return; + } + + 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 || '회원 삭제에 실패했습니다.'); + } + }; + + 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}명의 회원

+
+ + +
+
+
+ ); +}; + +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..8f0c8e70 --- /dev/null +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css @@ -0,0 +1,186 @@ +.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: 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; +} + +.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; +} + +@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..185f1207 --- /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 === 'TEAM_MEMBER'; // 추후 관리자 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/pages/AdminHome.jsx b/frontend/src/pages/AdminHome.jsx new file mode 100644 index 00000000..2d33535f --- /dev/null +++ b/frontend/src/pages/AdminHome.jsx @@ -0,0 +1,118 @@ +import styles from './AdminHome.module.css'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import MemberList from '../components/AdminHome/MemberList'; +import AdminHeader from '../components/AdminHome/AdminHeader'; +import AdminSidebar from '../components/AdminHome/AdminSidebar'; +import { getAdminHomeData } from '../utils/adminHomeData'; + +const AdminHome = () => { + const [data, setData] = useState({ + dashboardStats: [], + pendingApprovals: [], + recentActivities: [], + quickActions: [], + members: [], + }); + + useEffect(() => { + const loadAdminHomeData = async () => { + const adminHomeData = await getAdminHomeData(); + setData(adminHomeData); + }; + + loadAdminHomeData(); + }, []); + + return ( +
+ + +
+ +
+ +
+ {data.dashboardStats.map((stat) => ( +
+

{stat.title}

+

{stat.value}

+

{stat.description}

+
+ ))} +
+ +
+
+
+

가입 승인 대기

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

    {member.name}

    +

    {member.email}

    +
    +
    + {member.requestedAt} + + +
    +
  • + ))} +
+
+ +
+
+

최근 활동

+
+
    + {data.recentActivities.map((activity) => ( +
  • +

    {activity.message}

    +

    {activity.time}

    +
  • + ))} +
+
+
+ +
+
+

빠른 작업

+
+
+ {data.quickActions.map((action) => ( + + {action.label} + + ))} +
+
+ +
+
+

회원 목록

+
+ +
+
+
+
+ ); +}; + +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/utils/adminHomeData.js b/frontend/src/utils/adminHomeData.js new file mode 100644 index 00000000..9d20292b --- /dev/null +++ b/frontend/src/utils/adminHomeData.js @@ -0,0 +1,35 @@ +const mockAdminHomeData = { + dashboardStats: [ + { id: 'members', title: '총 회원 수', value: '156', description: '활성 회원' }, + { id: 'visitors', title: '금일 방문자', value: '89', description: '전일 대비 +5' }, + { id: 'attendance', title: '주간 출석률', value: '78%', description: '전주 대비 +3%' }, + { id: 'pending', title: '승인 대기', value: '12', description: '가입 신청' }, + ], + pendingApprovals: [ + { id: 1, name: '김민수', email: 'minsu@sisc.com', requestedAt: '2026-02-24' }, + { id: 2, name: '박서연', email: 'seoyeon@sisc.com', requestedAt: '2026-02-24' }, + { id: 3, name: '이준호', email: 'junho@sisc.com', requestedAt: '2026-02-23' }, + ], + recentActivities: [ + { id: 1, message: '출석 체크 세션이 생성되었습니다.', time: '10분 전' }, + { id: 2, message: '공지사항이 등록되었습니다.', time: '35분 전' }, + { id: 3, message: '회원 3명이 가입 신청했습니다.', time: '1시간 전' }, + { id: 4, message: '포인트 규칙이 업데이트되었습니다.', time: '어제' }, + ], + 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' }, + ], + members: [ + { id: 1, name: '김하늘', role: 'TEAM_MEMBER', status: 'ACTIVE' }, + { id: 2, name: '최지훈', role: 'TEAM_LEADER', status: 'ACTIVE' }, + { id: 3, name: '오수빈', role: 'PENDING_MEMBER', status: 'PENDING' }, + { id: 4, name: '정우진', role: 'TEAM_MEMBER', status: 'ACTIVE' }, + ], +}; + +export const getAdminHomeData = async () => { + return mockAdminHomeData; +}; diff --git a/frontend/src/utils/adminMemberManageData.js b/frontend/src/utils/adminMemberManageData.js new file mode 100644 index 00000000..44c75d37 --- /dev/null +++ b/frontend/src/utils/adminMemberManageData.js @@ -0,0 +1,49 @@ +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' }, + }); +}; + +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..92c9078f --- /dev/null +++ b/frontend/src/utils/adminMembersData.js @@ -0,0 +1,33 @@ +import { api } from './axios'; + +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 }) => { + await api.patch(`/api/admin/users/${userId}/role`, null, { + params: { role }, + }); +}; + +export const changeAdminMemberStatus = async ({ userId, status }) => { + await api.patch(`/api/admin/users/${userId}/status`, null, { + params: { status }, + }); +}; + +export const promoteAdminMemberSenior = async ({ userId }) => { + await api.patch(`/api/admin/users/${userId}/senior`); +}; + +export const deleteAdminMember = async ({ 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..a6d52fc3 --- /dev/null +++ b/frontend/src/utils/adminUserApi.js @@ -0,0 +1,46 @@ +import { api } from './axios'; + +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 }) => { + await api.patch(`/api/admin/users/${userId}/role`, null, { + params: { role }, + }); +}; + +export const updateAdminUserStatus = async ({ userId, status }) => { + await api.patch(`/api/admin/users/${userId}/status`, null, { + params: { status }, + }); +}; + +export const promoteAdminUserSenior = async ({ userId }) => { + await api.patch(`/api/admin/users/${userId}/senior`); +}; + +export const deleteAdminUser = async ({ userId }) => { + await api.delete(`/api/admin/users/${userId}`); +}; + +export const uploadAdminUsersExcel = async ({ file }) => { + 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; +}; From 343a0bcb1778584b9041d220f61067c62c0d848f Mon Sep 17 00:00:00 2001 From: sangkyu Date: Thu, 26 Feb 2026 18:46:56 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[FE]=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=94=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/AdminRoute.jsx | 2 +- frontend/src/components/Sidebar.jsx | 35 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AdminRoute.jsx b/frontend/src/components/AdminRoute.jsx index 185f1207..cfc4d980 100644 --- a/frontend/src/components/AdminRoute.jsx +++ b/frontend/src/components/AdminRoute.jsx @@ -4,7 +4,7 @@ import { api } from '../utils/axios'; const isAdminRole = (role) => { const normalizedRole = String(role || '').trim().toUpperCase(); - return normalizedRole === 'TEAM_MEMBER'; // 추후 관리자 role 이름을 넣으면 됨 + return normalizedRole === 'PRESIDENT'; // 추후 관리자 role 이름을 넣으면 됨 }; const 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 ? (
  • Date: Thu, 26 Feb 2026 18:55:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[FE]=20AdminHome=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AdminHome/DashboardStats.jsx | 15 ++++ .../src/components/AdminHome/MembersPanel.jsx | 14 +++ .../AdminHome/PendingApprovalsPanel.jsx | 35 ++++++++ .../AdminHome/QuickActionsPanel.jsx | 20 +++++ .../AdminHome/RecentActivitiesPanel.jsx | 19 ++++ frontend/src/pages/AdminHome.jsx | 88 +++---------------- 6 files changed, 114 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/AdminHome/DashboardStats.jsx create mode 100644 frontend/src/components/AdminHome/MembersPanel.jsx create mode 100644 frontend/src/components/AdminHome/PendingApprovalsPanel.jsx create mode 100644 frontend/src/components/AdminHome/QuickActionsPanel.jsx create mode 100644 frontend/src/components/AdminHome/RecentActivitiesPanel.jsx 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/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..c71b8854 --- /dev/null +++ b/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom'; + +const PendingApprovalsPanel = ({ members = [], styles }) => { + 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/pages/AdminHome.jsx b/frontend/src/pages/AdminHome.jsx index 2d33535f..04839670 100644 --- a/frontend/src/pages/AdminHome.jsx +++ b/frontend/src/pages/AdminHome.jsx @@ -1,9 +1,12 @@ import styles from './AdminHome.module.css'; import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import MemberList from '../components/AdminHome/MemberList'; 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 AdminHome = () => { @@ -31,84 +34,15 @@ const AdminHome = () => {
    + -
    - {data.dashboardStats.map((stat) => ( -
    -

    {stat.title}

    -

    {stat.value}

    -

    {stat.description}

    -
    - ))} -
    - -
    -
    -
    -

    가입 승인 대기

    - - 전체 보기 - -
    -
      - {data.pendingApprovals.map((member) => ( -
    • -
      -

      {member.name}

      -

      {member.email}

      -
      -
      - {member.requestedAt} - - -
      -
    • - ))} -
    -
    - -
    -
    -

    최근 활동

    +
    + +
    -
      - {data.recentActivities.map((activity) => ( -
    • -

      {activity.message}

      -

      {activity.time}

      -
    • - ))} -
    -
    -
    - -
    -
    -

    빠른 작업

    -
    -
    - {data.quickActions.map((action) => ( - - {action.label} - - ))} -
    -
    -
    -
    -

    회원 목록

    -
    - -
    + +
    From d8f80bef7d23d6e8591f46c5d3d12a271a0b5473 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Thu, 26 Feb 2026 19:37:15 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FE]=20Admin=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminHome/PendingApprovalsPanel.jsx | 44 ++++- .../AdminMemberApproval.jsx | 8 + .../AdminMemberManage/AdminMemberManage.jsx | 152 ++++++++++++++---- .../AdminMemberManage.module.css | 31 ++++ frontend/src/contexts/AuthContext.jsx | 1 + frontend/src/pages/AdminHome.jsx | 16 +- frontend/src/utils/adminHomeData.js | 85 ++++++---- frontend/src/utils/adminMemberManageData.js | 5 + frontend/src/utils/adminMembersData.js | 5 + frontend/src/utils/adminUserApi.js | 6 + 10 files changed, 278 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx b/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx index c71b8854..d26d863f 100644 --- a/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx +++ b/frontend/src/components/AdminHome/PendingApprovalsPanel.jsx @@ -1,6 +1,36 @@ 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 || '가입 거절 처리에 실패했습니다.'); + } + }; -const PendingApprovalsPanel = ({ members = [], styles }) => { return (
    @@ -18,10 +48,18 @@ const PendingApprovalsPanel = ({ members = [], styles }) => {
    {member.requestedAt} - -
    diff --git a/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx index 567997f8..4511fa74 100644 --- a/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx +++ b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx @@ -18,16 +18,20 @@ import { } 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 }); @@ -44,6 +48,7 @@ const AdminMemberApprovalList = () => { loadPendingMembers(); }, []); + // 검색어 기준 클라이언트 필터링 const filteredMembers = useMemo(() => { const normalizedQuery = searchQuery.trim().toLowerCase(); if (!normalizedQuery) return pendingMembers; @@ -62,6 +67,7 @@ const AdminMemberApprovalList = () => { ); }; + // 현재 필터된 목록 전체 선택/해제 const toggleSelectAll = () => { if (selectedIds.length === filteredMembers.length) { setSelectedIds([]); @@ -90,6 +96,7 @@ const AdminMemberApprovalList = () => { setRejectDialogOpen(true); }; + // 승인 확정 처리 (단건/일괄) const confirmApprove = () => { const approveAction = async () => { try { @@ -113,6 +120,7 @@ const AdminMemberApprovalList = () => { approveAction(); }; + // 거절 확정 처리 (단건/일괄) const confirmReject = () => { const rejectAction = async () => { try { diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx index 413860f6..31ca4bad 100644 --- a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx @@ -10,7 +10,6 @@ import { } from '../../utils/adminMembersData'; const ROLE_LABELS = { - SYSTEM_ADMIN: '시스템관리자', PRESIDENT: '회장', VICE_PRESIDENT: '부회장', TEAM_LEADER: '팀장', @@ -18,18 +17,25 @@ const ROLE_LABELS = { PENDING_MEMBER: '대기회원', }; +const ROLE_OPTIONS = [ + 'PRESIDENT', + 'VICE_PRESIDENT', + 'TEAM_LEADER', + 'TEAM_MEMBER', + 'PENDING_MEMBER', +]; + const STATUS_LABELS = { ACTIVE: '활성', INACTIVE: '비활성', GRADUATED: '졸업', - OUT: '탈퇴', }; +const STATUS_OPTIONS = ['ACTIVE', 'INACTIVE', 'GRADUATED']; + const getRoleClassName = (role) => { if (role === '회장') return styles.rolePresident; - if (role === '시스템관리자') return styles.rolePresident; if (role === '부회장') return styles.roleManager; - if (role === '운영부') return styles.roleManager; if (role === '팀장') return styles.roleLeader; if (role === '대기회원') return styles.roleLeader; return styles.roleNormal; @@ -37,20 +43,31 @@ const getRoleClassName = (role) => { const getStatusClassName = (status) => { if (status === '활성') return styles.statusActive; - if (status === '비활성' || status === '졸업' || status === '탈퇴') return styles.statusInactive; + 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 [changeDialog, setChangeDialog] = useState({ + open: false, + type: 'role', + member: null, + value: '', + }); + // 회원 목록 조회 (필요한 필터만 백엔드로 전달) const loadMembers = async ({ keyword, role, status } = {}) => { try { const data = await getAdminMembersData({ keyword, role, status }); - setMembers(data.members || []); + const nextMembers = data.members || []; + setMembers(nextMembers); } catch (error) { window.alert(error?.message || '회원 목록을 불러오지 못했습니다.'); setMembers([]); @@ -61,6 +78,7 @@ const AdminMemberManage = () => { loadMembers(); }, []); + // 필터/검색 조건 변경 시 목록 재조회 useEffect(() => { const backendRole = roleFilter === 'all' ? undefined : roleFilter; const backendStatus = statusFilter === 'all' ? undefined : statusFilter; @@ -79,46 +97,62 @@ const AdminMemberManage = () => { })); }, [members]); - const handleRoleChange = async (member) => { - const roleInput = window.prompt( - '변경할 권한을 입력하세요.\n예: TEAM_MEMBER, TEAM_LEADER, VICE_PRESIDENT, PRESIDENT, SYSTEM_ADMIN, PENDING_MEMBER', - member.role - ); - - if (!roleInput) return; + const openRoleDialog = (member) => { + setChangeDialog({ + open: true, + type: 'role', + member, + value: member.role, + }); + }; - try { - await changeAdminMemberRole({ userId: member.id, role: roleInput }); - await loadMembers({ - keyword: searchQuery.trim() || undefined, - role: roleFilter === 'all' ? undefined : roleFilter, - status: statusFilter === 'all' ? undefined : statusFilter, - }); - } catch (error) { - window.alert(error?.message || '권한 변경에 실패했습니다.'); - } + const openStatusDialog = (member) => { + setChangeDialog({ + open: true, + type: 'status', + member, + value: member.status, + }); }; - const handleStatusChange = async (member) => { - const statusInput = window.prompt( - '변경할 상태를 입력하세요.\n예: ACTIVE, INACTIVE, GRADUATED, OUT', - member.status - ); + const closeChangeDialog = () => { + setChangeDialog({ open: false, type: 'role', member: null, value: '' }); + }; - if (!statusInput) return; + const confirmChangeDialog = async () => { + const { type, member, value } = changeDialog; + if (!member) return; try { - await changeAdminMemberStatus({ userId: member.id, status: statusInput }); + 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 || '상태 변경에 실패했습니다.'); + window.alert( + error?.message || + (type === 'role' ? '권한 변경에 실패했습니다.' : '상태 변경에 실패했습니다.') + ); } }; + // 단일 회원 선배 전환 const handlePromoteSenior = async (member) => { if (!window.confirm(`${member.name}님을 선배(SENIOR)로 전환하시겠습니까?`)) { return; @@ -136,6 +170,7 @@ const AdminMemberManage = () => { } }; + // 단일 회원 삭제 const handleDelete = async (member) => { if (!window.confirm(`${member.name}님을 강제 탈퇴 처리하시겠습니까?`)) { return; @@ -188,7 +223,6 @@ const AdminMemberManage = () => { - @@ -246,14 +280,14 @@ const AdminMemberManage = () => { @@ -290,6 +324,56 @@ const AdminMemberManage = () => { + + {changeDialog.open && changeDialog.member && ( +
    +
    +

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

    +

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

    + +
    + + +
    +
    +
    + )} ); }; diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css b/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css index 8f0c8e70..724c48c4 100644 --- a/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css @@ -174,6 +174,37 @@ 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; 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/AdminHome.jsx b/frontend/src/pages/AdminHome.jsx index 04839670..2699521f 100644 --- a/frontend/src/pages/AdminHome.jsx +++ b/frontend/src/pages/AdminHome.jsx @@ -19,14 +19,14 @@ const AdminHome = () => { }); useEffect(() => { - const loadAdminHomeData = async () => { - const adminHomeData = await getAdminHomeData(); - setData(adminHomeData); - }; - loadAdminHomeData(); }, []); + const loadAdminHomeData = async () => { + const adminHomeData = await getAdminHomeData(); + setData(adminHomeData); + }; + return (
    @@ -37,7 +37,11 @@ const AdminHome = () => {
    - +
    diff --git a/frontend/src/utils/adminHomeData.js b/frontend/src/utils/adminHomeData.js index 9d20292b..30ef7a9b 100644 --- a/frontend/src/utils/adminHomeData.js +++ b/frontend/src/utils/adminHomeData.js @@ -1,35 +1,56 @@ -const mockAdminHomeData = { - dashboardStats: [ - { id: 'members', title: '총 회원 수', value: '156', description: '활성 회원' }, - { id: 'visitors', title: '금일 방문자', value: '89', description: '전일 대비 +5' }, - { id: 'attendance', title: '주간 출석률', value: '78%', description: '전주 대비 +3%' }, - { id: 'pending', title: '승인 대기', value: '12', description: '가입 신청' }, - ], - pendingApprovals: [ - { id: 1, name: '김민수', email: 'minsu@sisc.com', requestedAt: '2026-02-24' }, - { id: 2, name: '박서연', email: 'seoyeon@sisc.com', requestedAt: '2026-02-24' }, - { id: 3, name: '이준호', email: 'junho@sisc.com', requestedAt: '2026-02-23' }, - ], - recentActivities: [ - { id: 1, message: '출석 체크 세션이 생성되었습니다.', time: '10분 전' }, - { id: 2, message: '공지사항이 등록되었습니다.', time: '35분 전' }, - { id: 3, message: '회원 3명이 가입 신청했습니다.', time: '1시간 전' }, - { id: 4, message: '포인트 규칙이 업데이트되었습니다.', time: '어제' }, - ], - 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' }, - ], - members: [ - { id: 1, name: '김하늘', role: 'TEAM_MEMBER', status: 'ACTIVE' }, - { id: 2, name: '최지훈', role: 'TEAM_LEADER', status: 'ACTIVE' }, - { id: 3, name: '오수빈', role: 'PENDING_MEMBER', status: 'PENDING' }, - { id: 4, name: '정우진', role: 'TEAM_MEMBER', status: 'ACTIVE' }, - ], -}; +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 () => { - return mockAdminHomeData; + const response = await api.get('/api/admin/users'); + const users = response.data || []; + + 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 index 44c75d37..32c4e322 100644 --- a/frontend/src/utils/adminMemberManageData.js +++ b/frontend/src/utils/adminMemberManageData.js @@ -1,5 +1,6 @@ import { api } from './axios'; +// 가입 승인 대기 회원 목록 조회 export const getAdminMemberManageData = async ({ keyword } = {}) => { const params = { role: 'PENDING_MEMBER', @@ -16,18 +17,21 @@ export const getAdminMemberManageData = async ({ keyword } = {}) => { }; }; +// 단일 회원 가입 승인 (대기 -> 일반 회원) 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) => @@ -38,6 +42,7 @@ export const approvePendingMembersBulk = async ({ userIds }) => { ); }; +// 다중 회원 일괄 가입 거절 export const rejectPendingMembersBulk = async ({ userIds }) => { await Promise.all( userIds.map((userId) => diff --git a/frontend/src/utils/adminMembersData.js b/frontend/src/utils/adminMembersData.js index 92c9078f..1e71e2a4 100644 --- a/frontend/src/utils/adminMembersData.js +++ b/frontend/src/utils/adminMembersData.js @@ -1,5 +1,6 @@ import { api } from './axios'; +// 관리자 회원 목록 조회 (검색/권한/상태/기수 필터) export const getAdminMembersData = async ({ keyword, role, status, generation } = {}) => { const params = {}; @@ -12,22 +13,26 @@ export const getAdminMembersData = async ({ keyword, role, status, generation } return { members: response.data || [] }; }; +// 회원 권한 변경 export const changeAdminMemberRole = async ({ userId, role }) => { await api.patch(`/api/admin/users/${userId}/role`, null, { params: { role }, }); }; +// 회원 상태 변경 export const changeAdminMemberStatus = async ({ userId, status }) => { await api.patch(`/api/admin/users/${userId}/status`, null, { params: { status }, }); }; +// 회원을 선배(SENIOR)로 전환 export const promoteAdminMemberSenior = async ({ userId }) => { await api.patch(`/api/admin/users/${userId}/senior`); }; +// 회원 강제 삭제 export const deleteAdminMember = async ({ userId }) => { await api.delete(`/api/admin/users/${userId}`); }; diff --git a/frontend/src/utils/adminUserApi.js b/frontend/src/utils/adminUserApi.js index a6d52fc3..a680c81d 100644 --- a/frontend/src/utils/adminUserApi.js +++ b/frontend/src/utils/adminUserApi.js @@ -1,5 +1,6 @@ import { api } from './axios'; +// 관리자 사용자 목록 조회 export const getAdminUsers = async ({ keyword, generation, role, status } = {}) => { const params = {}; @@ -12,26 +13,31 @@ export const getAdminUsers = async ({ keyword, generation, role, status } = {}) return response.data; }; +// 사용자 권한 변경 export const updateAdminUserRole = async ({ userId, role }) => { await api.patch(`/api/admin/users/${userId}/role`, null, { params: { role }, }); }; +// 사용자 상태 변경 export const updateAdminUserStatus = async ({ userId, status }) => { await api.patch(`/api/admin/users/${userId}/status`, null, { params: { status }, }); }; +// 사용자를 선배(SENIOR)로 전환 export const promoteAdminUserSenior = async ({ userId }) => { await api.patch(`/api/admin/users/${userId}/senior`); }; +// 사용자 삭제 export const deleteAdminUser = async ({ userId }) => { await api.delete(`/api/admin/users/${userId}`); }; +// 관리자용 엑셀 업로드 API export const uploadAdminUsersExcel = async ({ file }) => { const formData = new FormData(); formData.append('file', file); From e0d22c843d55ba14061a33a9cde7374ab8c70ab4 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Thu, 26 Feb 2026 19:57:50 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[FE]=20Admin=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20api=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 2 + .../AdminExcelUpload/AdminExcelUpload.jsx | 158 ++++++++++++++ .../AdminExcelUpload.module.css | 203 ++++++++++++++++++ .../AdminExcelUploadHeader.jsx | 48 +++++ .../AdminMemberManage/AdminMemberManage.jsx | 1 + frontend/src/pages/AdminExcelUpload.jsx | 21 ++ .../src/pages/AdminExcelUpload.module.css | 33 +++ 7 files changed, 466 insertions(+) create mode 100644 frontend/src/components/AdminExcelUpload/AdminExcelUpload.jsx create mode 100644 frontend/src/components/AdminExcelUpload/AdminExcelUpload.module.css create mode 100644 frontend/src/components/AdminExcelUpload/AdminExcelUploadHeader.jsx create mode 100644 frontend/src/pages/AdminExcelUpload.jsx create mode 100644 frontend/src/pages/AdminExcelUpload.module.css diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2d350a23..8cd345a4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,7 @@ 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 QrRenderPage from './components/attendancemanage/qrmanagement/QrRenderPage.jsx'; @@ -78,6 +79,7 @@ function App() { } /> + } /> diff --git a/frontend/src/components/AdminExcelUpload/AdminExcelUpload.jsx b/frontend/src/components/AdminExcelUpload/AdminExcelUpload.jsx new file mode 100644 index 00000000..101c0bac --- /dev/null +++ b/frontend/src/components/AdminExcelUpload/AdminExcelUpload.jsx @@ -0,0 +1,158 @@ +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); + }; + + 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..7adcb2f7 --- /dev/null +++ b/frontend/src/components/AdminExcelUpload/AdminExcelUpload.module.css @@ -0,0 +1,203 @@ +.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 { + 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/AdminMemberManage/AdminMemberManage.jsx b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx index 31ca4bad..381af4cc 100644 --- a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx @@ -68,6 +68,7 @@ const AdminMemberManage = () => { const data = await getAdminMembersData({ keyword, role, status }); const nextMembers = data.members || []; setMembers(nextMembers); + console.log('회원 목록 로드 성공:', nextMembers); } catch (error) { window.alert(error?.message || '회원 목록을 불러오지 못했습니다.'); setMembers([]); 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; + } +} From 4ef0bd39394596959c6d6e772e616ed5334edeac Mon Sep 17 00:00:00 2001 From: sangkyu Date: Tue, 3 Mar 2026 20:15:56 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[FE]=20=EC=B6=9C=EC=84=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Sidebar.jsx | 2 +- .../components/attendance/SessionManage.jsx | 129 +++++++++++++----- .../attendance/SessionManage.module.css | 55 ++------ .../attendance/SessionSelectBox.jsx | 33 +++-- .../attendance/SessionSelectBox.module.css | 34 +++-- frontend/src/pages/Attendance.jsx | 36 ++++- frontend/src/pages/Attendance.module.css | 14 +- 7 files changed, 190 insertions(+), 113 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 5f9ab057..2671a619 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -114,7 +114,7 @@ const Sidebar = ({ isOpen, isRoot, onClose }) => { } onClick={handleNavLinkClick} > - 출석하기 + 출석조회
  • diff --git a/frontend/src/components/attendance/SessionManage.jsx b/frontend/src/components/attendance/SessionManage.jsx index cb6aaca3..55eb009a 100644 --- a/frontend/src/components/attendance/SessionManage.jsx +++ b/frontend/src/components/attendance/SessionManage.jsx @@ -1,37 +1,96 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import styles from './SessionManage.module.css'; import { ClipboardCheck } from 'lucide-react'; -import { attendanceList } from '../../utils/attendanceList'; - -const SessionManage = () => { - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchAttendance = async () => { - try { - const data = await attendanceList(); - setSessions(data); - console.log(data); - } catch (err) { - setError('데이터를 불러오는 중 오류가 발생했습니다.'); - } finally { - setLoading(false); + +const getRoundKey = (session) => session.roundId || `${session.roundDate || ''}-${session.roundStartAt || ''}`; + +const getTimestamp = (session) => { + const dateSource = session.roundStartAt || session.roundDate || session.createdAt || session.checkedAt; + const timestamp = Date.parse(dateSource); + return Number.isNaN(timestamp) ? 0 : timestamp; +}; + +const formatDate = (dateValue) => { + if (!dateValue) return '-'; + const date = new Date(dateValue); + return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString(); +}; + +const formatTime = (dateValue) => { + if (!dateValue) return '-'; + const date = new Date(dateValue); + return Number.isNaN(date.getTime()) + ? '-' + : date.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); +}; + +const formatAttendanceStatus = (attendanceStatus) => { + const status = (attendanceStatus || '').toUpperCase(); + + if (status === 'PRESENT' || status === 'ATTENDED') return '출석'; + if (status === 'LATE') return '지각'; + if (status === 'ABSENT') return '결석'; + if (status === 'EXCUSED') return '공결'; + if (status === 'PENDING') return '대기'; + + return attendanceStatus || '-'; +}; + +const SessionManage = ({ sessions = [], selectedSession = '', loading, error }) => { + const roundIndexMapBySession = useMemo(() => { + const roundMapBySession = new Map(); + sessions.forEach((session) => { + const sessionTitle = session.sessionTitle || '기타'; + if (!roundMapBySession.has(sessionTitle)) { + roundMapBySession.set(sessionTitle, new Map()); + } + + const roundMap = roundMapBySession.get(sessionTitle); + const roundKey = getRoundKey(session); + if (!roundMap.has(roundKey)) { + roundMap.set(roundKey, getTimestamp(session)); } - }; + }); + + const indexedMapBySession = new Map(); + + roundMapBySession.forEach((roundMap, sessionTitle) => { + const sortedRoundKeys = Array.from(roundMap.entries()) + .sort((a, b) => a[1] - b[1]) + .map(([roundKey]) => roundKey); + + const indexedRoundMap = new Map(); + sortedRoundKeys.forEach((roundKey, index) => { + indexedRoundMap.set(roundKey, index + 1); + }); - fetchAttendance(); - }, []); + indexedMapBySession.set(sessionTitle, indexedRoundMap); + }); + + return indexedMapBySession; + }, [sessions]); + + const visibleSessions = useMemo(() => { + const filtered = selectedSession + ? sessions.filter((session) => session.sessionTitle === selectedSession) + : sessions; + + return [...filtered].sort((a, b) => getTimestamp(a) - getTimestamp(b)); + }, [sessions, selectedSession]); - if (loading) return
    로딩 중...
    ; if (error) return
    {error}
    ; + const rows = loading ? [] : visibleSessions; + return (
    - 세션 관리 + 출석 목록
    @@ -39,26 +98,28 @@ const SessionManage = () => { - - + - {sessions.map((s) => ( + {rows.map((s) => { + const sessionTitle = s.sessionTitle || '기타'; + const roundKey = getRoundKey(s); + const roundIndex = roundIndexMapBySession.get(sessionTitle)?.get(roundKey) ?? '-'; + + return ( - - - - + + + - + - ))} + ); + })}
    일자 출석시작시간출석가능시간 회차 이름출석 상태
    {new Date(s.createdAt).toLocaleDateString()}{new Date(s.checkedAt).toLocaleTimeString()}30분{s.roundId}{formatDate(s.roundDate)}{formatTime(s.roundStartAt)}{roundIndex} {s.userName} - - {formatAttendanceStatus(s.attendanceStatus)}
    diff --git a/frontend/src/components/attendance/SessionManage.module.css b/frontend/src/components/attendance/SessionManage.module.css index f70872c7..1650e2a8 100644 --- a/frontend/src/components/attendance/SessionManage.module.css +++ b/frontend/src/components/attendance/SessionManage.module.css @@ -1,40 +1,35 @@ .card { width: 100%; max-width: 955px; - min-height: 320px; + min-height: 280px; box-sizing: border-box; - border: 1px solid #c9bebe; + border: 1px solid #d9d9d9; background: #fafbfc; border-radius: 12px; padding: 20px; display: flex; flex-direction: column; - gap: 30px; + gap: 16px; } .title { display: flex; align-items: center; gap: 10px; - font-weight: 400; + font-weight: 500; font-size: 18px; - line-height: 146%; - letter-spacing: 0%; + line-height: 1.4; color: #000000; } .table { width: 100%; border-collapse: collapse; - table-layout: auto; + table-layout: fixed; overflow-x: auto; display: block; } -.table td:last-child { - text-align: right; -} - .table thead, .table tbody { display: table; @@ -45,38 +40,17 @@ .table thead th, .table tbody td { text-align: left; - padding: 8px 7px; + padding: 10px 8px; white-space: nowrap; - border-bottom: 0.4px solid #6a6a6a; - font-family: Inter; + border-bottom: 1px solid #e5e5e5; font-weight: 400; font-size: 14px; - line-height: 100%; - letter-spacing: 0%; + line-height: 1.3; color: #000000; overflow: hidden; text-overflow: ellipsis; } -.button { - background: linear-gradient( - 92.89deg, - #d8e8ff 6.95%, - #d1d8ff 74.81%, - #dddeff 107.39% - ); - width: 60px; - height: 30px; - border-radius: 8px; - border: none; - font-weight: 400; - font-size: 14px; - line-height: 100%; - letter-spacing: 0%; - color: #000000; - cursor: pointer; -} - /* 태블릿 이상 */ @media (min-width: 768px) { .card { @@ -90,24 +64,19 @@ .table { display: table; + overflow-x: visible; } .table thead th, .table tbody td { font-size: 16px; } - - .button { - width: 70px; - height: 35px; - font-size: 16px; - } } /* 데스크톱 */ @media (min-width: 1024px) { .card { - padding: 20px 40px; - gap: 50px; + padding: 30px 40px; + gap: 20px; } } diff --git a/frontend/src/components/attendance/SessionSelectBox.jsx b/frontend/src/components/attendance/SessionSelectBox.jsx index 0d7f8301..0dbb5693 100644 --- a/frontend/src/components/attendance/SessionSelectBox.jsx +++ b/frontend/src/components/attendance/SessionSelectBox.jsx @@ -1,24 +1,33 @@ import styles from './SessionSelectBox.module.css'; import { ClipboardCheck } from 'lucide-react'; -const sessionList = [ - '증권 1팀', - '증권 2팀', - '증권 3팀', - '자신운용팀', - '금융 IT팀', - '매크로팀', - '트레이딩팀', -]; +const SessionSelectBox = ({ + sessions = [], + selectedSession = '', + onChange, + disabled = false, +}) => { + const sessionList = Array.from( + new Set( + sessions + .map((session) => session.sessionTitle) + .filter((sessionTitle) => typeof sessionTitle === 'string' && sessionTitle.trim() !== ''), + ), + ); -const SessionSelectBox = () => { return (
    - onChange?.(event.target.value)} + disabled={disabled} + > {sessionList.map((item) => (