Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const AdminExcelUpload = () => {
const handleInputChange = (event) => {
const file = event.target.files?.[0];
handleSelectFile(file);
event.target.value = '';
};

const handleDragOver = (event) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,6 @@
}

.fileName {
min-width: 0;
flex: 1;
font-size: 13px;
color: #111827;
overflow: hidden;
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/AdminHome/AdminHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const AdminHeader = ({ title }) => {
<input
type="search"
placeholder="검색..."
aria-label="관리자 검색"
className={styles.searchInput}
/>
</div>
Expand Down
7 changes: 1 addition & 6 deletions frontend/src/components/AdminHome/AdminHeader.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,7 @@
padding: 0 10px 0 32px;
border: 1px solid #e5e7eb;
border-radius: 8px;
}

.searchInput:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
border-color: #2563eb;
outline: none;
}

.iconButton,
Expand Down
18 changes: 6 additions & 12 deletions frontend/src/components/AdminHome/AdminSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ const presidentMenuItems = [
{
category: '콘텐츠',
items: [
{ label: '게시물 관리', href: '/admin/posts', icon: FileText, disabled: true },
{ label: '출석 관리', href: '/admin/attendance', icon: Calendar, disabled: true },
{ label: '포인트 관리', href: '/admin/points', icon: Star, disabled: true },
{ label: '게시물 관리', href: '/admin/posts', icon: FileText },
{ label: '출석 관리', href: '/admin/attendance', icon: Calendar },
{ label: '포인트 관리', href: '/admin/points', icon: Star },
],
},
{
category: '시스템',
items: [
{ label: '게임/툴 관리', href: '/admin/tools', icon: Gamepad2, disabled: true },
{ label: '통계 대시보드', href: '/admin/dashboard', icon: BarChart3, disabled: true },
{ label: '게임/툴 관리', href: '/admin/tools', icon: Gamepad2 },
{ label: '통계 대시보드', href: '/admin/dashboard', icon: BarChart3 },
],
},
];
Expand All @@ -56,12 +56,6 @@ const devMenuItems = [
const AdminSidebar = () => {
const { pathname } = useLocation();
const isDevSection = pathname.startsWith('/admin/dev');
const currentSections = (isDevSection ? devMenuItems : presidentMenuItems)
.map((section) => ({
...section,
items: section.items.filter((item) => !item.disabled),
}))
.filter((section) => section.items.length > 0);

return (
<aside className={styles.sidebar}>
Expand Down Expand Up @@ -92,7 +86,7 @@ const AdminSidebar = () => {
</div>

<nav className={styles.nav}>
{currentSections.map((section) => (
{(isDevSection ? devMenuItems : presidentMenuItems).map((section) => (
<div key={section.category} className={styles.section}>
<div className={styles.sectionTitleWrap}>
<span className={styles.sectionTitle}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -245,7 +245,7 @@ const AdminMemberApprovalList = () => {
</td>
<td>
<div className={styles.memberInfo}>
<div className={styles.avatar}>{member.name?.[0] || '?'}</div>
<div className={styles.avatar}>{member.name[0]}</div>
<div>
<p className={styles.memberName}>{member.name}</p>
<p className={styles.memberEmail}>{member.email}</p>
Expand Down
85 changes: 12 additions & 73 deletions frontend/src/components/AdminMemberManage/AdminMemberManage.jsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

민감한 회원 정보를 브라우저 콘솔에 출력하지 마세요.

회원 목록 객체에는 이메일/학번 등 식별자가 포함될 수 있어, 운영 환경 콘솔 로그는 개인정보 노출 경로가 됩니다.

🔧 제안 수정안
-      console.log('회원 목록 로드 성공:', nextMembers);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log('회원 목록 로드 성공:', nextMembers);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/AdminMemberManage/AdminMemberManage.jsx` at line 71,
Remove the console.log that prints member data to the browser (the
console.log('회원 목록 로드 성공:', nextMembers) in the AdminMemberManage component) to
avoid exposing PII; if you still need runtime visibility for debugging, use a
stripped/aggregated log (e.g., log only counts or non-identifying metrics) or
wrap detailed logs behind a debug flag that is disabled in production and ensure
nextMembers is never logged in production builds.

} 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(() => {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -239,11 +186,6 @@ const AdminMemberManage = () => {
});
} catch (error) {
window.alert(error?.message || '회원 삭제에 실패했습니다.');
} finally {
setIsDeletingById((prev) => ({
...prev,
[member.id]: false,
}));
}
};

Expand Down Expand Up @@ -295,7 +237,7 @@ const AdminMemberManage = () => {
<th>권한</th>
<th>포인트</th>
<th>상태</th>
<th>기수</th>
<th>가입일</th>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

컬럼 라벨과 실제 값이 불일치합니다.

헤더는 가입일인데 셀은 generation(기수)를 출력하고 있어 사용자에게 잘못된 정보를 보여줍니다.

Also applies to: 278-278

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/AdminMemberManage/AdminMemberManage.jsx` at line 240,
The table header label "가입일" does not match the cell value (the code renders the
member's generation), so update the JSX in AdminMemberManage.jsx to make them
consistent: either change the <th> text "가입일" to "기수" (or the correct label) or
change the cell renderer that currently outputs generation (e.g.,
member.generation or the map item rendering) to output the join date field
(e.g., member.joinDate/createdAt). Ensure you apply the same fix for the other
occurrence noted around the second instance (the cell at the other row/column
rendering, referenced near the code that uses generation).

<th className={styles.rightAlign}>작업</th>
</tr>
</thead>
Expand All @@ -304,7 +246,7 @@ const AdminMemberManage = () => {
<tr key={member.id}>
<td>
<div className={styles.memberInfo}>
<div className={styles.avatar}>{member.name?.[0] ?? '?'}</div>
<div className={styles.avatar}>{member.name[0]}</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

아바타 이니셜 접근에서 런타임 에러 가능성이 있습니다.

member.name이 비어있거나 null인 응답이 오면 렌더링이 깨질 수 있습니다.

🔧 제안 수정안
-                    <div className={styles.avatar}>{member.name[0]}</div>
+                    <div className={styles.avatar}>{member.name?.[0] ?? '?'}</div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className={styles.avatar}>{member.name[0]}</div>
<div className={styles.avatar}>{member.name?.[0] ?? '?'}</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/AdminMemberManage/AdminMemberManage.jsx` at line 249,
The avatar initial render can throw if member.name is null/undefined or not a
string; update the AdminMemberManage.jsx avatar rendering (the div using
styles.avatar and member.name[0]) to defensively read the initial—use optional
chaining and a safe default (e.g., derive initial with something like const
initial = (typeof member?.name === 'string' && member.name.length) ?
member.name[0] : fallbackChar) and render that initial instead, or create a
small utility/getInitial function to centralize this logic so non-string or
missing names won't break rendering.

<div>
<p className={styles.memberName}>{member.name}</p>
<p className={styles.memberEmail}>{member.email}</p>
Expand Down Expand Up @@ -354,17 +296,15 @@ const AdminMemberManage = () => {
type="button"
className={styles.actionButton}
onClick={() => handlePromoteSenior(member)}
disabled={Boolean(isPromotingById[member.id])}
>
{isPromotingById[member.id] ? '처리 중...' : '선배 전환'}
선배 전환
</button>
<button
type="button"
className={styles.actionButton}
onClick={() => handleDelete(member)}
disabled={Boolean(isDeletingById[member.id])}
>
{isDeletingById[member.id] ? '처리 중...' : '회원 삭제'}
회원 삭제
</button>
</div>
</td>
Expand Down Expand Up @@ -428,9 +368,8 @@ const AdminMemberManage = () => {
type="button"
className={styles.actionButton}
onClick={confirmChangeDialog}
disabled={Boolean(isChangingById[changeDialog.member.id])}
>
{isChangingById[changeDialog.member.id] ? '처리 중...' : '변경'}
변경
</button>
</div>
</div>
Expand Down
Loading