From 4a5bced43d93b7a3ddd395932cddd163ee84fb5b Mon Sep 17 00:00:00 2001 From: yyunee Date: Fri, 13 Mar 2026 18:08:51 +0900 Subject: [PATCH 01/10] [FEAT]AttendaceManagement fuction --- frontend/package-lock.json | 12 +- frontend/package.json | 1 + frontend/src/assets/add-user-icon.svg | 8 + frontend/src/assets/bin-icon.svg | 6 + frontend/src/assets/file-icon.svg | 3 + frontend/src/assets/menu-icon.svg | 5 + frontend/src/assets/profile-icon.svg | 12 + frontend/src/assets/slash-profile-icon.svg | 12 + .../attendancemanage/AddUsersModal.jsx | 145 ++++++--- .../AttendanceManagementCard.jsx | 307 ++++++++++++++---- .../AttendanceManagementCard.module.css | 91 +++++- .../SessionManagementCard.jsx | 54 +-- .../attendancemanage/SessionSettingCard.jsx | 17 +- frontend/src/contexts/AttendanceContext.jsx | 114 ++++--- frontend/src/pages/AttendanceManage.jsx | 37 --- frontend/src/utils/attendanceManage.js | 63 +++- frontend/src/utils/axios.js | 2 +- 17 files changed, 615 insertions(+), 274 deletions(-) create mode 100644 frontend/src/assets/add-user-icon.svg create mode 100644 frontend/src/assets/bin-icon.svg create mode 100644 frontend/src/assets/file-icon.svg create mode 100644 frontend/src/assets/menu-icon.svg create mode 100644 frontend/src/assets/profile-icon.svg create mode 100644 frontend/src/assets/slash-profile-icon.svg diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9fb41da6..f8018aa1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", + "baseline-browser-mapping": "^2.10.0", "eslint": "^9.33.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", @@ -3416,13 +3417,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", - "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { diff --git a/frontend/package.json b/frontend/package.json index 0ca4f885..a4d9d294 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", + "baseline-browser-mapping": "^2.10.0", "eslint": "^9.33.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/frontend/src/assets/add-user-icon.svg b/frontend/src/assets/add-user-icon.svg new file mode 100644 index 00000000..e392f761 --- /dev/null +++ b/frontend/src/assets/add-user-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/bin-icon.svg b/frontend/src/assets/bin-icon.svg new file mode 100644 index 00000000..36a625b0 --- /dev/null +++ b/frontend/src/assets/bin-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/file-icon.svg b/frontend/src/assets/file-icon.svg new file mode 100644 index 00000000..4ca379c0 --- /dev/null +++ b/frontend/src/assets/file-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/menu-icon.svg b/frontend/src/assets/menu-icon.svg new file mode 100644 index 00000000..e5549b46 --- /dev/null +++ b/frontend/src/assets/menu-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/profile-icon.svg b/frontend/src/assets/profile-icon.svg new file mode 100644 index 00000000..988a7074 --- /dev/null +++ b/frontend/src/assets/profile-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/slash-profile-icon.svg b/frontend/src/assets/slash-profile-icon.svg new file mode 100644 index 00000000..4f68e93d --- /dev/null +++ b/frontend/src/assets/slash-profile-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/components/attendancemanage/AddUsersModal.jsx b/frontend/src/components/attendancemanage/AddUsersModal.jsx index eab6baa1..0571854d 100644 --- a/frontend/src/components/attendancemanage/AddUsersModal.jsx +++ b/frontend/src/components/attendancemanage/AddUsersModal.jsx @@ -4,17 +4,15 @@ import { useAttendance } from '../../contexts/AttendanceContext'; import { getUserList } from '../../utils/attendanceManage'; const AddUsersModal = () => { - const { sessions, selectedSessionId, handleAddUsers, closeAddUsersModal } = + const { selectedSessionId, handleAddUsers, closeAddUsersModal } = useAttendance(); - const [selectedUserId, setSelectedUserId] = useState(null); const [users, setUsers] = useState([]); + const [selectedUserIds, setSelectedUserIds] = useState(new Set()); - // ESC 키로 모달 또는 토스트를 닫는 기능 useEffect(() => { - // 유저 리스트 가져오기 const fetchUsers = async () => { try { - const userList = await getUserList(); + const userList = await getUserList(selectedSessionId); setUsers(userList); } catch (err) { console.error('사용자 목록을 불러오는 데 실패했습니다:', err); @@ -25,80 +23,123 @@ const AddUsersModal = () => { } const handleKeyDown = (event) => { - if (event.key === 'Escape') { - closeAddUsersModal(); - } + if (event.key === 'Escape') closeAddUsersModal(); }; document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [closeAddUsersModal]); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [selectedSessionId, closeAddUsersModal]); - const handleComplete = () => { - const currentSession = sessions.find( - (s) => s.attendanceSessionId === selectedSessionId - ); + const toggleUser = (userId) => { + const newSelection = new Set(selectedUserIds); + if (newSelection.has(userId)) newSelection.delete(userId); + else newSelection.add(userId); + setSelectedUserIds(newSelection); + }; - if (!currentSession) { - alert('세션을 먼저 선택해주세요.'); - return; - } - if (!selectedUserId) { - alert('추가할 인원를 1명 이상 선택해주세요.'); + const toggleAll = () => { + if (selectedUserIds.size === users.length) setSelectedUserIds(new Set()); + else setSelectedUserIds(new Set(users.map((u) => u.userId))); + }; + + const handleComplete = async () => { + if (selectedUserIds.size === 0) { + alert('추가할 인원을 1명 이상 선택해주세요.'); return; } - handleAddUsers(selectedSessionId, selectedUserId); - closeAddUsersModal(); + const idsArray = Array.from(selectedUserIds); + + try { + for (const userId of idsArray) { + await handleAddUsers(selectedSessionId, userId); + } + closeAddUsersModal(); + } catch (error) { + alert('유저 추가 중 오류가 발생했습니다.'); + } }; return (
-
+
+ {' '} + {/* 목록을 위해 큰 사이즈 클래스 추가 */} +
-

세션에 유저 추가하기

+
+

세션에 유저 추가

+ {selectedUserIds.size}명 선택되었습니다. +
-
-
- - +
+ + + + + + + + + + + {users.length > 0 ? ( + users.map((user) => ( + toggleUser(user.userId)} + > + + + + + + )) + ) : ( + + + + )} + +
+ 0 && + selectedUserIds.size === users.length + } + /> + 이름팀학번
e.stopPropagation()}> + toggleUser(user.userId)} + /> + {user.name}{user.teamName}{user.studentId || '-'}
+ 불러올 수 있는 유저가 없습니다. +
-
+
diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx index 136ce105..59ba3187 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx @@ -1,94 +1,283 @@ import styles from './AttendanceManagementCard.module.css'; - +import { toast } from 'react-toastify'; import { useAttendance } from '../../contexts/AttendanceContext'; import { useEffect, useState } from 'react'; -import { getRoundUserAttendance } from '../../utils/attendanceManage'; - -const attendanceEnglishToKorean = { - PRESENT: '출석', - LATE: '지각', - ABSENT: '결석', - EXCUSED: '공결', - PENDING: '미정', +import { getUsers } from '../../utils/attendanceManage'; +import fileIcon from '../../assets/file-icon.svg'; +import addUserIcon from '../../assets/add-user-icon.svg'; +import profileIcon from '../../assets/profile-icon.svg'; +import binIcon from '../../assets/bin-icon.svg'; +import slashProfileIcon from '../../assets/slash-profile-icon.svg'; +import ConfirmationToast from './ConfirmationToast'; + +// 상태별 텍스트 및 스타일 통합 관리 +const ATTENDANCE_CONFIG = { + PRESENT: { + label: '출석', + color: '#2563eb', + bg: '#eff6ff', + }, + LATE: { label: '지각', color: '#7c3aed', bg: '#f5f3ff' }, + ABSENT: { label: '결석', color: '#dc2626', bg: '#fef2f2' }, + EXCUSED: { + label: '공결', + color: '#16a34a', + bg: '#f0fdf4', + }, + PENDING: { + label: '미정', + color: '#64748b', + bg: '#f8fafc', + }, }; const AttendanceManagementCard = ({ styles: commonStyles }) => { const { selectedSessionId, - selectedRound, handleAttendanceChange, roundAttendanceVersion, + openAddUsersModal, + handleAddManager, + handleRemoveManager, + handleDeleteUsers, } = useAttendance(); - const [users, setUsers] = useState([]); + const [attendanceData, setAttendanceData] = useState({ + sessionTitle: '', + rounds: [], + userRows: [], + }); + const [selectedUserIds, setSelectedUserIds] = useState(new Set()); + const [activeToastId, setActiveToastId] = useState(null); useEffect(() => { - const fetchUsers = async () => { - if (selectedSessionId && selectedRound) { - const userList = await getRoundUserAttendance(selectedRound); - // const sortedUsers = (userList || []).sort( - // (a, b) => - // new Date(`${a.date}T${a.startTime}`) - - // new Date(`${b.date}T${b.startTime}`) - // ); - setUsers(userList); - } else { - setUsers([]); + const fetchAttendanceSheet = async () => { + if (selectedSessionId) { + try { + const data = await getUsers(selectedSessionId); + setAttendanceData(data); + } catch (error) { + console.error('출석부 조회 실패:', error); + setAttendanceData({ sessionTitle: '', rounds: [], userRows: [] }); + } } }; - fetchUsers(); - }, [selectedSessionId, selectedRound, roundAttendanceVersion]); + fetchAttendanceSheet(); + }, [selectedSessionId, roundAttendanceVersion]); + + const confirmAction = (message, onConfirm) => { + if (selectedUserIds.size === 0) return alert('대상을 선택해주세요.'); + if (activeToastId) toast.dismiss(activeToastId); + + const handleConfirm = async () => { + await onConfirm(selectedSessionId, Array.from(selectedUserIds)); + setSelectedUserIds(new Set()); // 성공 후 선택 초기화 + toast.dismiss(); + }; + + const toastId = toast( + toast.dismiss()} + message={`${selectedUserIds.size}명의 유저에 대해 ${message}`} + />, + { + autoClose: false, + closeOnClick: false, + draggable: false, + closeButton: false, + onClose: () => setActiveToastId(null), + } + ); + setActiveToastId(toastId); + }; + + // 1. 매니저로 격상 버튼 핸들러 + const onPromoteClick = () => + confirmAction('매니저 권한을 부여하시겠습니까?', handleAddManager); + + // 2. 매니저 권한 제거 버튼 핸들러 + const onDemoteClick = () => + confirmAction('매니저 권한을 제거하시겠습니까?', handleRemoveManager); + + // 3. 유저 삭제(퇴출) 버튼 핸들러 + const onDeleteUsersClick = () => + confirmAction('유저를 삭제하시겠습니까?', handleDeleteUsers); + + const toggleUserSelection = (userId) => { + const newSelection = new Set(selectedUserIds); + newSelection.has(userId) + ? newSelection.delete(userId) + : newSelection.add(userId); + setSelectedUserIds(newSelection); + }; + + const toggleAllUsers = () => { + if ( + selectedUserIds.size === attendanceData.userRows.length && + attendanceData.userRows.length > 0 + ) { + setSelectedUserIds(new Set()); + } else { + setSelectedUserIds(new Set(attendanceData.userRows.map((u) => u.userId))); + } + }; return (
-

출석 관리

+
+
+ 세션 아이콘 +
+

세션별 유저 관리

+ + {selectedUserIds.size}명 선택되었습니다. + +
+
+
+ +
+ +
+ + + +
+
+
- +
- - - - + + + + + {attendanceData.rounds.map((round, index) => ( + + ))} - {!selectedRound ? ( - - - - ) : users.length > 0 ? ( - users.map((user) => ( - - - - + - + + + + {user.attendances.map((att) => { + const config = + ATTENDANCE_CONFIG[att.status] || + ATTENDANCE_CONFIG.PENDING; + return ( + + ); + })} )) ) : ( - )} diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css b/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css index f196ed5e..7e686241 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css @@ -14,6 +14,86 @@ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07); } +.headerLeft { + display: flex; +} + +/* 헤더 왼쪽 타이틀 및 카운트 정렬 */ +.titleArea { + display: flex; + align-items: center; + gap: 12px; +} + +.selectedCount { + font-size: 12px; + color: #64748b; + margin-top: 2px; + display: block; +} + +.selectedCount strong { + color: #2563eb; +} + +/* 우측 액션 버튼들 그룹핑 */ +.headerActions { + display: flex; + align-items: center; +} +.addUserButton { + border: 0.4px solid #aaaaaa; + border-radius: 4px; + background: none; + font: #6a6a6a; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + height: 40px; + width: 136px; +} +.addUserButton img { + width: 24px; + height: 24px; +} + +.buttonWithIcon { + padding: 0; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.handleRoleButton { + background: none; + border: none; +} +.handleRoleButton img { + width: 40px; + height: 40px; +} + +.iconActionButton img { + width: 24px; + height: 24px; +} +.iconActionButton:last-child:hover { + background: #fef2f2; + border-color: #fecaca; +} + +/* 화면이 매우 작아질 때를 대비한 미디어 쿼리 */ +@media (max-width: 768px) { + .actionButtonGroup { + display: flex; + } +} + .tableGroup { border-radius: 8px; overflow: auto; @@ -21,8 +101,8 @@ border: 1px solid #e5e7eb; background: white; width: 100%; + display: block; } - .tableGroup::-webkit-scrollbar { width: 10px; height: 10px; @@ -46,10 +126,8 @@ .table { width: 100%; border-collapse: collapse; - border-spacing: 0; - table-layout: fixed; + table-layout: auto; } - .attendanceSelect { display: inline-flex; align-items: center; @@ -127,19 +205,24 @@ .table tbody td:nth-child(1) { font-weight: 500; + width: 40px; color: #1e293b; } .table tbody td:nth-child(2) { white-space: nowrap; + min-width: 100px; + padding-left: 10px; } .table tbody td:nth-child(3) { white-space: nowrap; + min-width: 120px; } .table tbody td:nth-child(4) { white-space: nowrap; + min-width: 100px; } /* 컬럼 너비 - 비율 기반 */ diff --git a/frontend/src/components/attendancemanage/SessionManagementCard.jsx b/frontend/src/components/attendancemanage/SessionManagementCard.jsx index 9659f40e..8b4844fe 100644 --- a/frontend/src/components/attendancemanage/SessionManagementCard.jsx +++ b/frontend/src/components/attendancemanage/SessionManagementCard.jsx @@ -4,8 +4,7 @@ import calendarAddIcon from '../../assets/calendar-icon.svg'; import { useContext, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { useAttendance } from '../../contexts/AttendanceContext'; -import { getRounds, addRound } from '../../utils/attendanceManage'; -import RoundDayPicker from './RoundDayPicker'; +import { getRounds } from '../../utils/attendanceManage'; // 날짜 포맷 함수 const formatDate = (dateStr) => { @@ -20,7 +19,6 @@ const SessionManagementCard = ({ styles: commonStyles }) => { const { sessions, roundsVersion, - handleAddRounds, openAddRoundsModal, selectedSessionId, setSelectedSessionId, @@ -52,27 +50,29 @@ const SessionManagementCard = ({ styles: commonStyles }) => { fetchRounds(); }, [selectedSessionId, roundsVersion]); - const handleAddRound = async (newRoundData) => { - if (!selectedSessionId) { - toast.error('세션을 먼저 선택해주세요.'); - return; - } - - try { - await handleAddRounds(selectedSessionId, [newRoundData]); - - toast.success('라운드가 추가되었습니다.'); - } catch (err) { - toast.error('라운드 추가에 실패했습니다.'); - } - }; - return (

세션 관리

+ {/*세션 선택 드롭다운 */} +
+ +
- {/*세션 선택 드롭다운 */} -
- -
- {/* 라운드 테이블 (API 연결 전 구조만) */}
이름상태변경이메일 + 0 && + selectedUserIds.size === attendanceData.userRows.length + } + /> + 이름역할학번 + {round.roundNumber}회차 +
- 회차를 선택해주세요. -
{user.userName}{attendanceEnglishToKorean[user.attendanceStatus]} - + {attendanceData.userRows.length > 0 ? ( + attendanceData.userRows.map((user) => ( +
+ toggleUserSelection(user.userId)} + /> {user.email}{user.userName}{user.role}{user.studentId} + +
- 참가자가 없습니다. + + 데이터가 존재하지 않습니다.
diff --git a/frontend/src/components/attendancemanage/SessionSettingCard.jsx b/frontend/src/components/attendancemanage/SessionSettingCard.jsx index c32ac6ce..0de01141 100644 --- a/frontend/src/components/attendancemanage/SessionSettingCard.jsx +++ b/frontend/src/components/attendancemanage/SessionSettingCard.jsx @@ -8,7 +8,7 @@ const SessionSettingCard = ({ styles: commonStyles }) => { const [sessionTitle, setSessionTitle] = useState(''); const [description, setDescription] = useState(''); const [allowedMinutes, setAllowedMinutes] = useState(''); - const [status, setStatus] = useState('OPEN'); + const status = 'OPEN'; // 세션 생성 시 기본 상태는 OPEN으로 설정 const handleCreateClick = async () => { const parsedMinutes = parseInt(allowedMinutes, 10); @@ -97,21 +97,6 @@ const SessionSettingCard = ({ styles: commonStyles }) => { - {/* 세션 상태 */} -
- - -
-
diff --git a/frontend/src/contexts/AttendanceContext.jsx b/frontend/src/contexts/AttendanceContext.jsx index 7b167826..bb2adb0c 100644 --- a/frontend/src/contexts/AttendanceContext.jsx +++ b/frontend/src/contexts/AttendanceContext.jsx @@ -17,54 +17,13 @@ import { deleteSession, getAttendanceSessions, getRounds, + addManager, + deleteManager, + deleteUser, } from '../utils/attendanceManage'; export const AttendanceContext = createContext(null); -// 세션 목 데이터 -const sessionData = [ - // { - // id: 'session-1', - // title: '금융 IT팀 세션', - // // 세션의 기본 위치 정보 - // location: { - // lat: 37.5499, - // lng: 127.0751, - // }, - // defaultStartTime: '18:30:00', // 세션의 기본 시간 설정 - // defaultAvailableMinutes: 30, // 출석 인정 시간 (분 단위) - // rewardPoints: 100, // 세션의 리워드 - // isVisible: true, // 세션 공개 여부 - // // 세션 회차들 - // rounds: [ - // { - // id: 'round-1', - // date: '2025-11-06', - // startTime: '10:00:00', - // availableMinutes: 20, - // status: 'opened', - // participants: [ - // { memberId: 'member-1', name: '김민준', attendance: '출석' }, - // { memberId: 'member-2', name: '이서연', attendance: '결석' }, - // { memberId: 'member-3', name: '박도윤', attendance: '출석' }, - // ], - // }, - // { - // id: 'round-2', - // date: '2025-11-06', - // startTime: '11:00:00', - // availableMinutes: 30, - // status: 'opened', - // participants: [ - // { memberId: 'member-1', name: '김민준', attendance: '출석' }, - // { memberId: 'member-2', name: '이서연', attendance: '출석' }, - // { memberId: 'member-3', name: '박도윤', attendance: '결석' }, - // ], - // }, - // ], - // }, -]; - export const AttendanceProvider = ({ children }) => { const [sessions, setSessions] = useImmer([]); const [selectedSessionId, setSelectedSessionId] = useState(null); @@ -95,28 +54,22 @@ export const AttendanceProvider = ({ children }) => { fetchSessions(); }, [fetchSessions]); - const handleAttendanceChange = async (memberId, newAttendance) => { - // setSessions((draft) => { - // const session = draft.find((s) => s.id === selectedSessionId); - // if (!session) return; - // const round = session.rounds.find((r) => r.id === selectedRound); - // if (!round) return; - // const participant = round.participants.find( - // (p) => p.memberId === memberId - // ); - // if (participant) { - // participant.attendance = newAttendance; - // } - // }); + const handleAttendanceChange = async (userId, roundId, newStatus) => { try { - await changeUserAttendance(selectedRound, memberId, { - status: newAttendance, + // API 호출: 이제 selectedRound가 아닌 매개변수로 받은 roundId를 사용합니다. + await changeUserAttendance(roundId, userId, { + status: newStatus, reason: '관리자에 의한 출석 상태 변경', }); + // 버전 업을 통해 AttendanceManagementCard의 useEffect가 다시 실행되어 목록을 갱신합니다. setRoundAttendanceVersion((prev) => prev + 1); + + // 선택 사항: 성공 토스트 + // toast.success('출석 상태가 변경되었습니다.'); } catch (error) { console.error('유저 출석 상태 변경에 실패했습니다. ', error); + alert('출석 상태 변경에 실패했습니다.'); } }; @@ -278,6 +231,45 @@ export const AttendanceProvider = ({ children }) => { } }; + const handleDeleteUsers = async (sessionId, userIds) => { + try { + const promises = userIds.map((userId) => deleteUser(sessionId, userId)); + await Promise.all(promises); + setRoundAttendanceVersion((v) => v + 1); + } catch (error) { + console.error('유저 삭제 실패', error); + alert('일부 유저 삭제에 실패했습니다.'); + } + }; + + const handleAddManager = async (sessionId, userIds) => { + try { + const promises = userIds.map((userId) => addManager(sessionId, userId)); + await Promise.all(promises); + + setRoundAttendanceVersion((v) => v + 1); + alert('선택한 유저가 매니저로 격상되었습니다.'); + } catch (error) { + console.error('매니저 추가 실패:', error); + alert('매니저 권한 부여에 실패했습니다.'); + } + }; + + const handleRemoveManager = async (sessionId, userIds) => { + try { + const promises = userIds.map((userId) => + deleteManager(sessionId, userId) + ); + await Promise.all(promises); + + setRoundAttendanceVersion((v) => v + 1); + alert('선택한 유저가 일반 참가자로 변경되었습니다.'); + } catch (error) { + console.error('매니저 제거 실패:', error); + alert('권한 제거에 실패했습니다. (OWNER 여부 확인 필요)'); + } + }; + // 공유할 값들을 객체로 묶기 const value = { sessions, @@ -308,10 +300,14 @@ export const AttendanceProvider = ({ children }) => { closeAddRoundsModal, roundsVersion, roundAttendanceVersion, + openAddUsersModal, closeAddUsersModal, isAddUsersModalOpen, handleAddUsers, + handleDeleteUsers, + handleAddManager, + handleRemoveManager, }; return ( diff --git a/frontend/src/pages/AttendanceManage.jsx b/frontend/src/pages/AttendanceManage.jsx index 138e516c..070fbae4 100644 --- a/frontend/src/pages/AttendanceManage.jsx +++ b/frontend/src/pages/AttendanceManage.jsx @@ -12,17 +12,6 @@ import SessionModifyModal from '../components/attendancemanage/SessionModifyModa import RoundDayPicker from '../components/attendancemanage/RoundDayPicker'; import { ToastContainer } from 'react-toastify'; -import { - getAttendanceSessions, - addUser, - getUsers, - getAttendance, - getSessionAttendance, - getRounds, - changeUserAttendance, - getRoundUserAttendance, - getUserList, -} from '../utils/attendanceManage'; import AddUsersModal from '../components/attendancemanage/AddUsersModal'; const AttendanceContent = () => { @@ -45,32 +34,6 @@ const AttendanceContent = () => { return ( <> - {/* - - */} - - {/* --------------------------------------------------------------- */} -
diff --git a/frontend/src/utils/attendanceManage.js b/frontend/src/utils/attendanceManage.js index 861ff3b1..71c7bd4a 100644 --- a/frontend/src/utils/attendanceManage.js +++ b/frontend/src/utils/attendanceManage.js @@ -123,9 +123,13 @@ export const changeRoundData = async (roundId, updateRoundData) => { // 세션에 유저 추가 export const addUser = async (sessionId, userId) => { try { - const res = await api.post(`/api/attendance/sessions/${sessionId}/users`, { - userId: userId, - }); + const res = await api.post( + `/api/attendance/sessions/${sessionId}/users`, + null, + { + params: { userId: userId }, + } + ); return res.data; } catch (err) { console.error('유저 추가 중 오류 발생', err); @@ -133,6 +137,18 @@ export const addUser = async (sessionId, userId) => { } }; +export const deleteUser = async (sessionId, userId) => { + try { + const res = await api.delete( + `/api/attendance/sessions/${sessionId}/users/${userId}` + ); + return res.data; + } catch (err) { + console.error('세션 삭제 중 오류 발생', err); + throw err; + } +}; + // 세션 참여자 조회 export const getUsers = async (sessionId) => { try { @@ -193,13 +209,48 @@ export const getRoundUserAttendance = async (roundId) => { } }; -// 전체 유저 조회 -export const getUserList = async () => { +// 세션에 추가가능한 유저 조회 +export const getUserList = async (sessionId) => { try { - const res = await api.get('/api/attendance/sessions/get-users'); + const res = await api.get( + `/api/attendance/sessions/${sessionId}/users/available` + ); return res.data; } catch (err) { console.error('모든 유저 데이터 조회 중 오류 발생', err); throw err; } }; +//관리자 권한 추가 +export const addManager = async (sessionId, userId) => { + try { + const res = await api.post( + `/api/attendance/sessions/${sessionId}/admins/${userId}` + ); + + return res.data; + } catch (err) { + if (err.response) { + console.error('서버 에러 응답:', err.response.data); + } + console.error('매니저 권한 추가 중 오류 발생:', err); + throw err; + } +}; + +// 세션 관리자 권한 제거 (일반 참가자로 강등) +export const deleteManager = async (sessionId, userId) => { + try { + const res = await api.delete( + `/api/attendance/sessions/${sessionId}/admins/${userId}` + ); + + return res.data; + } catch (err) { + if (err.response) { + console.error('서버 에러 응답:', err.response.data); + } + console.error('매니저 권한 제거 중 오류 발생:', err); + throw err; + } +}; diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js index 497b6cfd..921ed684 100644 --- a/frontend/src/utils/axios.js +++ b/frontend/src/utils/axios.js @@ -1,7 +1,7 @@ import axios from 'axios'; const BASE_URL = import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL || ''; export const api = axios.create({ - baseURL: BASE_URL, + baseURL: '', withCredentials: true, }); From 16b75c01e47106584e0964eef8362af463554188 Mon Sep 17 00:00:00 2001 From: yyunee Date: Sat, 14 Mar 2026 12:56:58 +0900 Subject: [PATCH 02/10] [FEAT]Session Management Function --- .../AttendanceManagementCard.jsx | 20 +- .../SessionManagementCard.jsx | 117 ++++++++- .../SessionManagementCard.module.css | 38 +++ .../attendancemanage/SessionModifyModal.jsx | 225 +++++------------- frontend/src/contexts/AttendanceContext.jsx | 34 ++- frontend/src/pages/AttendanceManage.jsx | 16 +- frontend/src/utils/attendanceManage.js | 4 +- 7 files changed, 239 insertions(+), 215 deletions(-) diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx index 59ba3187..104d68f0 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx @@ -52,14 +52,18 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { useEffect(() => { const fetchAttendanceSheet = async () => { - if (selectedSessionId) { - try { - const data = await getUsers(selectedSessionId); - setAttendanceData(data); - } catch (error) { - console.error('출석부 조회 실패:', error); - setAttendanceData({ sessionTitle: '', rounds: [], userRows: [] }); - } + if (!selectedSessionId) { + setAttendanceData({ sessionTitle: '', rounds: [], userRows: [] }); + setSelectedUserIds(new Set()); + return; + } + + try { + const data = await getUsers(selectedSessionId); + setAttendanceData(data); + } catch (error) { + console.error('출석부 조회 실패:', error); + setAttendanceData({ sessionTitle: '', rounds: [], userRows: [] }); } }; fetchAttendanceSheet(); diff --git a/frontend/src/components/attendancemanage/SessionManagementCard.jsx b/frontend/src/components/attendancemanage/SessionManagementCard.jsx index 8b4844fe..bb5db8ef 100644 --- a/frontend/src/components/attendancemanage/SessionManagementCard.jsx +++ b/frontend/src/components/attendancemanage/SessionManagementCard.jsx @@ -1,12 +1,14 @@ import styles from './SessionManagementCard.module.css'; import calendarAddIcon from '../../assets/calendar-icon.svg'; +import menuIcon from '../../assets/menu-icon.svg'; -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; // useRef 추가 import { toast } from 'react-toastify'; +import ConfirmationToast from './ConfirmationToast'; +import SessionModifyModal from './SessionModifyModal'; import { useAttendance } from '../../contexts/AttendanceContext'; import { getRounds } from '../../utils/attendanceManage'; -// 날짜 포맷 함수 const formatDate = (dateStr) => { if (!dateStr) return ''; const date = new Date(dateStr); @@ -22,22 +24,39 @@ const SessionManagementCard = ({ styles: commonStyles }) => { openAddRoundsModal, selectedSessionId, setSelectedSessionId, + handleDeleteSession, // Context에서 가져온 삭제 함수 + openSessionModifyModal, + closeSessionModifyModal, + isSessionModifyModalOpen, + handleSessionChange, } = useAttendance(); + const [currentDisplayedRounds, setCurrentDisplayedRounds] = useState([]); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuRef = useRef(null); const sessionList = sessions || []; - const currentSession = sessionList.find( (session) => String(session.sessionId) === String(selectedSessionId) ); + // 외부 클릭 시 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + useEffect(() => { const fetchRounds = async () => { if (!selectedSessionId) { setCurrentDisplayedRounds([]); return; } - try { const rounds = await getRounds(selectedSessionId); setCurrentDisplayedRounds(rounds || []); @@ -46,26 +65,63 @@ const SessionManagementCard = ({ styles: commonStyles }) => { setCurrentDisplayedRounds([]); } }; - fetchRounds(); }, [selectedSessionId, roundsVersion]); + // 세션 수정 클릭 핸들러 + const onEditClick = () => { + if (!currentSession) { + toast.error('세션을 먼저 선택해주세요.'); + return; + } + openSessionModifyModal(); + setIsMenuOpen(false); + }; + + // 세션 삭제 클릭 핸들러 + const onDeleteClick = () => { + setIsMenuOpen(false); + + toast( + ({ closeToast }) => ( + { + try { + if (selectedSessionId) { + await handleDeleteSession(selectedSessionId); + toast.success('세션이 삭제되었습니다.'); + } + } catch (error) { + toast.error('세션 삭제에 실패했습니다.'); + } + }} + closeToast={closeToast} + /> + ), + { + position: 'top-center', + autoClose: false, + closeOnClick: false, + draggable: false, + closeButton: false, + } + ); + }; + return (

세션 관리

-
- {/*세션 선택 드롭다운 */}
+
+ + {/* 메뉴 영역 */} +
+ + + {isMenuOpen && ( +
+ + +
+ )} +
- {/* 라운드 테이블 (API 연결 전 구조만) */}
@@ -108,7 +189,6 @@ const SessionManagementCard = ({ styles: commonStyles }) => { currentDisplayedRounds.map((round, index) => { const startTime = new Date(round.startAt); const closeTime = new Date(round.closeAt); - const minutes = Math.floor((closeTime - startTime) / 60000); return ( @@ -148,6 +228,21 @@ const SessionManagementCard = ({ styles: commonStyles }) => {
+ {isSessionModifyModalOpen && currentSession && ( + { + try { + await handleSessionChange(sessionId, data); + toast.success('세션이 수정되었습니다.'); + } catch (error) { + toast.error('세션 수정에 실패했습니다.'); + } + }} + /> + )}
); }; diff --git a/frontend/src/components/attendancemanage/SessionManagementCard.module.css b/frontend/src/components/attendancemanage/SessionManagementCard.module.css index 68eaf97b..19b862a9 100644 --- a/frontend/src/components/attendancemanage/SessionManagementCard.module.css +++ b/frontend/src/components/attendancemanage/SessionManagementCard.module.css @@ -219,6 +219,44 @@ font-size: 13px; font-weight: 500; } +.menuWrapper { + position: relative; + display: flex; + align-items: center; +} + +.dropdownMenu { + position: absolute; + top: 45px; /* 버튼 아래 적당한 위치 */ + right: 0; + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + z-index: 999; + display: flex; + flex-direction: column; + min-width: 120px; + overflow: hidden; +} + +.dropdownMenu button { + padding: 10px 15px; + border: none; + background: none; + text-align: left; + cursor: pointer; + font-size: 14px; +} + +.dropdownMenu button:hover { + background-color: #f9f9f9; +} + +.dropdownMenu .deleteBtn { + color: #ff4d4f; /* 삭제 버튼 빨간색 */ + border-top: 1px solid #f0f0f0; +} /* 태블릿 이상 */ @media (min-width: 768px) { diff --git a/frontend/src/components/attendancemanage/SessionModifyModal.jsx b/frontend/src/components/attendancemanage/SessionModifyModal.jsx index 47c11bf6..e1d0e61e 100644 --- a/frontend/src/components/attendancemanage/SessionModifyModal.jsx +++ b/frontend/src/components/attendancemanage/SessionModifyModal.jsx @@ -1,125 +1,41 @@ import { useState, useEffect } from 'react'; import styles from '../VerificationModal.module.css'; -import { toast } from 'react-toastify'; -import ConfirmationToast from './ConfirmationToast'; const SessionModifyModal = ({ styles: commonStyles, onClose, session, onSave, - onDelete, }) => { - const [activeToastId, setActiveToastId] = useState(null); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [allowedMinutes, setAllowedMinutes] = useState(0); - // 받은 session 파싱 - const parseTime = (timeStr) => { - const parts = (timeStr || '00:00:00').split(':'); - return { - h: parts[0] || '00', - m: parts[1] || '00', - s: parts[2] || '00', - }; - }; - const { h = '00', m = '00', s = '00' } = parseTime(session.defaultStartTime); - - const [hh, setHh] = useState(h); - const [mm, setMm] = useState(m); - const [ss, setSs] = useState(s); - const [defaultAvailableMinutes, setDefaultAvailableMinutes] = useState( - session.defaultAvailableMinutes - ); - - // ESC 키로 모달 또는 토스트를 닫는 기능 useEffect(() => { - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - if (activeToastId) { - toast.dismiss(activeToastId); - setActiveToastId(null); - } else { - onClose(); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [onClose, activeToastId]); - - const isFormValid = (hour, minute, second, defaultAvailableMinutes) => { - if (isNaN(hour) || hour < 0 || hour > 23) { - alert('출석 시작 시간(시)은 0-23 사이의 숫자로 입력해주세요.'); - return false; - } - if (isNaN(minute) || minute < 0 || minute > 59) { - alert('출석 시작 시간(분)은 0-59 사이의 숫자로 입력해주세요.'); - return false; - } - if (isNaN(second) || second < 0 || second > 59) { - alert('출석 시작 시간(초)은 0-59 사이의 숫자로 입력해주세요.'); - return false; + if (session && session.session) { + setTitle(session.session.title || ''); + setDescription(session.session.description || ''); + setAllowedMinutes(session.session.allowedMinutes || 0); } - if ( - isNaN(defaultAvailableMinutes) || - defaultAvailableMinutes < 0 || - defaultAvailableMinutes > 59 - ) { - alert('출석 가능 시간(분)은 0-59 사이의 숫자로 입력해주세요.'); - return false; - } - return true; - }; + }, [session]); const handleModifyClick = () => { - const hour = parseInt(hh, 10); - const minute = parseInt(mm, 10); - const second = parseInt(ss, 10); - const availableMinute = parseInt(defaultAvailableMinutes, 10); - - // 유효성 검사 - if (!isFormValid(hour, minute, second, availableMinute)) return; + const minutes = parseInt(allowedMinutes, 10); + if (isNaN(minutes) || minutes < 0) { + alert('체크인 허용 시간은 0 이상의 숫자로 입력해주세요.'); + return; + } - // 상위 컴포넌트로 업데이트된 회차 데이터 전달 - onSave({ - attendanceSessionId: session.attendanceSessionId, - title: session.title, - defaultStartTime: `${hh.padStart(2, '0')}:${mm.padStart(2, '0')}:${ss.padStart(2, '0')}`, - allowedMinutes: defaultAvailableMinutes, - rewardPoints: session.rewardPoints, - latitude: session.location.lat, - longitude: session.location.lng, - radiusMeters: session.radiusMeters, + onSave(session.sessionId, { + title, + description, + allowedMinutes: minutes, + status: session.session.status || 'ACTIVE' }); onClose(); }; - const handleDeleteClick = () => { - const handleConfirm = () => { - onDelete(session.attendanceSessionId); - onClose(); - }; - - const toastId = toast( - , - { - autoClose: false, - closeOnClick: false, - draggable: false, - closeButton: false, - onClose: () => setActiveToastId(null), // 토스트가 닫히면 초기화 - } - ); - setActiveToastId(toastId); - }; - return (
@@ -129,7 +45,6 @@ const SessionModifyModal = ({ type="button" className={styles.closeButton} onClick={onClose} - disabled={!!activeToastId} // 토스트가 활성화되면 버튼 비활성화 > × @@ -137,67 +52,55 @@ const SessionModifyModal = ({
-
-
-
diff --git a/frontend/src/contexts/AttendanceContext.jsx b/frontend/src/contexts/AttendanceContext.jsx index bb2adb0c..c7471980 100644 --- a/frontend/src/contexts/AttendanceContext.jsx +++ b/frontend/src/contexts/AttendanceContext.jsx @@ -101,23 +101,29 @@ export const AttendanceProvider = ({ children }) => { } }; - const handleSessionChange = async (updateSessionData) => { - // setSessions((draft) => { - // const session = draft.find((s) => s.id === updateSessionData.id); - // if (session) { - // session.defaultStartTime = updateSessionData.defaultStartTime; - // session.defaultAvailableMinutes = - // updateSessionData.defaultAvailableMinutes; - // } - // }); + const handleSessionChange = async (sessionId, updateSessionData) => { + // 낙관적 업데이트 + setSessions((draft) => { + const session = draft.find((s) => String(s.sessionId) === String(sessionId)); + if (session && session.session) { + if (updateSessionData.title !== undefined) session.session.title = updateSessionData.title; + if (updateSessionData.description !== undefined) session.session.description = updateSessionData.description; + if (updateSessionData.allowedMinutes !== undefined) session.session.allowedMinutes = updateSessionData.allowedMinutes; + if (updateSessionData.status !== undefined) session.session.status = updateSessionData.status; + } + }); try { - await changeSessionData(updateSessionData); + await changeSessionData(sessionId, updateSessionData); const updatedSessions = await getAttendanceSessions(); setSessions(updatedSessions || []); } catch (error) { console.error('세션 수정에 실패했습니다. ', error); + // 실패 시 롤백 (전체 갱신) + const restoredSessions = await getAttendanceSessions(); + setSessions(restoredSessions || []); + throw error; } }; @@ -175,14 +181,6 @@ export const AttendanceProvider = ({ children }) => { // draft.splice(sessionIndex, 1); // } // }); - - // 세션 삭제 시 먼저 해당 세션의 회차들 삭제 - const roundsToDelete = await getRounds(sessionId); - if (roundsToDelete && roundsToDelete.length > 0) { - for (const round of roundsToDelete) { - await deleteRound(round.id); - } - } // 세션 삭제 await deleteSession(sessionId); diff --git a/frontend/src/pages/AttendanceManage.jsx b/frontend/src/pages/AttendanceManage.jsx index 070fbae4..a054d956 100644 --- a/frontend/src/pages/AttendanceManage.jsx +++ b/frontend/src/pages/AttendanceManage.jsx @@ -8,7 +8,6 @@ import SessionSettingCard from '../components/attendancemanage/SessionSettingCar import AttendanceManagementCard from '../components/attendancemanage/AttendanceManagementCard'; import SessionManagementCard from '../components/attendancemanage/SessionManagementCard'; import RoundModifyModal from '../components/attendancemanage/RoundModifyModal'; -import SessionModifyModal from '../components/attendancemanage/SessionModifyModal'; import RoundDayPicker from '../components/attendancemanage/RoundDayPicker'; import { ToastContainer } from 'react-toastify'; @@ -21,11 +20,6 @@ const AttendanceContent = () => { editingRound, closeRoundModifyModal, handleRoundChange, - isSessionModifyModalOpen, - editingSession, - closeSessionModifyModal, - handleSessionChange, - handleDeleteSession, handleDeleteRound, isAddRoundsModalOpen, selectedSessionId, @@ -52,15 +46,7 @@ const AttendanceContent = () => { onDelete={handleDeleteRound} /> )} - {isSessionModifyModalOpen && ( - - )} + {isAddRoundsModalOpen && } {isAddUsersModalOpen && } diff --git a/frontend/src/utils/attendanceManage.js b/frontend/src/utils/attendanceManage.js index 71c7bd4a..02a5adc2 100644 --- a/frontend/src/utils/attendanceManage.js +++ b/frontend/src/utils/attendanceManage.js @@ -93,10 +93,10 @@ export const deleteRound = async (roundId) => { }; // 세션 정보 수정 -export const changeSessionData = async (updateSessionData) => { +export const changeSessionData = async (sessionId, updateSessionData) => { try { const res = await api.put( - `/api/attendance/sessions/${updateSessionData.attendanceSessionId}`, + `/api/attendance/sessions/${sessionId}`, updateSessionData ); return res.data; From 6ac8e7260ada92479547992d60f62edec051a529 Mon Sep 17 00:00:00 2001 From: yyunee Date: Sat, 14 Mar 2026 14:45:42 +0900 Subject: [PATCH 03/10] [FIX]button design --- frontend/src/assets/pencil-icon_2.svg | 3 + frontend/src/assets/x-icon.svg | 4 + .../AttendanceManagementCard.jsx | 3 +- .../AttendanceManagementCard.module.css | 20 ++-- .../SessionManagementCard.jsx | 43 ++++++++- .../SessionManagementCard.module.css | 94 ++++++++++++++++--- .../attendancemanage/SessionSettingCard.jsx | 4 +- .../SessionSettingCard.module.css | 82 ++++++++++------ 8 files changed, 198 insertions(+), 55 deletions(-) create mode 100644 frontend/src/assets/pencil-icon_2.svg create mode 100644 frontend/src/assets/x-icon.svg diff --git a/frontend/src/assets/pencil-icon_2.svg b/frontend/src/assets/pencil-icon_2.svg new file mode 100644 index 00000000..bc604a66 --- /dev/null +++ b/frontend/src/assets/pencil-icon_2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/x-icon.svg b/frontend/src/assets/x-icon.svg new file mode 100644 index 00000000..5586be5c --- /dev/null +++ b/frontend/src/assets/x-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx index 104d68f0..12878c2d 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx @@ -153,7 +153,8 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { openAddUsersModal(); }} > - 유저 추가 + + 세션 유저 추가
- + {variant === 'roleChange' ? ( + <> + + + + ) : ( + <> + + + + )}
); diff --git a/frontend/src/components/attendancemanage/ConfirmationToast.module.css b/frontend/src/components/attendancemanage/ConfirmationToast.module.css index 60c69bd8..685ed348 100644 --- a/frontend/src/components/attendancemanage/ConfirmationToast.module.css +++ b/frontend/src/components/attendancemanage/ConfirmationToast.module.css @@ -3,44 +3,114 @@ flex-direction: column; align-items: center; justify-items: center; - margin-left: 40px; + width: min(92vw, 520px); + min-width: 0; + max-width: 100%; + padding: 8px 10px; + box-sizing: border-box; } .message { - font-size: 15px; - color: #333; + font-size: 16px; + font-weight: 700; + color: #111827; text-align: center; - margin-bottom: 12px; + margin: 0 0 8px; +} + +.description { + font-size: 14px; + color: #4b5563; + text-align: center; + margin: 0 0 14px; } .buttonGroup { display: flex; - gap: 8px; + gap: 10px; justify-content: center; + width: 100%; + flex-wrap: nowrap; } .button { - border: none; - padding: 8px 12px; - border-radius: 4px; + border: 1px solid #d1d5db; + padding: 10px 16px; + border-radius: 8px; cursor: pointer; - font-weight: bold; + font-weight: 600; + min-width: 132px; + max-width: 100%; + white-space: nowrap; + flex: 1 1 0; } .confirmButton { - background-color: #d9534f; + background-color: #2563eb; color: white; + border-color: #2563eb; } .confirmButton:hover { - background-color: #c9302c; + background-color: #1d4ed8; } .cancelButton { - background-color: #f0f0f0; - color: #333; + background-color: #f9fafb; + color: #374151; } .cancelButton:hover { - background-color: #e0e0e0; + background-color: #f3f4f6; +} + +.roleChangeContainer { + width: min(92vw, 560px); + min-width: 0; + max-width: 100%; + padding: 8px 4px; +} + +.roleChangeTitle { + font-size: clamp(18px, 2.5vw, 22px); + font-weight: 700; + line-height: 1.35; + margin-bottom: 10px; + white-space: normal; + word-break: keep-all; + overflow-wrap: anywhere; + max-width: 100%; +} + +.description { + max-width: 100%; + word-break: keep-all; + overflow-wrap: anywhere; +} + +@media (max-width: 480px) { + .toastContainer, + .roleChangeContainer { + width: min(94vw, 420px); + padding: 6px 2px; + } + + .button { + min-width: 110px; + width: auto; + flex: 1 1 0; + padding: 9px 12px; + font-size: 13px; + } +} + +.roleConfirmButton { + background-color: #2f80ed; + color: #fff; + border-color: #2f80ed; + min-width: 148px; +} + +.roleConfirmButton:hover { + background-color: #1f6fdc; } diff --git a/frontend/src/components/attendancemanage/RoundDayPicker.jsx b/frontend/src/components/attendancemanage/RoundDayPicker.jsx index 30e4fdc6..dee7f1e1 100644 --- a/frontend/src/components/attendancemanage/RoundDayPicker.jsx +++ b/frontend/src/components/attendancemanage/RoundDayPicker.jsx @@ -1,21 +1,47 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import styles from '../VerificationModal.module.css'; import { DayPicker } from 'react-day-picker'; import 'react-day-picker/style.css'; import { useAttendance } from '../../contexts/AttendanceContext'; +const DEFAULT_TIME = '18:00'; + const RoundDayPicker = () => { - const { selectedSessionId, handleAddRounds, closeAddRoundsModal } = + const { selectedSessionId, sessions, handleAddRounds, closeAddRoundsModal } = useAttendance(); const [selectedDate, setSelectedDate] = useState(); const [roundName, setRoundName] = useState(''); - const [startTime, setStartTime] = useState(''); - const [endTime, setEndTime] = useState(''); + const [startTime, setStartTime] = useState(DEFAULT_TIME); + const [endTime, setEndTime] = useState(DEFAULT_TIME); const [locationName, setLocationName] = useState(''); + const selectedSession = useMemo( + () => + (sessions || []).find( + (session) => String(session.sessionId) === String(selectedSessionId) + ), + [sessions, selectedSessionId] + ); + + const allowedMinutes = + Number(selectedSession?.session?.allowedMinutes || 0) > 0 + ? Number(selectedSession.session.allowedMinutes) + : 0; + const today = new Date(); + const calculateEndTime = (baseTime, minutesToAdd) => { + if (!baseTime || !minutesToAdd || Number(minutesToAdd) <= 0) return ''; + const [hours, minutes] = baseTime.split(':').map(Number); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return ''; + + const totalMinutes = (hours * 60 + minutes + Number(minutesToAdd)) % (24 * 60); + const endHour = String(Math.floor(totalMinutes / 60)).padStart(2, '0'); + const endMinute = String(totalMinutes % 60).padStart(2, '0'); + return `${endHour}:${endMinute}`; + }; + useEffect(() => { const handleKeyDown = (event) => { if (event.key === 'Escape') { @@ -29,6 +55,20 @@ const RoundDayPicker = () => { }; }, [closeAddRoundsModal]); + useEffect(() => { + if (!startTime || allowedMinutes <= 0) return; + setEndTime(calculateEndTime(startTime, allowedMinutes)); + }, [startTime, allowedMinutes]); + + const handleStartTimeChange = (e) => { + const nextStartTime = e.target.value; + setStartTime(nextStartTime); + + if (allowedMinutes > 0) { + setEndTime(calculateEndTime(nextStartTime, allowedMinutes)); + } + }; + const handleComplete = async () => { if (!selectedSessionId) { alert('세션을 먼저 선택해주세요.'); @@ -64,14 +104,14 @@ const RoundDayPicker = () => { await handleAddRounds(selectedSessionId, [newRound]); console.log('새로운 라운드 데이터:', newRound); closeAddRoundsModal(); - } catch (err) { + } catch { alert('라운드 생성에 실패했습니다.'); } }; return (
-
+

회차 추가하기

-
- setRoundName(e.target.value)} - /> - - setStartTime(e.target.value)} - /> - - setEndTime(e.target.value)} - /> - - setLocationName(e.target.value)} - /> +
+
+ + setRoundName(e.target.value)} + /> +
+ +
+
+ + +
+ +
+ + setEndTime(e.target.value)} + /> +
+
+ +

+ 기본 출석 가능 시간: {allowedMinutes > 0 ? `${allowedMinutes}분` : '설정되지 않음'} +

+ +
+ + setLocationName(e.target.value)} + /> +
-
-
+
+
diff --git a/frontend/src/components/attendancemanage/SessionManagementCard.jsx b/frontend/src/components/attendancemanage/SessionManagementCard.jsx index 94a60cba..f57e069b 100644 --- a/frontend/src/components/attendancemanage/SessionManagementCard.jsx +++ b/frontend/src/components/attendancemanage/SessionManagementCard.jsx @@ -2,6 +2,8 @@ import styles from './SessionManagementCard.module.css'; import calendarAddIcon from '../../assets/calendar-icon.svg'; import menuIcon from '../../assets/menu-icon.svg'; import xIcon from '../../assets/x-icon.svg'; +import fileIcon from '../../assets/file-icon.svg'; +import pencilIcon from '../../assets/pencil-icon.svg'; import { useEffect, useState, useRef } from 'react'; // useRef 추가 import { toast } from 'react-toastify'; @@ -114,10 +116,14 @@ const SessionManagementCard = ({ styles: commonStyles }) => { return (
-

세션 관리

+
+ 세션 아이콘 +

세션 관리

+
setTitle(e.target.value)} - placeholder="세션 제목" - style={{ padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' }} + placeholder="기존 세션 이름입니다" />
-
+
setDescription(e.target.value)} - placeholder="세션 설명" - style={{ padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' }} + placeholder="기존 세션 설명입니다" />
-
+
- setAllowedMinutes(e.target.value)} - placeholder="분(MM)" - style={{ padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' }} - /> +
+ setAllowedMinutes(e.target.value)} + placeholder="30분" + /> +
-
+
+
diff --git a/frontend/src/components/attendancemanage/SessionSettingCard.jsx b/frontend/src/components/attendancemanage/SessionSettingCard.jsx index 8f963bc7..d5563b95 100644 --- a/frontend/src/components/attendancemanage/SessionSettingCard.jsx +++ b/frontend/src/components/attendancemanage/SessionSettingCard.jsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import styles from './SessionSettingCard.module.css'; import { useAttendance } from '../../contexts/AttendanceContext'; +import fileIcon from '../../assets/file-icon.svg'; const SessionSettingCard = ({ styles: commonStyles }) => { const { handleAddSession } = useAttendance(); @@ -48,7 +49,10 @@ const SessionSettingCard = ({ styles: commonStyles }) => { return (
-

세션 설정

+
+ 세션 아이콘 +

세션 설정

+
@@ -97,7 +101,7 @@ const SessionSettingCard = ({ styles: commonStyles }) => {
-
+
diff --git a/frontend/src/components/attendancemanage/SessionSettingCard.module.css b/frontend/src/components/attendancemanage/SessionSettingCard.module.css index 457aa9e5..7dcb7c12 100644 --- a/frontend/src/components/attendancemanage/SessionSettingCard.module.css +++ b/frontend/src/components/attendancemanage/SessionSettingCard.module.css @@ -297,4 +297,109 @@ .createButton:active { transform: translateY(0); +} + +/* Session Setting Design Alignment */ +.SessionSettingCardContainer { + border: 1px solid #d7dbe3; + border-radius: 12px; + background: #f8f9fb; + padding: 20px 16px; + box-shadow: none; +} + +.SessionSettingCardContainer:hover { + box-shadow: none; +} + +.titleArea { + display: flex; + align-items: center; + gap: 8px; +} + +.titleIcon { + width: 18px; + height: 18px; + opacity: 0.85; +} + +.form { + display: grid; + grid-template-columns: 1.3fr 1.3fr 0.7fr auto; + gap: 14px; + align-items: end; +} + +.availableTimeInputGroup { + grid-template-columns: 1fr; + gap: 0; +} + +.availableTimeInputGroup > input { + text-align: left; + border: 1px solid #ced3dc; + border-radius: 10px; + height: 48px; + padding: 0 14px; + font-size: 15px; + background: #fff; +} + +.createButtonGroup { + justify-content: flex-end; + width: 100%; +} + +.createButton { + background: linear-gradient(99.86deg, #b3ebff -15.44%, #91c3ff 39.86%, #d3edff 94.81%); + width: 89px; + min-width: 89px; + height: 40px; + border-radius: 8px; + padding: 12px 26px; + gap: 10px; + display: flex; + justify-content: center; + align-items: center; + border: none; + cursor: pointer; + box-sizing: border-box; + font-family: Inter, Pretendard, sans-serif; + font-weight: 600; + font-size: 14px; + color: #111827; + transition: opacity 0.2s, transform 0.2s; +} + +.createButton:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +@media (max-width: 1100px) { + .form { + grid-template-columns: 1fr 1fr; + } + + .createButtonGroup { + justify-content: flex-start; + } +} + +@media (max-width: 768px) { + .SessionSettingCardContainer { + padding: 16px 14px; + } + + .form { + grid-template-columns: 1fr; + gap: 12px; + } + + .availableTimeInputGroup > input { + height: 44px; + font-size: 14px; + } + } \ No newline at end of file diff --git a/frontend/src/pages/AttendanceManage.module.css b/frontend/src/pages/AttendanceManage.module.css index ac208128..04078b1f 100644 --- a/frontend/src/pages/AttendanceManage.module.css +++ b/frontend/src/pages/AttendanceManage.module.css @@ -396,3 +396,151 @@ input:focus { font-size: 15px; } } + +/* Attendance Manage Design Alignment */ +.attendanceManageContainer { + max-width: 1500px; + padding: 20px 10px; + padding-bottom: 220px; +} + +.mainTitle { + font-size: 44px; + font-weight: 700; + line-height: 1.15; + margin-bottom: 20px; + letter-spacing: -0.02em; +} + +.cardLayout { + padding: 0; + gap: 18px; +} + +.leftColumn { + gap: 18px; +} + +.header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +} + +.header h1 { + font-size: 34px; + font-weight: 700; + line-height: 1.2; +} + +.buttonGroup { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; +} + +.iconButton { + height: 40px; + padding: 0 12px; + border: 1px solid #d4d7de; + border-radius: 8px; + background: #f7f8fa; + box-shadow: none; +} + +.iconButton:hover { + border-color: #c8cdd7; + background: #f1f3f7; + box-shadow: none; +} + +.iconButton img { + width: 18px; + height: 18px; +} + +.iconGroup { + gap: 5px; +} + +.text { + font-size: 14px; + font-weight: 500; + color: #6b7280; +} + +.inputGroup { + margin-bottom: 0; + gap: 8px; +} + +.inputGroup .label { + font-size: 16px; + font-weight: 600; + color: #2f3640; +} + +.inputGroup input { + max-width: 100%; + height: 48px; + border: 1px solid #ced3dc; + border-radius: 10px; + padding: 0 14px; + font-size: 15px; + box-shadow: none; +} + +.inputGroup input::placeholder, +.modalInputGroup input::placeholder { + font-size: 15px; + color: #9ca3af; +} + +.inputGroup input:focus, +.modalInputGroup input:focus { + border-color: #9dc7ff; + box-shadow: 0 0 0 2px rgba(157, 199, 255, 0.25); +} + +@media (max-width: 1024px) { + .mainTitle { + font-size: 36px; + } + + .header { + flex-direction: column; + align-items: flex-start; + } + + .buttonGroup { + flex-wrap: wrap; + } +} + +@media (max-width: 768px) { + .attendanceManageContainer { + padding: 12px; + } + + .mainTitle { + font-size: 30px; + margin-bottom: 14px; + } + + .header h1 { + font-size: 28px; + } + + .inputGroup .label { + font-size: 14px; + } + + .inputGroup input { + height: 44px; + font-size: 14px; + } +} From ca923d62df0062cc47185f9c7471d9bca374ddc6 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Mon, 16 Mar 2026 21:12:11 +0900 Subject: [PATCH 05/10] =?UTF-8?q?[FE]=20=ED=95=98=EC=9C=84=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Board/CategoryTabs.jsx | 34 ++++++++-- .../components/Board/CategoryTabs.module.css | 43 +++++++++++++ frontend/src/pages/Board.jsx | 64 +++++++++++++++++++ frontend/src/utils/boardApi.js | 14 ++++ 4 files changed, 150 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Board/CategoryTabs.jsx b/frontend/src/components/Board/CategoryTabs.jsx index f27821e7..981f658f 100644 --- a/frontend/src/components/Board/CategoryTabs.jsx +++ b/frontend/src/components/Board/CategoryTabs.jsx @@ -7,18 +7,42 @@ const CategoryTabs = ({ tabs, onCreateSubBoard, canCreateSubBoard = false, + canDeleteSubBoard = false, + deletingTabId = '', + onDeleteSubBoard, }) => { return (
{tabs?.map((tab) => ( - + + + {canDeleteSubBoard && tab.id !== 'all' && ( + + )} +
))}
diff --git a/frontend/src/components/Board/CategoryTabs.module.css b/frontend/src/components/Board/CategoryTabs.module.css index fa303541..cd6b32eb 100644 --- a/frontend/src/components/Board/CategoryTabs.module.css +++ b/frontend/src/components/Board/CategoryTabs.module.css @@ -9,6 +9,11 @@ .tabsLeft { display: flex; gap: 10px; + flex-wrap: wrap; +} + +.tabItem { + position: relative; } .tab { @@ -49,6 +54,44 @@ letter-spacing: 0; } +.deleteTabButton { + position: absolute; + top: -7px; + right: -7px; + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: rgba(23, 23, 23, 0.78); + color: #ffffff; + font-size: 11px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: opacity 0.16s ease; + z-index: 1; +} + +.tabItem:hover .deleteTabButton, +.tabItem:focus-within .deleteTabButton { + opacity: 1; + pointer-events: auto; +} + +.deleteTabButton:hover:not(:disabled) { + background: rgba(194, 31, 31, 0.92); +} + +.deleteTabButton:disabled { + cursor: not-allowed; + opacity: 0.75; + pointer-events: auto; +} + /* 하위 게시판 추가 버튼 */ .subBoardButton { height: 43px; diff --git a/frontend/src/pages/Board.jsx b/frontend/src/pages/Board.jsx index ebed9068..59547d16 100644 --- a/frontend/src/pages/Board.jsx +++ b/frontend/src/pages/Board.jsx @@ -17,6 +17,7 @@ import { api } from '../utils/axios'; const ALL_TAB_ID = 'all'; const SUB_BOARD_ADMIN_ROLES = ['SYSTEM_ADMIN', 'PRESIDENT', 'VICE_PRESIDENT']; +const SUB_BOARD_DELETE_ROLES = ['SYSTEM_ADMIN', 'PRESIDENT']; const getPostId = (post) => post?.postId || post?.id; @@ -96,7 +97,9 @@ const Board = () => { const [isCreatingSubBoard, setIsCreatingSubBoard] = useState(false); const [writeBoardId, setWriteBoardId] = useState(''); const [canCreateSubBoard, setCanCreateSubBoard] = useState(false); + const [canDeleteSubBoard, setCanDeleteSubBoard] = useState(false); const [isSavingPost, setIsSavingPost] = useState(false); + const [deletingSubBoardId, setDeletingSubBoardId] = useState(''); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 4; @@ -168,8 +171,10 @@ const Board = () => { const { data } = await api.get('/api/user/details'); const normalizedRole = String(data?.role || '').trim().toUpperCase(); setCanCreateSubBoard(SUB_BOARD_ADMIN_ROLES.includes(normalizedRole)); + setCanDeleteSubBoard(SUB_BOARD_DELETE_ROLES.includes(normalizedRole)); } catch { setCanCreateSubBoard(false); + setCanDeleteSubBoard(false); } }; @@ -403,6 +408,62 @@ const Board = () => { } }; + const handleDeleteSubBoard = async (boardId, boardName) => { + if (!canDeleteSubBoard) { + alert('하위 게시판 삭제 권한이 없습니다.'); + return; + } + + if (!boardId || boardId === ALL_TAB_ID) { + return; + } + + if (deletingSubBoardId) { + return; + } + + const confirmed = window.confirm( + `'${boardName || '해당'}' 하위 게시판을 삭제하시겠습니까?\n관련 첨부파일 및 댓글도 함께 삭제됩니다.` + ); + + if (!confirmed) { + return; + } + + try { + setDeletingSubBoardId(boardId); + await boardApi.deleteBoard(boardId); + + const subBoards = await boardApi.getSubBoards(currentBoardId); + const tabs = [ + { id: ALL_TAB_ID, name: '전체 게시판' }, + ...(Array.isArray(subBoards) + ? subBoards.map((board) => ({ id: board.boardId, name: board.boardName })) + : []), + ]; + + setSubBoardTabs(tabs); + setPostCacheByBoardId((prev) => { + if (!prev[boardId]) { + return prev; + } + + const next = { ...prev }; + delete next[boardId]; + return next; + }); + + setActiveSubBoard((prev) => (prev === boardId ? ALL_TAB_ID : prev)); + setCurrentPage(1); + alert('하위 게시판이 삭제되었습니다.'); + } catch (error) { + console.error('하위 게시판 삭제 실패:', error); + alert('하위 게시판 삭제에 실패했습니다.'); + } finally { + setDeletingSubBoardId(''); + } + }; + const handleFileChange = (e) => { const files = Array.from(e.target.files); setSelectedFiles((prevFiles) => [...prevFiles, ...files]); @@ -618,6 +679,9 @@ const Board = () => { tabs={subBoardTabs} onCreateSubBoard={handleOpenSubBoardModal} canCreateSubBoard={canCreateSubBoard} + canDeleteSubBoard={canDeleteSubBoard} + deletingTabId={deletingSubBoardId} + onDeleteSubBoard={handleDeleteSubBoard} /> { return createSubBoard(boardName, parentBoardId); }; +/* + * 하위 게시판 삭제 (회장/시스템 관리자 권한) + * DELETE /api/admin/board/{boardId} + * @param {string} boardId - 삭제할 게시판 ID + */ +export const deleteBoard = async (boardId) => { + if (!boardId) { + throw new Error('boardId is required'); + } + + const response = await api.delete(`/api/admin/board/${boardId}`); + return response.data; +}; + // ==================== 게시글 API ==================== /* From 6bf8da6b7258b366cfab5f0651176f4ea46c0686 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Mon, 16 Mar 2026 21:21:35 +0900 Subject: [PATCH 06/10] =?UTF-8?q?[FE]=20=EC=84=B8=EC=85=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A9=A4=EB=B2=84=20=EC=82=AD=EC=A0=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=EB=8A=94=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EC=84=B1=EA=B3=B5=ED=96=88=EC=A7=80?= =?UTF-8?q?=EB=A7=8C=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20=EB=9C=A8=EB=8D=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendancemanage/AttendanceManagementCard.jsx | 11 ++++++++++- .../attendancemanage/SessionSettingCard.jsx | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx index 22c556dd..d452982b 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx @@ -245,8 +245,17 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { ); // 3. 유저 삭제(퇴출) 버튼 핸들러 - const onDeleteUsersClick = () => + const onDeleteUsersClick = () => { + const selectedUsers = getSelectedUsers(); + const hasOwner = selectedUsers.some((user) => isOwnerRole(user.role)); + + if (hasOwner) { + alert('세션 생성자를 삭제할 수 없습니다'); + return; + } + confirmAction('유저를 삭제하시겠습니까?', handleDeleteUsers); + }; const toggleUserSelection = (userId) => { const newSelection = new Set(selectedUserIds); diff --git a/frontend/src/components/attendancemanage/SessionSettingCard.jsx b/frontend/src/components/attendancemanage/SessionSettingCard.jsx index d5563b95..b2728a88 100644 --- a/frontend/src/components/attendancemanage/SessionSettingCard.jsx +++ b/frontend/src/components/attendancemanage/SessionSettingCard.jsx @@ -38,7 +38,6 @@ const SessionSettingCard = ({ styles: commonStyles }) => { setSessionTitle(''); setDescription(''); setAllowedMinutes(''); - setStatus('OPEN'); alert('세션이 생성되었습니다.'); } catch (err) { From fe502c2b1ed5507b7301c76177343c1f78bf7a40 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Mon, 16 Mar 2026 21:35:01 +0900 Subject: [PATCH 07/10] =?UTF-8?q?[FE]=20=ED=8F=B0=ED=8A=B8=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=9D=BC=EC=A0=95=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/AttendanceManage.module.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/AttendanceManage.module.css b/frontend/src/pages/AttendanceManage.module.css index 04078b1f..15a84160 100644 --- a/frontend/src/pages/AttendanceManage.module.css +++ b/frontend/src/pages/AttendanceManage.module.css @@ -405,8 +405,8 @@ input:focus { } .mainTitle { - font-size: 44px; - font-weight: 700; + font-size: 24px; + font-weight: 600; line-height: 1.15; margin-bottom: 20px; letter-spacing: -0.02em; @@ -431,8 +431,8 @@ input:focus { } .header h1 { - font-size: 34px; - font-weight: 700; + font-size: 20px; + font-weight: 600; line-height: 1.2; } @@ -508,7 +508,7 @@ input:focus { @media (max-width: 1024px) { .mainTitle { - font-size: 36px; + font-size: 24px; } .header { @@ -527,12 +527,12 @@ input:focus { } .mainTitle { - font-size: 30px; + font-size: 24px; margin-bottom: 14px; } .header h1 { - font-size: 28px; + font-size: 20px; } .inputGroup .label { From 393d6b67a3e852b5044172326bf8ab396b3ee85a Mon Sep 17 00:00:00 2001 From: sangkyu Date: Mon, 16 Mar 2026 23:01:02 +0900 Subject: [PATCH 08/10] =?UTF-8?q?[FE]=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/utils/axios.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js index 921ed684..497b6cfd 100644 --- a/frontend/src/utils/axios.js +++ b/frontend/src/utils/axios.js @@ -1,7 +1,7 @@ import axios from 'axios'; const BASE_URL = import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL || ''; export const api = axios.create({ - baseURL: '', + baseURL: BASE_URL, withCredentials: true, }); From 0c84ad17c6ebcf8763bd7d0022b6516595f28186 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Mon, 16 Mar 2026 23:20:02 +0900 Subject: [PATCH 09/10] =?UTF-8?q?[FE]=20PR=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Board/CategoryTabs.module.css | 11 ++- .../attendancemanage/AddUsersModal.jsx | 28 +++++-- .../AttendanceManagementCard.jsx | 81 ++++++++++++------- .../AttendanceManagementCard.module.css | 1 + .../ConfirmationToast.module.css | 4 + .../attendancemanage/RoundDayPicker.jsx | 63 ++++++++++++--- frontend/src/contexts/AttendanceContext.jsx | 81 +++++++++++++------ 7 files changed, 197 insertions(+), 72 deletions(-) diff --git a/frontend/src/components/Board/CategoryTabs.module.css b/frontend/src/components/Board/CategoryTabs.module.css index cd6b32eb..ab82e634 100644 --- a/frontend/src/components/Board/CategoryTabs.module.css +++ b/frontend/src/components/Board/CategoryTabs.module.css @@ -77,11 +77,20 @@ } .tabItem:hover .deleteTabButton, -.tabItem:focus-within .deleteTabButton { +.tabItem:focus-within .deleteTabButton, +.tabItem:active .deleteTabButton, +.tabItem:focus .deleteTabButton { opacity: 1; pointer-events: auto; } +@media (hover: none) and (pointer: coarse) { + .deleteTabButton { + opacity: 1; + pointer-events: auto; + } +} + .deleteTabButton:hover:not(:disabled) { background: rgba(194, 31, 31, 0.92); } diff --git a/frontend/src/components/attendancemanage/AddUsersModal.jsx b/frontend/src/components/attendancemanage/AddUsersModal.jsx index 0dda2811..9c122fac 100644 --- a/frontend/src/components/attendancemanage/AddUsersModal.jsx +++ b/frontend/src/components/attendancemanage/AddUsersModal.jsx @@ -42,6 +42,11 @@ const AddUsersModal = () => { }; const handleComplete = async () => { + if (!selectedSessionId) { + alert('선택된 세션이 없습니다.'); + return; + } + if (selectedUserIds.size === 0) { alert('추가할 인원을 1명 이상 선택해주세요.'); return; @@ -49,14 +54,25 @@ const AddUsersModal = () => { const idsArray = Array.from(selectedUserIds); - try { - for (const userId of idsArray) { - await handleAddUsers(selectedSessionId, userId); - } + const results = await Promise.allSettled( + idsArray.map((userId) => handleAddUsers(selectedSessionId, userId)) + ); + + const failedCount = results.filter( + (result) => result.status === 'rejected' + ).length; + + if (failedCount === 0) { closeAddUsersModal(); - } catch (error) { - alert('유저 추가 중 오류가 발생했습니다.'); + return; } + + const successCount = idsArray.length - failedCount; + console.error( + `유저 추가 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, + results.filter((result) => result.status === 'rejected') + ); + alert(`유저 추가 중 ${failedCount}명이 실패했습니다.`); }; return ( diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx index d452982b..0df06435 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx @@ -42,6 +42,12 @@ const ATTENDANCE_MENU_ORDER = [ 'PENDING', ]; +const EMPTY_ATTENDANCE_DATA = { + sessionTitle: '', + rounds: [], + userRows: [], +}; + const AttendanceStatusDropdown = ({ value, statusClass, @@ -109,6 +115,7 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { const [activeToastId, setActiveToastId] = useState(null); const [openDropdownKey, setOpenDropdownKey] = useState(null); const cardRef = useRef(null); + const fetchRequestIdRef = useRef(0); const getSelectedUsers = () => attendanceData.userRows.filter((user) => selectedUserIds.has(user.userId)); @@ -123,22 +130,35 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { }; useEffect(() => { + const requestId = ++fetchRequestIdRef.current; + const isStale = () => requestId !== fetchRequestIdRef.current; + + setAttendanceData(EMPTY_ATTENDANCE_DATA); + setSelectedUserIds(new Set()); + const fetchAttendanceSheet = async () => { if (!selectedSessionId) { - setAttendanceData({ sessionTitle: '', rounds: [], userRows: [] }); - setSelectedUserIds(new Set()); return; } try { const data = await getUsers(selectedSessionId); - setAttendanceData(data); + if (isStale()) return; + setAttendanceData(data || EMPTY_ATTENDANCE_DATA); } catch (error) { + if (isStale()) return; console.error('출석부 조회 실패:', error); - setAttendanceData({ sessionTitle: '', rounds: [], userRows: [] }); + setAttendanceData(EMPTY_ATTENDANCE_DATA); } }; + fetchAttendanceSheet(); + + return () => { + if (fetchRequestIdRef.current === requestId) { + fetchRequestIdRef.current += 1; + } + }; }, [selectedSessionId, roundAttendanceVersion, roundsVersion]); useEffect(() => { @@ -156,18 +176,18 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { if (selectedUserIds.size === 0) return alert('대상을 선택해주세요.'); if (activeToastId) toast.dismiss(activeToastId); - const handleConfirm = async () => { - await onConfirm(selectedSessionId, Array.from(selectedUserIds)); - setSelectedUserIds(new Set()); // 성공 후 선택 초기화 - toast.dismiss(); - }; - const toastId = toast( - toast.dismiss()} - message={`${selectedUserIds.size}명의 유저에 대해 ${message}`} - />, + ({ closeToast }) => ( + { + await onConfirm(selectedSessionId, Array.from(selectedUserIds)); + setSelectedUserIds(new Set()); // 성공 후 선택 초기화 + closeToast?.(); + }} + onCancel={() => closeToast?.()} + message={`${selectedUserIds.size}명의 유저를 ${message}`} + /> + ), { autoClose: false, closeOnClick: false, @@ -194,21 +214,22 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { ? `${firstName} 외 ${restCount}명의 권한을 변경하시겠습니까?` : `${firstName}님의 권한을 변경하시겠습니까?`; - const handleConfirm = async () => { - await onConfirm(selectedSessionId, Array.from(selectedUserIds)); - setSelectedUserIds(new Set()); - toast.dismiss(); - }; - const toastId = toast( - , + ({ closeToast }) => ( + { + await onConfirm(selectedSessionId, Array.from(selectedUserIds)); + setSelectedUserIds(new Set()); + closeToast?.(); + }} + onCancel={() => closeToast?.()} + title={title} + description={description} + confirmLabel="권한 변경하기" + cancelLabel="취소" + variant="roleChange" + /> + ), { autoClose: false, closeOnClick: false, @@ -254,7 +275,7 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { return; } - confirmAction('유저를 삭제하시겠습니까?', handleDeleteUsers); + confirmAction('삭제하시겠습니까?', handleDeleteUsers); }; const toggleUserSelection = (userId) => { diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css b/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css index 4d8e56a8..89be92b9 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css @@ -163,6 +163,7 @@ --status-color: #2f80ed; --status-bg: #eaf4ff; --status-bg-hover: #deeeff; + position: relative; width: 100%; max-width: 92px; diff --git a/frontend/src/components/attendancemanage/ConfirmationToast.module.css b/frontend/src/components/attendancemanage/ConfirmationToast.module.css index 685ed348..b1afe947 100644 --- a/frontend/src/components/attendancemanage/ConfirmationToast.module.css +++ b/frontend/src/components/attendancemanage/ConfirmationToast.module.css @@ -102,6 +102,10 @@ padding: 9px 12px; font-size: 13px; } + + .roleConfirmButton { + min-width: 110px; + } } .roleConfirmButton { diff --git a/frontend/src/components/attendancemanage/RoundDayPicker.jsx b/frontend/src/components/attendancemanage/RoundDayPicker.jsx index dee7f1e1..f15aee19 100644 --- a/frontend/src/components/attendancemanage/RoundDayPicker.jsx +++ b/frontend/src/components/attendancemanage/RoundDayPicker.jsx @@ -6,6 +6,19 @@ import { useAttendance } from '../../contexts/AttendanceContext'; const DEFAULT_TIME = '18:00'; +const formatDatePart = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const formatTimePart = (date) => { + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + return `${hour}:${minute}`; +}; + const RoundDayPicker = () => { const { selectedSessionId, sessions, handleAddRounds, closeAddRoundsModal } = useAttendance(); @@ -31,15 +44,35 @@ const RoundDayPicker = () => { const today = new Date(); - const calculateEndTime = (baseTime, minutesToAdd) => { - if (!baseTime || !minutesToAdd || Number(minutesToAdd) <= 0) return ''; + const calculateEndTime = (baseTime, minutesToAdd, baseDate = new Date()) => { + if (!baseTime || !minutesToAdd || Number(minutesToAdd) <= 0) { + return { + time: '', + nextDay: false, + iso: '', + }; + } + const [hours, minutes] = baseTime.split(':').map(Number); - if (Number.isNaN(hours) || Number.isNaN(minutes)) return ''; + if (Number.isNaN(hours) || Number.isNaN(minutes)) { + return { + time: '', + nextDay: false, + iso: '', + }; + } - const totalMinutes = (hours * 60 + minutes + Number(minutesToAdd)) % (24 * 60); - const endHour = String(Math.floor(totalMinutes / 60)).padStart(2, '0'); - const endMinute = String(totalMinutes % 60).padStart(2, '0'); - return `${endHour}:${endMinute}`; + const startDateTime = new Date(baseDate); + startDateTime.setHours(hours, minutes, 0, 0); + + const endDateTime = new Date(startDateTime); + endDateTime.setMinutes(endDateTime.getMinutes() + Number(minutesToAdd)); + + return { + time: formatTimePart(endDateTime), + nextDay: endDateTime.getDate() !== startDateTime.getDate(), + iso: `${formatDatePart(endDateTime)}T${formatTimePart(endDateTime)}:00`, + }; }; useEffect(() => { @@ -57,7 +90,8 @@ const RoundDayPicker = () => { useEffect(() => { if (!startTime || allowedMinutes <= 0) return; - setEndTime(calculateEndTime(startTime, allowedMinutes)); + const calculated = calculateEndTime(startTime, allowedMinutes); + setEndTime(calculated.time); }, [startTime, allowedMinutes]); const handleStartTimeChange = (e) => { @@ -65,7 +99,8 @@ const RoundDayPicker = () => { setStartTime(nextStartTime); if (allowedMinutes > 0) { - setEndTime(calculateEndTime(nextStartTime, allowedMinutes)); + const calculated = calculateEndTime(nextStartTime, allowedMinutes); + setEndTime(calculated.time); } }; @@ -90,7 +125,15 @@ const RoundDayPicker = () => { const roundDate = selectedDate.toLocaleDateString('sv-SE'); const startAt = `${roundDate}T${startTime}:00`; - const closeAt = `${roundDate}T${endTime}:00`; + + const startDateTime = new Date(`${roundDate}T${startTime}:00`); + const endDateTime = new Date(`${roundDate}T${endTime}:00`); + + if (endDateTime <= startDateTime) { + endDateTime.setDate(endDateTime.getDate() + 1); + } + + const closeAt = `${formatDatePart(endDateTime)}T${formatTimePart(endDateTime)}:00`; const newRound = { roundDate, diff --git a/frontend/src/contexts/AttendanceContext.jsx b/frontend/src/contexts/AttendanceContext.jsx index c7471980..5170fd23 100644 --- a/frontend/src/contexts/AttendanceContext.jsx +++ b/frontend/src/contexts/AttendanceContext.jsx @@ -226,46 +226,77 @@ export const AttendanceProvider = ({ children }) => { setRoundAttendanceVersion((prev) => prev + 1); } catch (error) { console.error('유저 추가에 실패했습니다. ', error); + throw error; } }; const handleDeleteUsers = async (sessionId, userIds) => { - try { - const promises = userIds.map((userId) => deleteUser(sessionId, userId)); - await Promise.all(promises); - setRoundAttendanceVersion((v) => v + 1); - } catch (error) { - console.error('유저 삭제 실패', error); - alert('일부 유저 삭제에 실패했습니다.'); + const results = await Promise.allSettled( + userIds.map((userId) => deleteUser(sessionId, userId)) + ); + + setRoundAttendanceVersion((v) => v + 1); + + const failedCount = results.filter( + (result) => result.status === 'rejected' + ).length; + + if (failedCount > 0) { + const successCount = userIds.length - failedCount; + console.error( + `유저 삭제 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, + results.filter((result) => result.status === 'rejected') + ); + alert(`유저 삭제 중 ${failedCount}명이 실패했습니다.`); } }; const handleAddManager = async (sessionId, userIds) => { - try { - const promises = userIds.map((userId) => addManager(sessionId, userId)); - await Promise.all(promises); + const results = await Promise.allSettled( + userIds.map((userId) => addManager(sessionId, userId)) + ); - setRoundAttendanceVersion((v) => v + 1); - alert('선택한 유저가 매니저로 격상되었습니다.'); - } catch (error) { - console.error('매니저 추가 실패:', error); - alert('매니저 권한 부여에 실패했습니다.'); + setRoundAttendanceVersion((v) => v + 1); + + const failedCount = results.filter( + (result) => result.status === 'rejected' + ).length; + + if (failedCount > 0) { + const successCount = userIds.length - failedCount; + console.error( + `매니저 추가 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, + results.filter((result) => result.status === 'rejected') + ); + alert(`매니저 권한 부여 중 ${failedCount}명이 실패했습니다.`); + return; } + + alert('선택한 유저가 매니저로 격상되었습니다.'); }; const handleRemoveManager = async (sessionId, userIds) => { - try { - const promises = userIds.map((userId) => - deleteManager(sessionId, userId) - ); - await Promise.all(promises); + const results = await Promise.allSettled( + userIds.map((userId) => deleteManager(sessionId, userId)) + ); - setRoundAttendanceVersion((v) => v + 1); - alert('선택한 유저가 일반 참가자로 변경되었습니다.'); - } catch (error) { - console.error('매니저 제거 실패:', error); - alert('권한 제거에 실패했습니다. (OWNER 여부 확인 필요)'); + setRoundAttendanceVersion((v) => v + 1); + + const failedCount = results.filter( + (result) => result.status === 'rejected' + ).length; + + if (failedCount > 0) { + const successCount = userIds.length - failedCount; + console.error( + `매니저 제거 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, + results.filter((result) => result.status === 'rejected') + ); + alert(`권한 제거 중 ${failedCount}명이 실패했습니다. (OWNER 여부 확인 필요)`); + return; } + + alert('선택한 유저가 일반 참가자로 변경되었습니다.'); }; // 공유할 값들을 객체로 묶기 From f6bcca97afc0ac305112ebfa489a27bb00795301 Mon Sep 17 00:00:00 2001 From: sangkyu Date: Tue, 17 Mar 2026 22:28:39 +0900 Subject: [PATCH 10/10] =?UTF-8?q?[FE]=20PR=20=EC=88=98=EC=A0=95=EC=82=AC?= =?UTF-8?q?=ED=95=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Board/CategoryTabs.jsx | 2 +- .../attendancemanage/AddUsersModal.jsx | 27 ++++++-- .../AttendanceManagementCard.jsx | 32 +++++++++- .../AttendanceManagementCard.module.css | 2 +- frontend/src/contexts/AttendanceContext.jsx | 62 +++++++++++++------ 5 files changed, 96 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/Board/CategoryTabs.jsx b/frontend/src/components/Board/CategoryTabs.jsx index 981f658f..5c1ec28d 100644 --- a/frontend/src/components/Board/CategoryTabs.jsx +++ b/frontend/src/components/Board/CategoryTabs.jsx @@ -35,7 +35,7 @@ const CategoryTabs = ({ event.stopPropagation(); onDeleteSubBoard?.(tab.id, tab.name); }} - disabled={Boolean(deletingTabId)} + disabled={deletingTabId === tab.id} aria-label={`${tab.name} 하위 게시판 삭제`} title="하위 게시판 삭제" > diff --git a/frontend/src/components/attendancemanage/AddUsersModal.jsx b/frontend/src/components/attendancemanage/AddUsersModal.jsx index 9c122fac..3e9608f4 100644 --- a/frontend/src/components/attendancemanage/AddUsersModal.jsx +++ b/frontend/src/components/attendancemanage/AddUsersModal.jsx @@ -58,21 +58,36 @@ const AddUsersModal = () => { idsArray.map((userId) => handleAddUsers(selectedSessionId, userId)) ); - const failedCount = results.filter( - (result) => result.status === 'rejected' - ).length; + // Extract failed IDs from results + const failedIds = results + .map((result, index) => ({ result, index })) + .filter((item) => item.result.status === 'rejected') + .map((item) => idsArray[item.index]); + + const failedCount = failedIds.length; + const successCount = idsArray.length - failedCount; if (failedCount === 0) { closeAddUsersModal(); return; } - const successCount = idsArray.length - failedCount; + // Remove successfully-added users from the list + const successfulIds = new Set(idsArray.filter((id) => !failedIds.includes(id))); + setUsers((prevUsers) => + prevUsers.filter((user) => !successfulIds.has(user.userId)) + ); + + // Keep only failed IDs selected for retry + setSelectedUserIds(new Set(failedIds)); + console.error( `유저 추가 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, - results.filter((result) => result.status === 'rejected') + results + .map((result, index) => ({ result, userId: idsArray[index] })) + .filter((item) => item.result.status === 'rejected') ); - alert(`유저 추가 중 ${failedCount}명이 실패했습니다.`); + alert(`유저 추가 중 ${failedCount}명이 실패했습니다. 실패한 유저를 다시 추가할 수 있습니다.`); }; return ( diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx index 0df06435..e1b98316 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.jsx @@ -133,6 +133,12 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { const requestId = ++fetchRequestIdRef.current; const isStale = () => requestId !== fetchRequestIdRef.current; + // Dismiss any active confirmation toast when session changes + if (activeToastId) { + toast.dismiss(activeToastId); + setActiveToastId(null); + } + setAttendanceData(EMPTY_ATTENDANCE_DATA); setSelectedUserIds(new Set()); @@ -180,8 +186,18 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { ({ closeToast }) => ( { - await onConfirm(selectedSessionId, Array.from(selectedUserIds)); - setSelectedUserIds(new Set()); // 성공 후 선택 초기화 + const result = await onConfirm(selectedSessionId, Array.from(selectedUserIds)); + + // Handle partial failures + if (result?.failedIds && result.failedIds.length > 0) { + // Keep only failed IDs selected for retry + setSelectedUserIds(new Set(result.failedIds)); + // Don't close the toast, keep it open for retry + return; + } + + // All succeeded, clear selection and close + setSelectedUserIds(new Set()); closeToast?.(); }} onCancel={() => closeToast?.()} @@ -218,7 +234,17 @@ const AttendanceManagementCard = ({ styles: commonStyles }) => { ({ closeToast }) => ( { - await onConfirm(selectedSessionId, Array.from(selectedUserIds)); + const result = await onConfirm(selectedSessionId, Array.from(selectedUserIds)); + + // Handle partial failures + if (result?.failedIds && result.failedIds.length > 0) { + // Keep only failed IDs selected for retry + setSelectedUserIds(new Set(result.failedIds)); + // Don't close the toast, keep it open for retry + return; + } + + // All succeeded, clear selection and close setSelectedUserIds(new Set()); closeToast?.(); }} diff --git a/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css b/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css index 89be92b9..f98f9300 100644 --- a/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css +++ b/frontend/src/components/attendancemanage/AttendanceManagementCard.module.css @@ -584,7 +584,7 @@ .tableGroup { margin-top: 4px; max-height: none; - overflow: visible; + overflow: auto; } .table { diff --git a/frontend/src/contexts/AttendanceContext.jsx b/frontend/src/contexts/AttendanceContext.jsx index 5170fd23..e53332f9 100644 --- a/frontend/src/contexts/AttendanceContext.jsx +++ b/frontend/src/contexts/AttendanceContext.jsx @@ -237,18 +237,26 @@ export const AttendanceProvider = ({ children }) => { setRoundAttendanceVersion((v) => v + 1); - const failedCount = results.filter( - (result) => result.status === 'rejected' - ).length; + // Extract failed IDs + const failedIds = results + .map((result, index) => ({ result, index })) + .filter((item) => item.result.status === 'rejected') + .map((item) => userIds[item.index]); + + const failedCount = failedIds.length; if (failedCount > 0) { const successCount = userIds.length - failedCount; console.error( `유저 삭제 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, - results.filter((result) => result.status === 'rejected') + results + .map((result, index) => ({ result, userId: userIds[index] })) + .filter((item) => item.result.status === 'rejected') ); alert(`유저 삭제 중 ${failedCount}명이 실패했습니다.`); } + + return { failedIds, successCount: userIds.length - failedCount }; }; const handleAddManager = async (sessionId, userIds) => { @@ -258,21 +266,30 @@ export const AttendanceProvider = ({ children }) => { setRoundAttendanceVersion((v) => v + 1); - const failedCount = results.filter( - (result) => result.status === 'rejected' - ).length; + // Extract failed IDs + const failedIds = results + .map((result, index) => ({ result, index })) + .filter((item) => item.result.status === 'rejected') + .map((item) => userIds[item.index]); + + const failedCount = failedIds.length; + const successCount = userIds.length - failedCount; if (failedCount > 0) { - const successCount = userIds.length - failedCount; console.error( `매니저 추가 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, - results.filter((result) => result.status === 'rejected') + results + .map((result, index) => ({ result, userId: userIds[index] })) + .filter((item) => item.result.status === 'rejected') ); alert(`매니저 권한 부여 중 ${failedCount}명이 실패했습니다.`); - return; } - alert('선택한 유저가 매니저로 격상되었습니다.'); + if (failedCount === 0) { + alert('선택한 유저가 매니저로 격상되었습니다.'); + } + + return { failedIds, successCount }; }; const handleRemoveManager = async (sessionId, userIds) => { @@ -282,21 +299,30 @@ export const AttendanceProvider = ({ children }) => { setRoundAttendanceVersion((v) => v + 1); - const failedCount = results.filter( - (result) => result.status === 'rejected' - ).length; + // Extract failed IDs + const failedIds = results + .map((result, index) => ({ result, index })) + .filter((item) => item.result.status === 'rejected') + .map((item) => userIds[item.index]); + + const failedCount = failedIds.length; + const successCount = userIds.length - failedCount; if (failedCount > 0) { - const successCount = userIds.length - failedCount; console.error( `매니저 제거 부분 실패: 성공 ${successCount}명, 실패 ${failedCount}명`, - results.filter((result) => result.status === 'rejected') + results + .map((result, index) => ({ result, userId: userIds[index] })) + .filter((item) => item.result.status === 'rejected') ); alert(`권한 제거 중 ${failedCount}명이 실패했습니다. (OWNER 여부 확인 필요)`); - return; } - alert('선택한 유저가 일반 참가자로 변경되었습니다.'); + if (failedCount === 0) { + alert('선택한 유저가 일반 참가자로 변경되었습니다.'); + } + + return { failedIds, successCount }; }; // 공유할 값들을 객체로 묶기