diff --git a/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx
index 725b998a..4511fa74 100644
--- a/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx
+++ b/frontend/src/components/AdminMemberApproval/AdminMemberApproval.jsx
@@ -36,16 +36,8 @@ const AdminMemberApprovalList = () => {
try {
const data = await getAdminMemberManageData({ keyword });
setPendingMembers(data.pendingMembers || []);
- setMonthlyApprovedCount((prev) =>
- data.monthlyApprovedCount != null
- ? Math.max(prev, data.monthlyApprovedCount)
- : prev
- );
- setMonthlyRejectedCount((prev) =>
- data.monthlyRejectedCount != null
- ? Math.max(prev, data.monthlyRejectedCount)
- : prev
- );
+ setMonthlyApprovedCount(data.monthlyApprovedCount || 0);
+ setMonthlyRejectedCount(data.monthlyRejectedCount || 0);
} catch (error) {
window.alert(error?.message || '가입 승인 대기 회원을 불러오지 못했습니다.');
setPendingMembers([]);
@@ -105,43 +97,51 @@ const AdminMemberApprovalList = () => {
};
// 승인 확정 처리 (단건/일괄)
- const confirmApprove = async () => {
- try {
- if (actionTarget === 'single' && targetMember) {
- await approvePendingMember({ userId: targetMember.id });
- setMonthlyApprovedCount((prev) => prev + 1);
- } else {
- await approvePendingMembersBulk({ userIds: selectedIds });
- setMonthlyApprovedCount((prev) => prev + selectedIds.length);
+ 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 || '가입 승인 처리에 실패했습니다.');
}
+ };
- setApproveDialogOpen(false);
- setSelectedIds([]);
- setTargetMember(null);
- await loadPendingMembers({ keyword: searchQuery.trim() || undefined });
- } catch (error) {
- window.alert(error?.message || '가입 승인 처리에 실패했습니다.');
- }
+ approveAction();
};
// 거절 확정 처리 (단건/일괄)
- const confirmReject = async () => {
- try {
- if (actionTarget === 'single' && targetMember) {
- await rejectPendingMember({ userId: targetMember.id });
- setMonthlyRejectedCount((prev) => prev + 1);
- } else {
- await rejectPendingMembersBulk({ userIds: selectedIds });
- setMonthlyRejectedCount((prev) => prev + selectedIds.length);
+ 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 || '가입 거절 처리에 실패했습니다.');
}
+ };
- setRejectDialogOpen(false);
- setSelectedIds([]);
- setTargetMember(null);
- await loadPendingMembers({ keyword: searchQuery.trim() || undefined });
- } catch (error) {
- window.alert(error?.message || '가입 거절 처리에 실패했습니다.');
- }
+ rejectAction();
};
return (
@@ -245,7 +245,7 @@ const AdminMemberApprovalList = () => {
- {member.name?.[0] || '?'}
+ {member.name[0]}
{member.name}
{member.email}
diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx
index a55a73d0..381af4cc 100644
--- a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx
+++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { Search, Star } from 'lucide-react';
import styles from './AdminMemberManage.module.css';
import {
@@ -55,55 +55,39 @@ const AdminMemberManage = () => {
// 회원 목록 데이터 상태
const [members, setMembers] = useState([]);
- const [isPromotingById, setIsPromotingById] = useState({});
- const [isDeletingById, setIsDeletingById] = useState({});
- const [isChangingById, setIsChangingById] = useState({});
const [changeDialog, setChangeDialog] = useState({
open: false,
type: 'role',
member: null,
value: '',
});
- const latestRequestIdRef = useRef(0);
// 회원 목록 조회 (필요한 필터만 백엔드로 전달)
- const loadMembers = async ({ keyword, role, status, requestId } = {}) => {
+ const loadMembers = async ({ keyword, role, status } = {}) => {
try {
const data = await getAdminMembersData({ keyword, role, status });
-
- if (requestId != null && requestId !== latestRequestIdRef.current) {
- return;
- }
-
const nextMembers = data.members || [];
setMembers(nextMembers);
+ console.log('회원 목록 로드 성공:', nextMembers);
} catch (error) {
- if (requestId != null && requestId !== latestRequestIdRef.current) {
- return;
- }
-
window.alert(error?.message || '회원 목록을 불러오지 못했습니다.');
setMembers([]);
}
};
+ useEffect(() => {
+ loadMembers();
+ }, []);
+
// 필터/검색 조건 변경 시 목록 재조회
useEffect(() => {
- const requestId = ++latestRequestIdRef.current;
const backendRole = roleFilter === 'all' ? undefined : roleFilter;
const backendStatus = statusFilter === 'all' ? undefined : statusFilter;
loadMembers({
keyword: searchQuery.trim() || undefined,
role: backendRole,
status: backendStatus,
- requestId,
});
-
- return () => {
- if (latestRequestIdRef.current === requestId) {
- latestRequestIdRef.current += 1;
- }
- };
}, [roleFilter, searchQuery, statusFilter]);
const filteredMembers = useMemo(() => {
@@ -140,15 +124,6 @@ const AdminMemberManage = () => {
const { type, member, value } = changeDialog;
if (!member) return;
- if (isChangingById[member.id]) {
- return;
- }
-
- setIsChangingById((prev) => ({
- ...prev,
- [member.id]: true,
- }));
-
try {
if (type === 'role') {
if (!ROLE_OPTIONS.includes(value)) {
@@ -175,29 +150,15 @@ const AdminMemberManage = () => {
error?.message ||
(type === 'role' ? '권한 변경에 실패했습니다.' : '상태 변경에 실패했습니다.')
);
- } finally {
- setIsChangingById((prev) => ({
- ...prev,
- [member.id]: false,
- }));
}
};
// 단일 회원 선배 전환
const handlePromoteSenior = async (member) => {
- if (isPromotingById[member.id]) {
- return;
- }
-
if (!window.confirm(`${member.name}님을 선배(SENIOR)로 전환하시겠습니까?`)) {
return;
}
- setIsPromotingById((prev) => ({
- ...prev,
- [member.id]: true,
- }));
-
try {
await promoteAdminMemberSenior({ userId: member.id });
await loadMembers({
@@ -207,29 +168,15 @@ const AdminMemberManage = () => {
});
} catch (error) {
window.alert(error?.message || '선배 전환에 실패했습니다.');
- } finally {
- setIsPromotingById((prev) => ({
- ...prev,
- [member.id]: false,
- }));
}
};
// 단일 회원 삭제
const handleDelete = async (member) => {
- if (isDeletingById[member.id]) {
- return;
- }
-
if (!window.confirm(`${member.name}님을 강제 탈퇴 처리하시겠습니까?`)) {
return;
}
- setIsDeletingById((prev) => ({
- ...prev,
- [member.id]: true,
- }));
-
try {
await deleteAdminMember({ userId: member.id });
await loadMembers({
@@ -239,11 +186,6 @@ const AdminMemberManage = () => {
});
} catch (error) {
window.alert(error?.message || '회원 삭제에 실패했습니다.');
- } finally {
- setIsDeletingById((prev) => ({
- ...prev,
- [member.id]: false,
- }));
}
};
@@ -295,7 +237,7 @@ const AdminMemberManage = () => {
권한 |
포인트 |
상태 |
- 기수 |
+ 가입일 |
작업 |
@@ -304,7 +246,7 @@ const AdminMemberManage = () => {
- {member.name?.[0] ?? '?'}
+ {member.name[0]}
{member.name}
{member.email}
@@ -354,17 +296,15 @@ const AdminMemberManage = () => {
type="button"
className={styles.actionButton}
onClick={() => handlePromoteSenior(member)}
- disabled={Boolean(isPromotingById[member.id])}
>
- {isPromotingById[member.id] ? '처리 중...' : '선배 전환'}
+ 선배 전환
|
@@ -428,9 +368,8 @@ const AdminMemberManage = () => {
type="button"
className={styles.actionButton}
onClick={confirmChangeDialog}
- disabled={Boolean(isChangingById[changeDialog.member.id])}
>
- {isChangingById[changeDialog.member.id] ? '처리 중...' : '변경'}
+ 변경
diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css b/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css
index 8db6587f..724c48c4 100644
--- a/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css
+++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.module.css
@@ -41,14 +41,12 @@
.tableWrap {
border: 1px solid #e5e7eb;
border-radius: 12px;
- overflow-x: auto;
- overflow-y: hidden;
+ overflow: hidden;
background: #fff;
}
.table {
width: 100%;
- min-width: 1080px;
border-collapse: collapse;
}
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 2561c193..892f8299 100644
--- a/frontend/src/components/attendance/SessionManage.jsx
+++ b/frontend/src/components/attendance/SessionManage.jsx
@@ -1,40 +1,96 @@
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();
- const normalizedSessions = Array.isArray(data)
- ? data.filter((item) => item && typeof item === 'object')
- : [];
- setSessions(normalizedSessions);
- } catch (err) {
- setError('데이터를 불러오는 중 오류가 발생했습니다.');
- setSessions([]);
- } 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 (
- 세션 관리
+ 출석 목록
@@ -42,26 +98,28 @@ const SessionManage = () => {
| 일자 |
출석시작시간 |
- 출석가능시간 |
회차 |
이름 |
- |
+ 출석 상태 |
- {(Array.isArray(sessions) ? 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 (
-
+
- |