Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions public/icons/seal-check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 31 additions & 1 deletion src/features/admin/api/member-list.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
};
10 changes: 10 additions & 0 deletions src/features/admin/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ export interface GetMemberListResponse {
};
}[];
}

export interface ChangeMemberStatusRequest {
memberId: number;
to: MemberStatus;
}

export interface ChangeMemberRoleRequest {
memberId: number;
roleId: RoleId;
}
24 changes: 24 additions & 0 deletions src/features/admin/const/member.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
112 changes: 109 additions & 3 deletions src/features/admin/model/use-member-list-query.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'] });
},
});
};
98 changes: 98 additions & 0 deletions src/features/admin/ui/chage-status-modal.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);

return (
<Modal.Root open={open} onOpenChange={setOpen}>
<Modal.Trigger asChild>
<Button size="small" color="secondary">
상태 변경
</Button>
</Modal.Trigger>

<Modal.Portal>
<Modal.Overlay />
<Modal.Content size="small" className="w-[390px]">
<Modal.Header className="border-border-default flex justify-between border-b">
<Modal.Title className="font-designer-20b text-text-strong">
계정 상태 변경
</Modal.Title>
<Modal.Close onClick={() => setOpen(false)}>
<XIcon />
</Modal.Close>
</Modal.Header>

<ChangeStatusForm members={members} onClose={() => setOpen(false)} />
</Modal.Content>
</Modal.Portal>
</Modal.Root>
);
}

function ChangeStatusForm({
members,
onClose,
}: {
members: GetMemberListResponse['content'];
onClose: () => void;
}) {
const INIT_STATUS: MemberStatus = 'ACTIVE';
const [status, setStatus] = useState<MemberStatus>(INIT_STATUS);

const { mutate: changeStatus } = useChangeMemberStatusMutation();

const handleChangeStatus = () => {
changeStatus(
{ members, to: status },
{
onSuccess: () => {
onClose();
},
},
);
};

return (
<>
<Modal.Body className="flex flex-col items-center gap-150 p-400">
<RadioGroup
className="w-full"
defaultValue={INIT_STATUS}
onValueChange={(status: MemberStatus) => setStatus(status)}
>
{MEMBER_STATUS_OPTIONS.map(({ value, label }) => (
<div className="flex h-[48px] items-center gap-100" key={value}>
<RadioGroupItem value={value} id={value} />
<label htmlFor={value}>{label}</label>
</div>
))}
</RadioGroup>
</Modal.Body>

<Modal.Footer className="flex justify-end gap-100">
<Modal.Close asChild>
<Button color="secondary" size="large" onClick={onClose}>
취소
</Button>
</Modal.Close>
<Button color="primary" size="large" onClick={handleChangeStatus}>
변경하기
</Button>
</Modal.Footer>
</>
);
}
Loading