diff --git a/public/icons/seal-check.svg b/public/icons/seal-check.svg new file mode 100644 index 00000000..57512572 --- /dev/null +++ b/public/icons/seal-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/features/admin/api/member-list.ts b/src/features/admin/api/member-list.ts index 064ffedd..6c5251ac 100644 --- a/src/features/admin/api/member-list.ts +++ b/src/features/admin/api/member-list.ts @@ -1,6 +1,12 @@ import { axiosInstance } from '@/shared/tanstack-query/axios'; -import { GetMemberListRequest, GetMemberListResponse } from './types'; +import { + ChangeMemberRoleRequest, + ChangeMemberStatusRequest, + GetMemberListRequest, + GetMemberListResponse, +} from './types'; +// 사용자 목록 조회 export const getMemberList = async ({ roleId, memberStatus, @@ -24,3 +30,27 @@ export const getMemberList = async ({ return res.data.content; }; + +// 사용자 계정 상태 변경 +export const changeMemberStatus = async ({ + memberId, + to, +}: ChangeMemberStatusRequest) => { + const res = await axiosInstance.patch( + `/admin/members/${memberId}/status?to=${to}`, + ); + + return res.data; +}; + +// 사용자 권한 변경 +export const changeMemberRole = async ({ + memberId, + roleId, +}: ChangeMemberRoleRequest) => { + const res = await axiosInstance.patch( + `/admin/members/${memberId}/role?role-id=${roleId}`, + ); + + return res.data; +}; diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts index 9f4d17a9..071def3c 100644 --- a/src/features/admin/api/types.ts +++ b/src/features/admin/api/types.ts @@ -28,3 +28,13 @@ export interface GetMemberListResponse { }; }[]; } + +export interface ChangeMemberStatusRequest { + memberId: number; + to: MemberStatus; +} + +export interface ChangeMemberRoleRequest { + memberId: number; + roleId: RoleId; +} diff --git a/src/features/admin/const/member.ts b/src/features/admin/const/member.ts new file mode 100644 index 00000000..9c269162 --- /dev/null +++ b/src/features/admin/const/member.ts @@ -0,0 +1,24 @@ +export const ROLE_MAP = { + ROLE_MEMBER: '일반', + ROLE_MENTOR: '멘토', + ROLE_ADMIN: '관리자', +}; + +export const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ + value: key, + label, +})); + +export const MEMBER_STATUS_MAP = { + ACTIVE: '활성', + PERM_BAN: '일시정지', + PAUSED: '영구정지', + DORMANT: '휴면', +}; + +export const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( + ([key, label]) => ({ + value: key, + label, + }), +); diff --git a/src/features/admin/model/use-member-list-query.ts b/src/features/admin/model/use-member-list-query.ts index 762ce211..38422eb3 100644 --- a/src/features/admin/model/use-member-list-query.ts +++ b/src/features/admin/model/use-member-list-query.ts @@ -1,7 +1,17 @@ -import { useQuery } from '@tanstack/react-query'; -import { getMemberList } from '../api/member-list'; -import { GetMemberListRequest } from '../api/types'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + changeMemberRole, + changeMemberStatus, + getMemberList, +} from '../api/member-list'; +import { + ChangeMemberRoleRequest, + ChangeMemberStatusRequest, + GetMemberListRequest, + GetMemberListResponse, +} from '../api/types'; +// 사용자 목록 조회 export const useGetMemberListQuery = ({ roleId, memberStatus, @@ -19,3 +29,99 @@ export const useGetMemberListQuery = ({ }), }); }; + +// 사용자 계정 상태 변경 +export const useChangeMemberStatusMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { + members: GetMemberListResponse['content']; + to: ChangeMemberStatusRequest['to']; + }) => { + // 병렬로 선택한 모든 사용자의 상태 변경 요청을 보냄 + // 성공: {status: "fulfilled", value: {memberId, success: true}} + // 실패: {status: "fulfilled", value: {memberId, success: false}} (개별 요청 실패는 catch에서 처리했기 때문에 status가 rejected가 되지 않음) + const results: PromiseSettledResult<{ + memberId: number; + success: boolean; + }>[] = await Promise.allSettled( + data.members.map((member) => + changeMemberStatus({ memberId: member.memberId, to: data.to }) + .then(() => ({ memberId: member.memberId, success: true })) + .catch(() => ({ memberId: member.memberId, success: false })), + ), + ); + + const failedMemberIds = results + .filter( + (result) => result.status === 'fulfilled' && !result.value.success, + ) + .map((result) => + result.status === 'fulfilled' ? result.value.memberId : null, + ) + .filter(Boolean); + + if (failedMemberIds.length > 0) { + const failedMemberNames = data.members + .filter((member) => failedMemberIds.includes(member.memberId)) + .map((member) => member.memberName); + + alert( + `다음 회원들의 상태 변경에 실패했습니다: ${failedMemberNames.join(', ')}`, + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['memberList'] }); + }, + }); +}; + +// 사용자 권한 변경 +export const useChangeMemberRoleMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { + members: GetMemberListResponse['content']; + roleId: ChangeMemberRoleRequest['roleId']; + }) => { + // 병렬로 선택한 모든 사용자의 상태 변경 요청을 보냄 + // 성공: {status: "fulfilled", value: {memberId, success: true}} + // 실패: {status: "fulfilled", value: {memberId, success: false}} (개별 요청 실패는 catch에서 처리했기 때문에 status가 rejected가 되지 않음) + const results: PromiseSettledResult<{ + memberId: number; + success: boolean; + }>[] = await Promise.allSettled( + data.members.map((member) => + changeMemberRole({ memberId: member.memberId, roleId: data.roleId }) + .then(() => ({ memberId: member.memberId, success: true })) + .catch(() => ({ memberId: member.memberId, success: false })), + ), + ); + + const failedMemberIds = results + .filter( + (result) => result.status === 'fulfilled' && !result.value.success, + ) + .map((result) => + result.status === 'fulfilled' ? result.value.memberId : null, + ) + .filter(Boolean); + + if (failedMemberIds.length > 0) { + const failedMemberNames = data.members + .filter((member) => failedMemberIds.includes(member.memberId)) + .map((member) => member.memberName); + + alert( + `다음 회원들의 권한 변경에 실패했습니다: ${failedMemberNames.join(', ')}`, + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['memberList'] }); + }, + }); +}; diff --git a/src/features/admin/ui/chage-status-modal.tsx b/src/features/admin/ui/chage-status-modal.tsx new file mode 100644 index 00000000..18d2cb13 --- /dev/null +++ b/src/features/admin/ui/chage-status-modal.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; +import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; +import { GetMemberListResponse, MemberStatus } from '../api/types'; +import { MEMBER_STATUS_OPTIONS } from '../const/member'; +import { useChangeMemberStatusMutation } from '../model/use-member-list-query'; + +interface ChangeStatusModalProps { + members: GetMemberListResponse['content']; +} + +export default function ChangeStatusModal({ members }: ChangeStatusModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + 상태 변경 + + + + + + + + + 계정 상태 변경 + + setOpen(false)}> + + + + + setOpen(false)} /> + + + + ); +} + +function ChangeStatusForm({ + members, + onClose, +}: { + members: GetMemberListResponse['content']; + onClose: () => void; +}) { + const INIT_STATUS: MemberStatus = 'ACTIVE'; + const [status, setStatus] = useState(INIT_STATUS); + + const { mutate: changeStatus } = useChangeMemberStatusMutation(); + + const handleChangeStatus = () => { + changeStatus( + { members, to: status }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + return ( + <> + + setStatus(status)} + > + {MEMBER_STATUS_OPTIONS.map(({ value, label }) => ( + + + {label} + + ))} + + + + + + + 취소 + + + + 변경하기 + + + > + ); +} diff --git a/src/features/admin/ui/change-role-modal.tsx b/src/features/admin/ui/change-role-modal.tsx new file mode 100644 index 00000000..b7751d8a --- /dev/null +++ b/src/features/admin/ui/change-role-modal.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; +import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; +import { GetMemberListResponse, RoleId } from '../api/types'; +import { ROLE_OPTIONS } from '../const/member'; +import { useChangeMemberRoleMutation } from '../model/use-member-list-query'; + +interface ChangeRoleModalProps { + members: GetMemberListResponse['content']; +} + +export default function ChangeRoleModal({ members }: ChangeRoleModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + 권한 변경 + + + + + + + + + 권한 변경 + + setOpen(false)}> + + + + + setOpen(false)} /> + + + + ); +} + +function ChangeRoleForm({ + members, + onClose, +}: { + members: GetMemberListResponse['content']; + onClose: () => void; +}) { + const INIT_ROLE: RoleId = 'ROLE_MEMBER'; + const [role, setRole] = useState(INIT_ROLE); + + const { mutate: changeStatus } = useChangeMemberRoleMutation(); + + const handleChangeStatus = () => { + changeStatus( + { members, roleId: role }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + return ( + <> + + setRole(roleId)} + > + {ROLE_OPTIONS.map(({ value, label }) => ( + + + {label} + + ))} + + + + + + + 취소 + + + + 변경하기 + + + > + ); +} diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx index d244a28f..f6b135c3 100644 --- a/src/features/admin/ui/member-list-table.tsx +++ b/src/features/admin/ui/member-list-table.tsx @@ -8,33 +8,19 @@ import Checkbox from '@/shared/ui/checkbox'; import { SingleDropdown } from '@/shared/ui/dropdown'; import Pagination from '@/shared/ui/pagination'; import FilledX from 'public/icons/filled-x.svg'; +import SealCheckIcon from 'public/icons/seal-check.svg'; import SearchIcon from 'public/icons/search.svg'; +import ChangeStatusModal from './chage-status-modal'; +import ChangeRoleModal from './change-role-modal'; import { MemberStatus, RoleId } from '../api/types'; +import { + MEMBER_STATUS_MAP, + MEMBER_STATUS_OPTIONS, + ROLE_MAP, + ROLE_OPTIONS, +} from '../const/member'; import { useGetMemberListQuery } from '../model/use-member-list-query'; -const ROLE_MAP = { - ROLE_MEMBER: '일반', - ROLE_MENTOR: '멘토', - ROLE_ADMIN: '관리자', -}; -const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ - value: key, - label, -})); - -const MEMBER_STATUS_MAP = { - ACTIVE: '활성', - PERM_BAN: '일시정지', - PAUSED: '영구정지', - DORMANT: '휴면', -}; -const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( - ([key, label]) => ({ - value: key, - label, - }), -); - export default function MemberListTable() { const [roleId, setRoleId] = useState(null); const [memberStatus, setMemberStatus] = useState(null); @@ -50,7 +36,7 @@ export default function MemberListTable() { const memberList = data?.content || []; - const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); const headerCheckboxRef = useRef(null); const allSelected = @@ -102,7 +88,34 @@ export default function MemberListTable() { onChange={setSearchKeyword} /> - + + + + {someSelected && ( + + + + {selectedIds.size} + + 명 선택 + + + + + selectedIds.has(member.memberId), + )} + /> + + selectedIds.has(member.memberId), + )} + /> + + + )} + + ( {formatYYYYMMDD(user.loginMostRecentlyAt)} - + + {user.role.roleId === 'ROLE_MENTOR' && } {ROLE_MAP[user.role.roleId]} @@ -207,7 +225,7 @@ function MemberListFilter({ onSelectMemberStatus: (memberStatus: MemberStatus | null) => void; }) { return ( - <> + {(roleId || memberStatus) && ( - > + ); }
+ + {selectedIds.size} + + 명 선택 +