diff --git a/frontend/src/components/AdminDashboard/AdminDashboard.jsx b/frontend/src/components/AdminDashboard/AdminDashboard.jsx index 34883a29..4eafe1fa 100644 --- a/frontend/src/components/AdminDashboard/AdminDashboard.jsx +++ b/frontend/src/components/AdminDashboard/AdminDashboard.jsx @@ -39,8 +39,24 @@ const PIE_COLORS = ['#2563eb', '#16a34a', '#d97706', '#7c3aed', '#475569', '#dc2 const ACTIVITY_FETCH_SIZE = 100; const MAX_ACTIVITY_LOGS = 300; +const DASHBOARD_ROLE_LABELS = { + SYSTEM_ADMIN: '관리자', + '시스템 관리자': '관리자', + PRESIDENT: '회장', + VICE_PRESIDENT: '부회장', + TEAM_LEADER: '팀장', + TEAM_MEMBER: '일반', + PENDING_MEMBER: '대기회원', +}; + const toNumber = (value) => Number(value || 0); +const formatDashboardRoleLabel = (value) => { + const normalizedValue = String(value || '').trim(); + if (!normalizedValue) return 'UNKNOWN'; + return DASHBOARD_ROLE_LABELS[normalizedValue] || normalizedValue; +}; + const formatDateLabel = (value) => { if (!value) return '-'; const date = new Date(value); @@ -112,7 +128,7 @@ const AdminDashboard = () => { const pieChartData = useMemo( () => usersDistribution.map((item, index) => ({ - name: item?.roleName || 'UNKNOWN', + name: formatDashboardRoleLabel(item?.roleName), value: toNumber(item?.count), color: PIE_COLORS[index % PIE_COLORS.length], })), @@ -443,7 +459,7 @@ const AdminDashboard = () => {
  • {formatDateTime(activity.createdAt)} - {activity.username} {activity.message} + {activity.username}님이 {activity.message} {activity.boardName ? ` (${activity.boardName})` : ''}
  • diff --git a/frontend/src/components/AdminHome/MemberList.jsx b/frontend/src/components/AdminHome/MemberList.jsx index 7546830b..07a575ca 100644 --- a/frontend/src/components/AdminHome/MemberList.jsx +++ b/frontend/src/components/AdminHome/MemberList.jsx @@ -1,5 +1,5 @@ const ROLE_LABELS = { - SYSTEM_ADMIN: '시스템 관리자', + SYSTEM_ADMIN: '관리자', PRESIDENT: '회장', VICE_PRESIDENT: '부회장', TEAM_LEADER: '팀장', @@ -16,7 +16,7 @@ const STATUS_LABELS = { const getRoleClassName = (roleLabel, styles) => { if (roleLabel === '회장') return styles.rolePresident; - if (roleLabel === '부회장') return styles.roleManager; + if (roleLabel === '관리자' || roleLabel === '부회장') return styles.roleManager; if (roleLabel === '팀장' || roleLabel === '대기회원') return styles.roleLeader; return styles.roleNormal; }; diff --git a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx index a55a73d0..9cd04d0f 100644 --- a/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx +++ b/frontend/src/components/AdminMemberManage/AdminMemberManage.jsx @@ -1,15 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Search, Star } from 'lucide-react'; +import { MoreVertical, Search, Star } from 'lucide-react'; import styles from './AdminMemberManage.module.css'; import { + changeAdminMemberGrade, changeAdminMemberRole, changeAdminMemberStatus, deleteAdminMember, getAdminMembersData, - promoteAdminMemberSenior, } from '../../utils/adminMembersData'; const ROLE_LABELS = { + SYSTEM_ADMIN: '관리자', PRESIDENT: '회장', VICE_PRESIDENT: '부회장', TEAM_LEADER: '팀장', @@ -18,6 +19,7 @@ const ROLE_LABELS = { }; const ROLE_OPTIONS = [ + 'SYSTEM_ADMIN', 'PRESIDENT', 'VICE_PRESIDENT', 'TEAM_LEADER', @@ -33,11 +35,18 @@ const STATUS_LABELS = { const STATUS_OPTIONS = ['ACTIVE', 'INACTIVE', 'GRADUATED']; +const GRADE_LABELS = { + NEW_MEMBER: '신입부원', + ASSOCIATE_MEMBER: '준회원', + REGULAR_MEMBER: '정회원', +}; + +const GRADE_OPTIONS = ['NEW_MEMBER', 'ASSOCIATE_MEMBER', 'REGULAR_MEMBER']; + const getRoleClassName = (role) => { - if (role === '회장') return styles.rolePresident; - if (role === '부회장') return styles.roleManager; - if (role === '팀장') return styles.roleLeader; - if (role === '대기회원') return styles.roleLeader; + if (role === 'PRESIDENT') return styles.rolePresident; + if (role === 'SYSTEM_ADMIN' || role === 'VICE_PRESIDENT') return styles.roleManager; + if (role === 'TEAM_LEADER' || role === 'PENDING_MEMBER') return styles.roleLeader; return styles.roleNormal; }; @@ -55,9 +64,9 @@ const AdminMemberManage = () => { // 회원 목록 데이터 상태 const [members, setMembers] = useState([]); - const [isPromotingById, setIsPromotingById] = useState({}); const [isDeletingById, setIsDeletingById] = useState({}); const [isChangingById, setIsChangingById] = useState({}); + const [openActionMenuId, setOpenActionMenuId] = useState(null); const [changeDialog, setChangeDialog] = useState({ open: false, type: 'role', @@ -106,11 +115,25 @@ const AdminMemberManage = () => { }; }, [roleFilter, searchQuery, statusFilter]); + useEffect(() => { + const closeActionMenuOnOutsideClick = (event) => { + if (!event.target.closest('[data-member-action-menu]')) { + setOpenActionMenuId(null); + } + }; + + document.addEventListener('mousedown', closeActionMenuOnOutsideClick); + return () => { + document.removeEventListener('mousedown', closeActionMenuOnOutsideClick); + }; + }, []); + const filteredMembers = useMemo(() => { return members.map((member) => ({ ...member, displayRole: ROLE_LABELS[member.role] || member.role, displayStatus: STATUS_LABELS[member.status] || member.status, + displayGrade: GRADE_LABELS[member.grade] || member.grade || '-', })); }, [members]); @@ -132,6 +155,15 @@ const AdminMemberManage = () => { }); }; + const openGradeDialog = (member) => { + setChangeDialog({ + open: true, + type: 'grade', + member, + value: member.grade || GRADE_OPTIONS[0], + }); + }; + const closeChangeDialog = () => { setChangeDialog({ open: false, type: 'role', member: null, value: '' }); }; @@ -156,12 +188,18 @@ const AdminMemberManage = () => { return; } await changeAdminMemberRole({ userId: member.id, role: value }); - } else { + } else if (type === 'status') { if (!STATUS_OPTIONS.includes(value)) { window.alert('유효하지 않은 상태입니다.'); return; } await changeAdminMemberStatus({ userId: member.id, status: value }); + } else { + if (!GRADE_OPTIONS.includes(value)) { + window.alert('유효하지 않은 신분입니다.'); + return; + } + await changeAdminMemberGrade({ userId: member.id, grade: value }); } closeChangeDialog(); @@ -173,7 +211,11 @@ const AdminMemberManage = () => { } catch (error) { window.alert( error?.message || - (type === 'role' ? '권한 변경에 실패했습니다.' : '상태 변경에 실패했습니다.') + (type === 'role' + ? '권한 변경에 실패했습니다.' + : type === 'status' + ? '상태 변경에 실패했습니다.' + : '신분 변경에 실패했습니다.') ); } finally { setIsChangingById((prev) => ({ @@ -183,38 +225,6 @@ const AdminMemberManage = () => { } }; - // 단일 회원 선배 전환 - const handlePromoteSenior = async (member) => { - if (isPromotingById[member.id]) { - return; - } - - if (!window.confirm(`${member.name}님을 선배(SENIOR)로 전환하시겠습니까?`)) { - return; - } - - setIsPromotingById((prev) => ({ - ...prev, - [member.id]: true, - })); - - try { - await promoteAdminMemberSenior({ userId: member.id }); - await loadMembers({ - keyword: searchQuery.trim() || undefined, - role: roleFilter === 'all' ? undefined : roleFilter, - status: statusFilter === 'all' ? undefined : statusFilter, - }); - } catch (error) { - window.alert(error?.message || '선배 전환에 실패했습니다.'); - } finally { - setIsPromotingById((prev) => ({ - ...prev, - [member.id]: false, - })); - } - }; - // 단일 회원 삭제 const handleDelete = async (member) => { if (isDeletingById[member.id]) { @@ -266,11 +276,11 @@ const AdminMemberManage = () => { onChange={(event) => setRoleFilter(event.target.value)} > - - - - - + {ROLE_OPTIONS.map((role) => ( + + ))} { })) } > - {(changeDialog.type === 'role' ? ROLE_OPTIONS : STATUS_OPTIONS).map( - (option) => ( - - ) - )} + {(changeDialog.type === 'role' + ? ROLE_OPTIONS + : changeDialog.type === 'status' + ? STATUS_OPTIONS + : GRADE_OPTIONS + ).map((option) => ( + + ))}
    +
    + + +
    ); diff --git a/frontend/src/components/Board/Modal.module.css b/frontend/src/components/Board/Modal.module.css index 44cd9284..1a6e9e4d 100644 --- a/frontend/src/components/Board/Modal.module.css +++ b/frontend/src/components/Board/Modal.module.css @@ -8,7 +8,7 @@ display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 1200; } .modal { @@ -370,3 +370,20 @@ cursor: not-allowed; transform: none; } + +.actionRow { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-shrink: 0; +} + +.anonymousOption { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: rgba(80, 80, 80, 1); + user-select: none; +} diff --git a/frontend/src/components/Board/PostDetail/CommentSection.jsx b/frontend/src/components/Board/PostDetail/CommentSection.jsx index faf03e2a..dfeac8dd 100644 --- a/frontend/src/components/Board/PostDetail/CommentSection.jsx +++ b/frontend/src/components/Board/PostDetail/CommentSection.jsx @@ -27,13 +27,18 @@ const CommentItem = ({ const date = comment.createdDate || comment.createdAt || comment.date; const [localReplyText, setLocalReplyText] = useState(''); + const [isAnonymousReply, setIsAnonymousReply] = useState(false); React.useEffect(() => { - if (!isReplying) setLocalReplyText(''); + if (!isReplying) { + setLocalReplyText(''); + setIsAnonymousReply(false); + } }, [isReplying]); const handleLocalSubmit = () => { - onSubmitReply(commentId, localReplyText); + onSubmitReply(commentId, localReplyText, isAnonymousReply); + setIsAnonymousReply(false); }; return ( @@ -97,14 +102,24 @@ const CommentItem = ({ value={localReplyText} onChange={(e) => setLocalReplyText(e.target.value)} /> - +
    + + +
    )} @@ -146,10 +161,12 @@ const CommentSection = ({ setShowCommentMenu, }) => { const [commentText, setCommentText] = useState(''); + const [isAnonymousComment, setIsAnonymousComment] = useState(false); const handleAdd = () => { - onAddComment(commentText); + onAddComment(commentText, isAnonymousComment); setCommentText(''); + setIsAnonymousComment(false); }; const handleKeyDown = (e) => { @@ -195,13 +212,23 @@ const CommentSection = ({ onKeyDown={handleKeyDown} /> - +
    + + +
    {comments.length > 0 ? ( diff --git a/frontend/src/components/Board/PostDetail/PostView.jsx b/frontend/src/components/Board/PostDetail/PostView.jsx index 017a3b6b..ec876844 100644 --- a/frontend/src/components/Board/PostDetail/PostView.jsx +++ b/frontend/src/components/Board/PostDetail/PostView.jsx @@ -9,6 +9,7 @@ import FileAttachmentList from './FileAttachmentList'; const PostView = ({ post, boardName, + canManagePost, showMenu, setShowMenu, onEdit, @@ -37,30 +38,32 @@ const PostView = ({

    {post.title}

    -
    - - {showMenu && ( -
    - - -
    - )} -
    + {canManagePost && ( +
    + + {showMenu && ( +
    + + +
    + )} +
    + )}
    diff --git a/frontend/src/components/mypage/ProfileCard.jsx b/frontend/src/components/mypage/ProfileCard.jsx index 619f7ecc..26aef11e 100644 --- a/frontend/src/components/mypage/ProfileCard.jsx +++ b/frontend/src/components/mypage/ProfileCard.jsx @@ -7,11 +7,12 @@ import { toast } from 'react-toastify'; import AccountSecurity from './AccountSecurity'; const roleMap = { - SYSTEM_ADMIN: '시스템관리자', + SYSTEM_ADMIN: '관리자', PRESIDENT: '회장', VICE_PRESIDENT: '부회장', TEAM_LEADER: '팀장', - TEAM_MEMBER: '부원', + TEAM_MEMBER: '일반', + PENDING_MEMBER: '대기회원', }; const ProfileCard = () => { @@ -29,7 +30,7 @@ const ProfileCard = () => { setUserName(name || '사용자'); setUserPoint(point || 0); - setUserRole(roleMap[role] || '일반'); + setUserRole(roleMap[role] || '-'); } catch (error) { console.error('사용자 정보 불러오기 실패:', error); toast.error('사용자 정보를 불러올 수 없습니다.'); diff --git a/frontend/src/components/signup/SignUpForm.jsx b/frontend/src/components/signup/SignUpForm.jsx index b6717935..b8b80ee9 100644 --- a/frontend/src/components/signup/SignUpForm.jsx +++ b/frontend/src/components/signup/SignUpForm.jsx @@ -37,10 +37,14 @@ const SignUpForm = () => { ); const [isSending, setIsSending] = useState(false); + const [isCheckingVerification, setIsCheckingVerification] = useState(false); + const [isSigningUp, setIsSigningUp] = useState(false); const [isVerificationSent, setVerificationSent] = useState(false); const [isVerificationChecked, setVerificationChecked] = useState(false); - const abortRef = useRef(null); + const abortRefSend = useRef(null); + const abortRefCheck = useRef(null); + const abortRefSignUp = useRef(null); const nav = useNavigate(); const handlePasswordChange = (e) => { @@ -118,13 +122,13 @@ const SignUpForm = () => { const handleSendVerificationNumber = async (e) => { e.preventDefault(); - abortRef.current?.abort(); - abortRef.current = new AbortController(); + abortRefSend.current?.abort(); + abortRefSend.current = new AbortController(); setIsSending(true); try { - await sendVerificationNumber({ email }, abortRef.current.signal); + await sendVerificationNumber({ email }, abortRefSend.current.signal); setVerificationSent(true); toast.success('인증번호가 발송되었습니다.'); } catch (error) { @@ -136,27 +140,41 @@ const SignUpForm = () => { }; const handleCheckVerificationNumber = async () => { - abortRef.current?.abort(); - abortRef.current = new AbortController(); + if (isCheckingVerification) { + return; + } + + abortRefCheck.current?.abort(); + abortRefCheck.current = new AbortController(); + + setIsCheckingVerification(true); try { await checkVerificationNumber( { email, verificationNumber }, - abortRef.current.signal + abortRefCheck.current.signal ); setVerificationChecked(true); toast.success('인증되었습니다.'); } catch (error) { console.log(error); toast.error('인증에 실패했습니다.'); + } finally { + setIsCheckingVerification(false); } }; const handleSignUp = async (e) => { e.preventDefault(); - abortRef.current?.abort(); - abortRef.current = new AbortController(); + if (isSigningUp) { + return; + } + + abortRefSignUp.current?.abort(); + abortRefSignUp.current = new AbortController(); + + setIsSigningUp(true); try { await signUp( @@ -173,13 +191,15 @@ const SignUpForm = () => { teamName, remark, }, - abortRef.current.signal + abortRefSignUp.current.signal ); toast.success('회원가입이 완료되었습니다.'); nav('/login'); } catch (error) { console.log(error); toast.error('회원가입에 실패하였습니다.'); + } finally { + setIsSigningUp(false); } }; @@ -288,9 +308,9 @@ const SignUpForm = () => { type="button" className={styles.verifyButton} onClick={handleCheckVerificationNumber} - disabled={!isVerificationSent} + disabled={!isVerificationSent || isCheckingVerification} > - 인증번호 확인 + {isCheckingVerification ? '확인 중...' : '인증번호 확인'}
    @@ -382,9 +402,9 @@ const SignUpForm = () => { diff --git a/frontend/src/pages/Board.jsx b/frontend/src/pages/Board.jsx index ebed9068..b8259280 100644 --- a/frontend/src/pages/Board.jsx +++ b/frontend/src/pages/Board.jsx @@ -86,6 +86,7 @@ const Board = () => { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [selectedFiles, setSelectedFiles] = useState([]); + const [isAnonymousPost, setIsAnonymousPost] = useState(false); const [sortOption, setSortOption] = useState('latest'); const [loading, setLoading] = useState(false); const [boardsLoaded, setBoardsLoaded] = useState(false); @@ -315,6 +316,7 @@ const Board = () => { const handleOpenModal = () => { setWriteBoardId(''); + setIsAnonymousPost(false); setShowModal(true); }; @@ -324,6 +326,7 @@ const Board = () => { setContent(''); setSelectedFiles([]); setWriteBoardId(''); + setIsAnonymousPost(false); setIsSavingPost(false); }; @@ -439,6 +442,7 @@ const Board = () => { title: title.trim(), content: content.trim(), files: selectedFiles, + anonymous: isAnonymousPost, }; await boardApi.createPost(writeBoardId, postData); @@ -680,6 +684,8 @@ const Board = () => { setTitle={setTitle} content={content} setContent={setContent} + isAnonymous={isAnonymousPost} + setIsAnonymous={setIsAnonymousPost} boardOptions={subBoardTabs.filter((tab) => tab.id !== ALL_TAB_ID)} selectedBoardId={writeBoardId} onBoardChange={setWriteBoardId} diff --git a/frontend/src/pages/PostDetail.jsx b/frontend/src/pages/PostDetail.jsx index 7374819a..d004dc03 100644 --- a/frontend/src/pages/PostDetail.jsx +++ b/frontend/src/pages/PostDetail.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import * as boardApi from '../utils/boardApi'; +import { api } from '../utils/axios'; import styles from './PostDetail.module.css'; import { toBoardRouteSegment } from '../utils/boardRoute'; @@ -84,11 +85,23 @@ const PostDetail = () => { const [showMenu, setShowMenu] = useState(false); const [showCommentMenu, setShowCommentMenu] = useState(null); const [replyTargetId, setReplyTargetId] = useState(null); + const [currentUser, setCurrentUser] = useState(null); const boardName = post?.boardName || post?.board?.boardName || post?.board?.name || ''; const boardId = post?.boardId || post?.board?.boardId || ''; + const postAuthorId = post?.user?.id || post?.userId || post?.createdBy?.id; + const currentUserId = currentUser?.id || currentUser?.userId; + + const isPostOwner = Boolean( + post && + currentUser && + postAuthorId && + currentUserId && + postAuthorId === currentUserId + ); + // 데이터 로드 로직 const refreshPostAndComments = async () => { try { @@ -132,6 +145,19 @@ const PostDetail = () => { fetchPostAndComments(); }, [postId]); + useEffect(() => { + const fetchCurrentUser = async () => { + try { + const { data } = await api.get('/api/user/details'); + setCurrentUser(data || null); + } catch { + setCurrentUser(null); + } + }; + + fetchCurrentUser(); + }, []); + // --- 게시글 액션 핸들러 --- const handleLike = async () => { const prevPost = post; @@ -283,9 +309,9 @@ const PostDetail = () => { // --- 댓글 핸들러 --- const handleCommentHandlers = { - add: async (text) => { + add: async (text, anonymous) => { try { - await boardApi.createComment({ postId, content: text }); + await boardApi.createComment({ postId, content: text, anonymous }); await refreshPostAndComments(); } catch (error) { console.error('댓글 작성 실패:', error); @@ -326,12 +352,13 @@ const PostDetail = () => { alert('댓글 삭제에 실패했습니다.'); } }, - reply: async (parentId, text) => { + reply: async (parentId, text, anonymous) => { try { await boardApi.createComment({ postId, content: text, parentCommentId: parentId, + anonymous, }); await refreshPostAndComments(); setReplyTargetId(null); @@ -382,6 +409,7 @@ const PostDetail = () => { { const recentActivities = activityItems.map((activity, index) => ({ id: activity?.id || `${activity?.createdAt || 'unknown'}-${index}`, - message: `${activity?.username || '시스템'} ${activity?.message || '-'}`, + message: `${activity?.username || '시스템'}님이 ${activity?.message || '-'}`, time: formatDateTime(activity?.createdAt), })); diff --git a/frontend/src/utils/adminMembersData.js b/frontend/src/utils/adminMembersData.js index a3213a79..ad6b9b0f 100644 --- a/frontend/src/utils/adminMembersData.js +++ b/frontend/src/utils/adminMembersData.js @@ -35,10 +35,12 @@ export const changeAdminMemberStatus = async ({ userId, status }) => { }); }; -// 회원을 선배(SENIOR)로 전환 -export const promoteAdminMemberSenior = async ({ userId }) => { +// 회원 신분(grade) 변경 +export const changeAdminMemberGrade = async ({ userId, grade }) => { ensureUserId(userId); - await api.patch(`/api/admin/users/${userId}/senior`); + await api.patch(`/api/admin/users/${userId}/grade`, null, { + params: { grade }, + }); }; // 회원 강제 삭제 diff --git a/frontend/src/utils/adminUserApi.js b/frontend/src/utils/adminUserApi.js index 7754f3fa..11d8321b 100644 --- a/frontend/src/utils/adminUserApi.js +++ b/frontend/src/utils/adminUserApi.js @@ -38,10 +38,12 @@ export const updateAdminUserStatus = async ({ userId, status }) => { }); }; -// 사용자를 선배(SENIOR)로 전환 -export const promoteAdminUserSenior = async ({ userId }) => { +// 사용자 신분(grade) 변경 +export const updateAdminUserGrade = async ({ userId, grade }) => { ensureUserId(userId); - await api.patch(`/api/admin/users/${userId}/senior`); + await api.patch(`/api/admin/users/${userId}/grade`, null, { + params: { grade }, + }); }; // 사용자 삭제 diff --git a/frontend/src/utils/boardApi.js b/frontend/src/utils/boardApi.js index 4265c5b0..9b945fcd 100644 --- a/frontend/src/utils/boardApi.js +++ b/frontend/src/utils/boardApi.js @@ -133,6 +133,7 @@ export const createPost = async (boardId, postData) => { formData.append('boardId', boardId); formData.append('title', postData.title); formData.append('content', postData.content); + formData.append('anonymous', String(Boolean(postData.anonymous))); if (postData.files && postData.files.length > 0) { postData.files.forEach((file) => { @@ -237,6 +238,7 @@ export const createComment = async (commentData) => { const requestBody = { postId: commentData.postId, content: commentData.content, + anonymous: Boolean(commentData.anonymous), }; if (commentData.parentCommentId) {