+
{uploadError && (
-
{uploadError.message ?? '이미지 업로드에 실패했습니다.'}
+
{uploadError.message ?? '이미지 업로드에 실패했습니다.'}
)}
{error && isApiError(error) && error.apiError?.fieldErrors?.length
- ? error.apiError.fieldErrors.map((fe) => (
-
- {fe.message}
+ ? error.apiError.fieldErrors.map((fieldError) => (
+
+ {fieldError.message}
))
- : error &&
{error.message ?? '동아리 정보 수정에 실패했습니다.'}
}
+ : error && (
+
{error.message ?? '동아리 정보 수정에 실패했습니다.'}
+ )}
@@ -213,11 +278,15 @@ function ManagedClubInfo() {
type="button"
disabled={isPending || isUploading}
onClick={handleSubmit}
- className="bg-primary text-h3 w-full rounded-lg py-3.5 text-center text-white disabled:cursor-not-allowed disabled:opacity-50"
+ className="bg-primary-500 text-h3 w-full rounded-lg py-3.5 text-center text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{isUploading ? '수정 중...' : isPending ? '수정 중...' : '수정하기'}
-
diff --git a/src/pages/Manager/ManagedMemberApplicationDetail/index.tsx b/src/pages/Manager/ManagedMemberApplicationDetail/index.tsx
index c436368..8d45574 100644
--- a/src/pages/Manager/ManagedMemberApplicationDetail/index.tsx
+++ b/src/pages/Manager/ManagedMemberApplicationDetail/index.tsx
@@ -1,8 +1,6 @@
import { useParams } from 'react-router-dom';
-import Portal from '@/components/common/Portal';
-import useBooleanState from '@/utils/hooks/useBooleanState';
-import { formatIsoDateToYYYYMMDDHHMM } from '@/utils/ts/date';
-import { useGetManagedMemberApplicationDetailByUser } from '../hooks/useManagedApplications';
+import ApplicationDetailContent from '@/pages/Manager/components/ApplicationDetailContent';
+import { useGetManagedMemberApplicationDetailByUser } from '@/pages/Manager/hooks/useManagedApplications';
function ManagedMemberApplicationDetail() {
const params = useParams();
@@ -13,7 +11,6 @@ function ManagedMemberApplicationDetail() {
clubId,
userId
);
- const { value: isImageOpen, setTrue: openImage, setFalse: closeImage } = useBooleanState();
if (!application) {
return (
@@ -23,83 +20,7 @@ function ManagedMemberApplicationDetail() {
);
}
- return (
-
-
-
-
-

-
-
- {application.name}
- ({application.studentNumber})
-
-
- 지원일: {formatIsoDateToYYYYMMDDHHMM(application.appliedAt)}
-
-
-
-
-
- {application.feePaymentImageUrl && (
-
- 회비 납부 인증
-
-
-
-
-
-
- )}
-
-
-
- 지원서 내용
- {application.answers.length}개의 문항
-
-
-
- {application.answers.map((answer, index) => (
-
-
- 문항 {index + 1}
- {answer.isRequired && (
- 필수
- )}
-
-
-
{answer.question}
-
-
-
{answer.answer || '(응답 없음)'}
-
-
- ))}
-
-
-
-
- {isImageOpen && application.feePaymentImageUrl && (
-
-
-

e.stopPropagation()}
- />
-
-
- )}
-
- );
+ return
;
}
export default ManagedMemberApplicationDetail;
diff --git a/src/pages/Manager/ManagedMemberList/index.tsx b/src/pages/Manager/ManagedMemberList/index.tsx
index 45f323c..57b374a 100644
--- a/src/pages/Manager/ManagedMemberList/index.tsx
+++ b/src/pages/Manager/ManagedMemberList/index.tsx
@@ -1,61 +1,240 @@
-import { useState, useMemo } from 'react';
+import { type MouseEvent, type ReactNode, useMemo, useRef, useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import type { ClubMember, PositionType, PreMember } from '@/apis/club/entity';
import CheckIcon from '@/assets/svg/check.svg';
+import MoreHorizontalIcon from '@/assets/svg/more-horizontal.svg';
+import RoleSelectorArrowDownIcon from '@/assets/svg/role-selector-arrow-down.svg';
import BottomModal from '@/components/common/BottomModal';
-import Card from '@/components/common/Card';
+import Portal from '@/components/common/Portal';
+import { useToastContext } from '@/contexts/useToastContext';
import UserInfoCard from '@/pages/User/MyPage/components/UserInfoCard';
import useBooleanState from '@/utils/hooks/useBooleanState';
+import useClickTouchOutside from '@/utils/hooks/useClickTouchOutside';
import {
+ memberQueryKeys,
useAddPreMember,
useChangeMemberPosition,
useChangeVicePresident,
- useGetPreMemberList,
useDeletePreMember,
+ useGetPreMemberList,
useManagedMembers,
useRemoveMember,
useTransferPresident,
} from '../hooks/useManagedMembers';
-const POSITION_PRIORITY: Record
= {
- PRESIDENT: 0,
- VICE_PRESIDENT: 1,
- MANAGER: 2,
- MEMBER: 3,
-};
-
const POSITION_LABELS: Record = {
PRESIDENT: '회장',
VICE_PRESIDENT: '부회장',
MANAGER: '운영진',
- MEMBER: '일반 회원',
+ MEMBER: '부원',
};
const PROTECTED_POSITIONS = new Set(['PRESIDENT', 'VICE_PRESIDENT']);
+const ACTION_MENU_WIDTH = 195;
+const ACTION_MENU_OFFSET = 8;
+const ACTION_MENU_MARGIN = 16;
+const ROLE_OPTIONS = [
+ { label: '회장', value: 'PRESIDENT' },
+ { label: '부회장', value: 'VICE_PRESIDENT' },
+ { label: '운영진', value: 'MANAGER' },
+] as const;
+
+type RoleManageOption = (typeof ROLE_OPTIONS)[number]['value'];
+
+interface MenuAnchor {
+ bottom: number;
+ right: number;
+ top: number;
+}
-function groupMembers(members: ClubMember[]) {
- const grouped = new Map();
- for (const member of members) {
- const pos = member.position;
- if (!grouped.has(pos)) {
- grouped.set(pos, []);
- }
- grouped.get(pos)!.push(member);
- }
- const sorted = [...grouped.entries()].sort(([a], [b]) => POSITION_PRIORITY[a] - POSITION_PRIORITY[b]);
- return sorted;
+interface PopupMenuItem {
+ label: string;
+ onClick: () => void;
+ tone?: 'danger' | 'default';
+}
+
+function RoleManageSelector({
+ onChange,
+ value,
+}: {
+ onChange: (value: RoleManageOption) => void;
+ value: RoleManageOption;
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+ const selectorRef = useRef(null);
+
+ useClickTouchOutside(selectorRef, () => setIsOpen(false));
+
+ const selectedOption = ROLE_OPTIONS.find((option) => option.value === value);
+
+ return (
+
+
setIsOpen((prev) => !prev)}
+ className="flex h-[29px] min-w-[72px] items-center rounded-full border border-[#A5B3C1] bg-white pr-2 pl-[18px]"
+ >
+ {selectedOption?.label}
+
+
+
+ {isOpen && (
+
+ {ROLE_OPTIONS.map((option, index) => (
+ {
+ onChange(option.value);
+ setIsOpen(false);
+ }}
+ className={`text-sub2 text-text-600 w-full px-3 py-[3px] text-left ${
+ index !== ROLE_OPTIONS.length - 1 ? 'border-b border-[#C6CFD8]' : ''
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function MemberAvatar({ name }: { name: string }) {
+ return (
+
+ {name.charAt(0)}
+
+ );
+}
+
+interface MemberCardProps {
+ disabled?: boolean;
+ name: string;
+ onAction?: (event: MouseEvent) => void;
+ positionLabel: string;
+ showAction?: boolean;
+ studentNumber: string;
+}
+
+function MemberCard({
+ disabled = false,
+ name,
+ onAction,
+ positionLabel,
+ showAction = false,
+ studentNumber,
+}: MemberCardProps) {
+ return (
+
+
+
+
+
+ {name} ({studentNumber})
+
+
{positionLabel}
+
+
+
+ {showAction && onAction && (
+
onAction(event)}
+ disabled={disabled}
+ aria-label={`${name} 관리`}
+ className="ml-3 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-indigo-300 disabled:opacity-50"
+ >
+
+
+ )}
+
+ );
+}
+
+function ActionPopupMenu({
+ anchor,
+ isOpen,
+ items,
+ onClose,
+}: {
+ anchor: MenuAnchor | null;
+ isOpen: boolean;
+ items: PopupMenuItem[];
+ onClose: () => void;
+}) {
+ if (!isOpen || !anchor) return null;
+
+ const popupHeight = 24 + items.length * 22 + Math.max(0, items.length - 1) * 8;
+ const left = Math.min(
+ Math.max(anchor.right - ACTION_MENU_WIDTH, ACTION_MENU_MARGIN),
+ window.innerWidth - ACTION_MENU_WIDTH - ACTION_MENU_MARGIN
+ );
+ const top =
+ anchor.bottom + ACTION_MENU_OFFSET + popupHeight <= window.innerHeight - ACTION_MENU_MARGIN
+ ? anchor.bottom + ACTION_MENU_OFFSET
+ : Math.max(ACTION_MENU_MARGIN, anchor.top - popupHeight - ACTION_MENU_OFFSET);
+
+ return (
+
+
+
event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ onTouchStart={(event) => event.stopPropagation()}
+ >
+
+ {items.map(({ label, onClick, tone = 'default' }) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+}
+
+function MemberSection({ title, children }: { children: ReactNode; title: string }) {
+ return (
+
+ );
}
function ManagedMemberList() {
const params = useParams();
const navigate = useNavigate();
+ const queryClient = useQueryClient();
const clubId = Number(params.clubId);
+ const { showToast } = useToastContext();
const { managedMemberList } = useManagedMembers(clubId);
const { mutate: transferPresident, isPending: isTransferring } = useTransferPresident(clubId);
const { mutate: changeVicePresident, isPending: isChangingVP } = useChangeVicePresident(clubId);
- const { mutate: changeMemberPosition, isPending: isChangingPosition } = useChangeMemberPosition(clubId);
+ const { mutateAsync: changeMemberPosition, isPending: isChangingPosition } = useChangeMemberPosition(clubId, {
+ invalidateOnSuccess: false,
+ showToastOnSuccess: false,
+ });
const { mutate: removeMember, isPending: isRemoving } = useRemoveMember(clubId);
const { mutate: addPreMember, isPending: isAdding } = useAddPreMember(clubId);
@@ -68,10 +247,8 @@ function ManagedMemberList() {
const [selectedMember, setSelectedMember] = useState(null);
const [selectedPreMember, setSelectedPreMember] = useState(null);
+ const { value: isRoleManageOpen, setTrue: openRoleManage, setFalse: closeRoleManage } = useBooleanState();
const { value: isActionOpen, setTrue: openAction, setFalse: closeAction } = useBooleanState();
- const { value: isTransferOpen, setTrue: openTransfer, setFalse: closeTransfer } = useBooleanState();
- const { value: isVPOpen, setTrue: openVP, setFalse: closeVP } = useBooleanState();
- const { value: isPositionOpen, setTrue: openPosition, setFalse: closePosition } = useBooleanState();
const { value: isRemoveOpen, setTrue: openRemove, setFalse: closeRemove } = useBooleanState();
const { value: isAddOpen, setTrue: openAdd, setFalse: closeAdd } = useBooleanState();
const {
@@ -85,60 +262,163 @@ function ManagedMemberList() {
setFalse: closePreMemberDelete,
} = useBooleanState();
- const [transferTarget, setTransferTarget] = useState(null);
- const [vpTarget, setVPTarget] = useState(null);
- const [selectedPosition, setSelectedPosition] = useState<'MANAGER' | 'MEMBER' | null>(null);
+ const [roleManageTarget, setRoleManageTarget] = useState('PRESIDENT');
+ const [selectedRoleUserIds, setSelectedRoleUserIds] = useState>(new Set());
const [newStudentNumber, setNewStudentNumber] = useState('');
const [newMemberName, setNewMemberName] = useState('');
+ const [actionMenuAnchor, setActionMenuAnchor] = useState(null);
+ const [preMemberActionMenuAnchor, setPreMemberActionMenuAnchor] = useState(null);
const members = managedMemberList.clubMembers;
-
- const groupedEntries = useMemo(() => groupMembers(members), [members]);
const total = members.length;
- const nonPresidentMembers = members.filter((m) => !PROTECTED_POSITIONS.has(m.position));
+ const protectedMembers = useMemo(
+ () => members.filter((member) => PROTECTED_POSITIONS.has(member.position)),
+ [members]
+ );
+ const currentPresident = useMemo(() => members.find((member) => member.position === 'PRESIDENT') ?? null, [members]);
+ const managerMembers = useMemo(() => members.filter((member) => member.position === 'MANAGER'), [members]);
+ const generalMembers = useMemo(() => members.filter((member) => member.position === 'MEMBER'), [members]);
+ const currentVicePresident = useMemo(
+ () => members.find((member) => member.position === 'VICE_PRESIDENT') ?? null,
+ [members]
+ );
+
+ const vicePresidentCandidates = useMemo(() => members.filter((member) => member.position !== 'PRESIDENT'), [members]);
+ const managerCandidates = useMemo(
+ () => members.filter((member) => !PROTECTED_POSITIONS.has(member.position)),
+ [members]
+ );
+
+ const roleManageMembers = useMemo(() => {
+ switch (roleManageTarget) {
+ case 'PRESIDENT':
+ return members;
+ case 'VICE_PRESIDENT':
+ return vicePresidentCandidates;
+ case 'MANAGER':
+ return managerCandidates;
+ }
+ }, [managerCandidates, members, roleManageTarget, vicePresidentCandidates]);
+
+ const getInitialRoleSelection = (target: RoleManageOption) => {
+ if (target === 'PRESIDENT') {
+ return new Set(currentPresident ? [currentPresident.userId] : []);
+ }
+
+ if (target === 'VICE_PRESIDENT') {
+ return new Set(currentVicePresident ? [currentVicePresident.userId] : []);
+ }
+
+ return new Set(managerMembers.map((member) => member.userId));
+ };
- const handleMemberAction = (member: ClubMember) => {
+ const handleMemberAction = (member: ClubMember, event: MouseEvent) => {
+ const { bottom, right, top } = event.currentTarget.getBoundingClientRect();
setSelectedMember(member);
+ setActionMenuAnchor({ bottom, right, top });
openAction();
};
- const handleOpenPositionChange = () => {
- closeAction();
- setSelectedPosition(null);
- openPosition();
+ const handleOpenRoleManage = () => {
+ setRoleManageTarget('PRESIDENT');
+ setSelectedRoleUserIds(getInitialRoleSelection('PRESIDENT'));
+ openRoleManage();
+ };
+
+ const handleChangeRoleManageTarget = (target: RoleManageOption) => {
+ setRoleManageTarget(target);
+ setSelectedRoleUserIds(getInitialRoleSelection(target));
};
const handleOpenRemove = () => {
closeAction();
+ setActionMenuAnchor(null);
openRemove();
};
const handleOpenMemberApplication = () => {
if (!selectedMember) return;
closeAction();
+ setActionMenuAnchor(null);
navigate(`${selectedMember.userId}/application`);
};
- const handleTransferPresident = () => {
- if (transferTarget === null) return;
- transferPresident({ newPresidentUserId: transferTarget });
- closeTransfer();
- };
+ const handleRoleMemberClick = (userId: number) => {
+ if (roleManageTarget === 'MANAGER') {
+ setSelectedRoleUserIds((prev) => {
+ const next = new Set(prev);
+
+ if (next.has(userId)) {
+ next.delete(userId);
+ } else {
+ next.add(userId);
+ }
+
+ return next;
+ });
+
+ return;
+ }
+
+ if (roleManageTarget === 'VICE_PRESIDENT') {
+ setSelectedRoleUserIds((prev) => {
+ if (prev.has(userId) && prev.size === 1) {
+ return new Set();
+ }
+
+ return new Set([userId]);
+ });
+
+ return;
+ }
- const handleChangeVP = () => {
- changeVicePresident({ vicePresidentUserId: vpTarget });
- closeVP();
+ setSelectedRoleUserIds(new Set([userId]));
};
- const handleChangePosition = () => {
- if (!selectedMember || selectedPosition === null) return;
- changeMemberPosition({
- userId: selectedMember.userId,
- data: { position: selectedPosition },
- });
- closePosition();
- setSelectedMember(null);
+ const handleSubmitRoleManage = async () => {
+ if (roleManageTarget === 'PRESIDENT') {
+ const nextPresidentId = selectedRoleUserIds.values().next().value as number | undefined;
+
+ if (!nextPresidentId || nextPresidentId === currentPresident?.userId) {
+ closeRoleManage();
+ return;
+ }
+
+ closeRoleManage();
+ transferPresident({ newPresidentUserId: nextPresidentId });
+ return;
+ }
+
+ if (roleManageTarget === 'VICE_PRESIDENT') {
+ const nextVicePresidentId = (selectedRoleUserIds.values().next().value as number | undefined) ?? null;
+
+ if (nextVicePresidentId === (currentVicePresident?.userId ?? null)) {
+ closeRoleManage();
+ return;
+ }
+
+ closeRoleManage();
+ changeVicePresident({ vicePresidentUserId: nextVicePresidentId });
+ return;
+ }
+
+ const currentManagerUserIds = new Set(managerMembers.map((member) => member.userId));
+ const promoteUserIds = [...selectedRoleUserIds].filter((userId) => !currentManagerUserIds.has(userId));
+ const demoteUserIds = [...currentManagerUserIds].filter((userId) => !selectedRoleUserIds.has(userId));
+
+ if (promoteUserIds.length === 0 && demoteUserIds.length === 0) {
+ closeRoleManage();
+ return;
+ }
+
+ await Promise.all(promoteUserIds.map((userId) => changeMemberPosition({ userId, data: { position: 'MANAGER' } })));
+
+ await Promise.all(demoteUserIds.map((userId) => changeMemberPosition({ userId, data: { position: 'MEMBER' } })));
+
+ await queryClient.invalidateQueries({ queryKey: memberQueryKeys.managedMembers(clubId) });
+ showToast('직책이 변경되었습니다');
+ closeRoleManage();
};
const handleRemoveMember = () => {
@@ -156,13 +436,16 @@ function ManagedMemberList() {
setNewMemberName('');
};
- const handlePreMemberAction = (member: PreMember) => {
+ const handlePreMemberAction = (member: PreMember, event: MouseEvent) => {
+ const { bottom, right, top } = event.currentTarget.getBoundingClientRect();
setSelectedPreMember(member);
+ setPreMemberActionMenuAnchor({ bottom, right, top });
openPreMemberAction();
};
const handleOpenPreMemberDelete = () => {
closePreMemberAction();
+ setPreMemberActionMenuAnchor(null);
openPreMemberDelete();
};
@@ -174,270 +457,210 @@ function ManagedMemberList() {
};
return (
-
-
-
-
-
- 총 부원 수 : {total}명
-
-
-
-
- 회장 위임
-
-
- 부회장 변경
-
-
- 부원 추가
-
-
-
- {groupedEntries.map(([position, groupMembers]) => (
-
-
{POSITION_LABELS[position]}
- {groupMembers.map((member) => (
-
-
- {/*

*/}
-
- {member.name.charAt(0)}
-
-
-
- {member.name} ({member.studentNumber})
-
-
{POSITION_LABELS[member.position]}
-
-
- {!PROTECTED_POSITIONS.has(position) && (
- handleMemberAction(member)}
- disabled={isPending}
- className="hover:bg-indigo-5 rounded-full p-2 text-indigo-400 disabled:opacity-50"
- >
- ⋯
-
- )}
-
- ))}
-
- ))}
-
- {preMembersList.preMembers.length > 0 && (
-
-
사전 등록 회원
- {preMembersList.preMembers.map((member) => (
-
-
-
- {member.name.charAt(0)}
-
-
-
- {member.name} ({member.studentNumber})
-
-
사전 등록
-
-
- handlePreMemberAction(member)}
- disabled={isPending}
- className="hover:bg-indigo-5 rounded-full p-2 text-indigo-400 disabled:opacity-50"
- >
- ⋯
-
-
- ))}
-
- )}
-
-
- {/* Member Action Modal */}
-
-
-
{selectedMember?.name} 관리
-
- 지원서 보기
-
-
- 직책 변경
-
-
- 부원 추방
-
-
-
+
+
- {/* Transfer President Modal */}
-
-
-
회장 위임
-
새 회장을 선택해주세요.
-
- {nonPresidentMembers.map((member) => {
- const isSelected = transferTarget === member.userId;
- return (
-
setTransferTarget(member.userId)}
- className={`flex items-center justify-between rounded-lg p-2 ${
- isSelected ? 'bg-indigo-5' : 'active:bg-indigo-5'
- }`}
- >
-
- {/*

*/}
-
- {member.name.charAt(0)}
-
-
- {member.name} ({member.studentNumber})
-
-
- {isSelected && }
-
- );
- })}
+
+
+
+ 총 부원수 : {total}명
-
- {isTransferring ? '위임 중...' : '확인'}
-
-
-
- {/* Change Vice President Modal */}
-
-
-
부회장 변경
-
새 부회장을 선택하거나 해제해주세요.
-
+
setVPTarget(null)}
- className={`flex items-center justify-between rounded-lg p-2 ${
- vpTarget === null ? 'bg-indigo-5' : 'active:bg-indigo-5'
- }`}
+ onClick={handleOpenRoleManage}
+ disabled={isPending}
+ className="border-indigo-5 flex-1 rounded-2xl border bg-[#69BFDF] px-4 py-1.5 text-[15px] leading-6 font-semibold text-white disabled:opacity-50"
+ >
+ 직책 변경
+
+
- 부회장 해제
- {vpTarget === null && }
+ 부원 추가
- {nonPresidentMembers.map((member) => {
- const isSelected = vpTarget === member.userId;
- return (
-
+
+
+
+ {protectedMembers.length > 0 && (
+
+ {protectedMembers.map((member) => (
+
setVPTarget(member.userId)}
- className={`flex items-center justify-between rounded-lg p-2 ${
- isSelected ? 'bg-indigo-5' : 'active:bg-indigo-5'
- }`}
- >
-
- {/*

*/}
-
- {member.name.charAt(0)}
-
-
- {member.name} ({member.studentNumber})
+ name={member.name}
+ positionLabel={POSITION_LABELS[member.position]}
+ studentNumber={member.studentNumber}
+ />
+ ))}
+
+ )}
+
+ {managerMembers.length > 0 && (
+
+ {managerMembers.map((member) => (
+ handleMemberAction(member, event)}
+ positionLabel={POSITION_LABELS[member.position]}
+ showAction
+ studentNumber={member.studentNumber}
+ />
+ ))}
+
+ )}
+
+ {generalMembers.length > 0 && (
+
+ {generalMembers.map((member) => (
+ handleMemberAction(member, event)}
+ positionLabel={POSITION_LABELS[member.position]}
+ showAction
+ studentNumber={member.studentNumber}
+ />
+ ))}
+
+ )}
+
+ {preMembersList.preMembers.length > 0 && (
+
+ {preMembersList.preMembers.map((member) => (
+ handlePreMemberAction(member, event)}
+ positionLabel="사전 등록"
+ showAction
+ studentNumber={member.studentNumber}
+ />
+ ))}
+
+ )}
+
+
+
+
{
+ closeAction();
+ setActionMenuAnchor(null);
+ }}
+ items={[
+ {
+ label: '지원서 보기',
+ onClick: handleOpenMemberApplication,
+ },
+ {
+ label: '부원 삭제',
+ onClick: handleOpenRemove,
+ tone: 'danger',
+ },
+ ]}
+ />
+
+ {
+ closePreMemberAction();
+ setPreMemberActionMenuAnchor(null);
+ }}
+ items={[
+ {
+ label: '사전 등록 삭제',
+ onClick: handleOpenPreMemberDelete,
+ tone: 'danger',
+ },
+ ]}
+ />
+
+
+
+
+
직책 변경
+
+
+
+
+
+
+
+
+ {roleManageMembers.map((member) => {
+ const isSelected = selectedRoleUserIds.has(member.userId);
+
+ return (
+
handleRoleMemberClick(member.userId)}
+ className="flex w-full items-center gap-3 text-left"
+ >
+
+
+
+ {member.name} ({member.studentNumber})
+
-
- {isSelected &&
}
-
- );
- })}
+ {isSelected &&
}
+
+ );
+ })}
+
+
+
+ void handleSubmitRoleManage()}
+ disabled={isPending}
+ className="text-h2 h-12 w-full rounded-2xl bg-[#69BFDF] text-white disabled:opacity-50"
+ >
+ 완료
+
+
-
- {isChangingVP ? '변경 중...' : '확인'}
-
- {/* Change Position Modal */}
-
-
-
{selectedMember?.name} 직책 변경
-
- {(['MANAGER', 'MEMBER'] as const).map((position) => {
- const isSelected = selectedPosition === position;
- return (
-
setSelectedPosition(position)}
- className={`flex items-center justify-between rounded-lg p-2 ${
- isSelected ? 'bg-indigo-5' : 'active:bg-indigo-5'
- }`}
- >
-
- {POSITION_LABELS[position]}
-
- {isSelected && }
-
- );
- })}
+
+
+
+
부원 삭제
-
- {isChangingPosition ? '변경 중...' : '확인'}
-
-
-
- {/* Remove Member Confirm Modal */}
-
-
-
부원 추방
-
정말 {selectedMember?.name}님을 추방하시겠어요?
-
+
+
+ 정말 {selectedMember?.name}님을 삭제하시겠어요?
+
+
+
+
취소
@@ -445,65 +668,74 @@ function ManagedMemberList() {
type="button"
onClick={handleRemoveMember}
disabled={isPending}
- className="flex-1 rounded-lg bg-red-500 py-3 text-center font-bold text-white disabled:opacity-50"
+ className="h-[55px] flex-1 rounded-2xl border border-[#69BFDF] bg-[#69BFDF] text-center text-[16px] leading-[22px] font-bold tracking-[-0.408px] text-white disabled:opacity-50"
>
- {isRemoving ? '추방 중...' : '추방'}
+ {isRemoving ? '삭제 중...' : '삭제'}
- {/* Add Member Modal */}
-
-
-
부원 추가
-
서비스에 가입하지 않은 학생을 사전 등록합니다.
-
-
-
setNewStudentNumber(e.target.value.replace(/\D/g, ''))}
- placeholder="학번을 입력해주세요"
- className="border-indigo-25 rounded-lg border px-3 py-2 text-sm outline-none focus:border-blue-500"
- />
+
+
+
-
-
-
setNewMemberName(e.target.value)}
- placeholder="이름을 입력해주세요"
- className="border-indigo-25 rounded-lg border px-3 py-2 text-sm outline-none focus:border-blue-500"
- />
+
+
+
+
부원 추가하기
+
서비스에 가입하지 않은 학생을 사전 등록해주세요.
+
+
+
+
+ setNewStudentNumber(e.target.value.replace(/\D/g, ''))}
+ placeholder="학번을 입력해주세요."
+ className="text-sub3 h-9 rounded-2xl border border-[#C6CFD8] px-3 text-indigo-700 outline-none placeholder:text-[#8497AA] focus:border-[#69BFDF]"
+ />
+
+
+
+
+ setNewMemberName(e.target.value)}
+ placeholder="이름을 입력해주세요."
+ className="text-sub3 h-9 rounded-2xl border border-[#C6CFD8] px-3 text-indigo-700 outline-none placeholder:text-[#8497AA] focus:border-[#69BFDF]"
+ />
+
-
- {isAdding ? '추가 중...' : '추가'}
-
-
-
- {/* Pre-Member Action Modal */}
-
-
-
{selectedPreMember?.name} 관리
-
- 사전 등록 삭제
-
+
+
+ {isAdding ? '추가 중...' : '추가'}
+
+
- {/* Pre-Member Delete Confirm Modal */}
사전 등록 삭제
diff --git a/src/pages/Manager/ManagedRecruitment/index.tsx b/src/pages/Manager/ManagedRecruitment/index.tsx
index 1ddfcd1..658adae 100644
--- a/src/pages/Manager/ManagedRecruitment/index.tsx
+++ b/src/pages/Manager/ManagedRecruitment/index.tsx
@@ -1,44 +1,15 @@
-import { useNavigate, useParams } from 'react-router-dom';
-import CreditCardSmIcon from '@/assets/svg/credit-card-sm.svg';
-import CreditCardIcon from '@/assets/svg/credit-card.svg';
-import FileSmIcon from '@/assets/svg/file-sm.svg';
-import FileIcon from '@/assets/svg/file.svg';
-import MegaphoneSmIcon from '@/assets/svg/megaphone-sm.svg';
-import MegaphoneIcon from '@/assets/svg/megaphone.svg';
+import { Link, useParams } from 'react-router-dom';
+import cardIcon from '@/assets/image/3d-card.png';
+import fileIcon from '@/assets/image/3d-file.png';
+import flagIcon from '@/assets/image/3d-flag.png';
+import ChevronRightIcon from '@/assets/svg/chevron-right.svg';
+import Card from '@/components/common/Card';
import UserInfoCard from '@/pages/User/MyPage/components/UserInfoCard';
-import ToggleSwitch from '../../../components/common/ToggleSwitch';
-import { useGetClubSettings, usePatchClubSettings } from '../hooks/useManagedSettings';
-import StatusCard from './components/StatusCard';
+import { useGetClubSettings } from '../hooks/useManagedSettings';
function ManagedRecruitment() {
const { clubId } = useParams<{ clubId: string }>();
- const navigate = useNavigate();
const { data: settings } = useGetClubSettings(Number(clubId));
- const { mutate: patchSettings, isPending: isPatchingSettings } = usePatchClubSettings(Number(clubId));
-
- const handleRecruitmentToggle = (value: boolean) => {
- if (value && !settings?.recruitment) {
- navigate('write', { state: { enableAfterSave: true } });
- return;
- }
- patchSettings({ isRecruitmentEnabled: value });
- };
-
- const handleApplicationToggle = (value: boolean) => {
- if (value && (!settings?.application || settings.application.questionCount === 0)) {
- navigate('form', { state: { enableAfterSave: true } });
- return;
- }
- patchSettings({ isApplicationEnabled: value });
- };
-
- const handleFeeToggle = (value: boolean) => {
- if (value && !settings?.fee) {
- navigate('account', { state: { enableAfterSave: true } });
- return;
- }
- patchSettings({ isFeeEnabled: value });
- };
const recruitmentContent = (() => {
if (!settings?.isRecruitmentEnabled) return '모집공고가 비활성화되어 있습니다.';
@@ -59,42 +30,27 @@ function ManagedRecruitment() {
return `${settings.fee.amount} / ${settings.fee.bankName}`;
})();
+ const rows = [
+ { icon: flagIcon, title: '모집 공고', content: recruitmentContent, to: 'write' },
+ { icon: fileIcon, title: '지원서', content: applicationContent, to: 'form' },
+ { icon: cardIcon, title: '회비', content: feeContent, to: 'account' },
+ ];
+
return (
-
+
-
-
-
-
-
-
+
+ {rows.map(({ icon, title, content, to }) => (
+
+
+
+
+
+ ))}
+
);
}
diff --git a/src/pages/Manager/ManagedRecruitmentForm/index.tsx b/src/pages/Manager/ManagedRecruitmentForm/index.tsx
index 3c4da2d..0612907 100644
--- a/src/pages/Manager/ManagedRecruitmentForm/index.tsx
+++ b/src/pages/Manager/ManagedRecruitmentForm/index.tsx
@@ -1,30 +1,39 @@
import { useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
-import type { ClubQuestionRequest } from '@/apis/club/entity';
-import TrashIcon from '@/assets/svg/trash-full.svg';
+import type { ClubQuestion, ClubQuestionRequest } from '@/apis/club/entity';
+import CheckIcon from '@/assets/svg/check.svg';
+import ToggleSwitch from '@/components/common/ToggleSwitch';
import { useManagedClubQuestions, useManagedClubQuestionsMutation } from '@/pages/Manager/hooks/useManagedRecruitment';
-import { usePatchClubSettings } from '@/pages/Manager/hooks/useManagedSettings';
+import { useGetClubSettings, usePatchClubSettings } from '@/pages/Manager/hooks/useManagedSettings';
+import { cn } from '@/utils/ts/cn';
interface QuestionItem extends ClubQuestionRequest {
tempId: string;
}
+const createQuestionItem = ({ id, question, isRequired }: ClubQuestion): QuestionItem => ({
+ tempId: crypto.randomUUID(),
+ questionId: id,
+ question,
+ isRequired,
+});
+
+const sectionCardStyle = 'flex w-full flex-col rounded-2xl bg-white px-5 py-5';
+const sectionTitleStyle = 'text-[16px] leading-[1.6] font-semibold text-indigo-700';
+
function ManagedRecruitmentForm() {
const { clubId } = useParams<{ clubId: string }>();
+ const clubIdNumber = Number(clubId);
const navigate = useNavigate();
const location = useLocation();
- const { managedClubQuestions } = useManagedClubQuestions(Number(clubId));
- const { mutate: updateQuestions, isPending, error } = useManagedClubQuestionsMutation(Number(clubId));
- const { mutate: patchSettings } = usePatchClubSettings(Number(clubId));
+ const { managedClubQuestions } = useManagedClubQuestions(clubIdNumber);
+ const { data: clubSettings } = useGetClubSettings(clubIdNumber);
+ const { mutate: updateQuestions, isPending, error } = useManagedClubQuestionsMutation(clubIdNumber);
+ const { mutate: patchSettings, isPending: isPatchPending } = usePatchClubSettings(clubIdNumber);
const [questions, setQuestions] = useState
(() =>
- managedClubQuestions.questions.map((q) => ({
- tempId: crypto.randomUUID(),
- questionId: q.id,
- question: q.question,
- isRequired: q.isRequired,
- }))
+ managedClubQuestions.questions.map(createQuestionItem)
);
const handleAddQuestion = () => {
@@ -50,6 +59,17 @@ function ManagedRecruitmentForm() {
setQuestions((prev) => prev.map((q) => (q.tempId === tempId ? { ...q, isRequired: checked } : q)));
};
+ const hasEmptyQuestion = questions.some((q) => !q.question.trim());
+ const isApplicationToggleDisabled = hasEmptyQuestion || isPending || isPatchPending;
+
+ const handleApplicationEnabledChange = (enabled: boolean) => {
+ if (isApplicationToggleDisabled) {
+ return;
+ }
+
+ patchSettings({ isApplicationEnabled: enabled });
+ };
+
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -70,95 +90,111 @@ function ManagedRecruitmentForm() {
});
};
- const hasEmptyQuestion = questions.some((q) => !q.question.trim());
+ const isApplicationEnabled = clubSettings?.isApplicationEnabled ?? false;
+ const applicationStatusLabel = isApplicationEnabled ? '활성화' : '비활성화';
return (