From 50ba7ce17a78af86ceb9162d2a84948dfa71c47d Mon Sep 17 00:00:00 2001 From: mgYang53 Date: Mon, 9 Feb 2026 23:40:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모임장 전용 설정 페이지 추가 (정보 수정, 멤버 관리, 모임 삭제). - 모임 이름/설명 수정 및 저장 기능 - 멤버 관리 탭 (승인 대기/승인 완료) 및 승인·거절·강퇴 기능 - 모임 삭제 확인 모달 연동 - 멤버 목록 커서 기반 페이지네이션 지원 - 관련 API, 타입, 엔드포인트, 쿼리 훅 추가 --- .../gatherings/components/MemberCard.tsx | 81 +++++ src/features/gatherings/components/index.ts | 1 + src/features/gatherings/gatherings.api.ts | 79 +++++ .../gatherings/gatherings.endpoints.ts | 8 + src/features/gatherings/gatherings.types.ts | 57 +++ .../gatherings/hooks/gatheringQueryKeys.ts | 7 +- src/features/gatherings/hooks/index.ts | 6 + .../gatherings/hooks/useDeleteGathering.ts | 20 ++ .../gatherings/hooks/useGatheringMembers.ts | 48 +++ .../hooks/useGatheringSettingForm.ts | 46 +++ .../gatherings/hooks/useHandleJoinRequest.ts | 28 ++ .../gatherings/hooks/useRemoveMember.ts | 25 ++ .../gatherings/hooks/useUpdateGathering.ts | 26 ++ src/features/gatherings/index.ts | 7 + src/pages/Gatherings/GatheringSettingPage.tsx | 327 ++++++++++++++++++ src/pages/Gatherings/index.ts | 1 + src/routes/index.tsx | 5 + src/shared/constants/pagination.ts | 2 + 18 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 src/features/gatherings/components/MemberCard.tsx create mode 100644 src/features/gatherings/hooks/useDeleteGathering.ts create mode 100644 src/features/gatherings/hooks/useGatheringMembers.ts create mode 100644 src/features/gatherings/hooks/useGatheringSettingForm.ts create mode 100644 src/features/gatherings/hooks/useHandleJoinRequest.ts create mode 100644 src/features/gatherings/hooks/useRemoveMember.ts create mode 100644 src/features/gatherings/hooks/useUpdateGathering.ts create mode 100644 src/pages/Gatherings/GatheringSettingPage.tsx diff --git a/src/features/gatherings/components/MemberCard.tsx b/src/features/gatherings/components/MemberCard.tsx new file mode 100644 index 0000000..29dc421 --- /dev/null +++ b/src/features/gatherings/components/MemberCard.tsx @@ -0,0 +1,81 @@ +import { Button } from '@/shared/ui/Button' + +import type { GatheringMember } from '../gatherings.types' + +type MemberCardAction = 'approve' | 'reject' | 'remove' + +interface MemberCardProps { + member: GatheringMember + /** 표시할 날짜 문자열 (YY.MM.DD) */ + dateLabel?: string + /** 표시할 액션 버튼 목록 */ + actions: MemberCardAction[] + /** 액션 버튼 클릭 핸들러 */ + onAction: (action: MemberCardAction, member: GatheringMember) => void + /** 버튼 비활성화 여부 */ + disabled?: boolean +} + +export default function MemberCard({ + member, + dateLabel, + actions, + onAction, + disabled, +}: MemberCardProps) { + return ( +
+
+ {member.profileImageUrl ? ( + {member.nickname} + ) : ( +
+ {member.nickname.charAt(0)} +
+ )} +
+ {member.nickname} + {dateLabel && {dateLabel}} +
+
+
+ {actions.includes('reject') && ( + + )} + {actions.includes('approve') && ( + + )} + {actions.includes('remove') && ( + + )} +
+
+ ) +} diff --git a/src/features/gatherings/components/index.ts b/src/features/gatherings/components/index.ts index 43695be..227e9b9 100644 --- a/src/features/gatherings/components/index.ts +++ b/src/features/gatherings/components/index.ts @@ -6,3 +6,4 @@ export { default as GatheringDetailHeader } from './GatheringDetailHeader' export { default as GatheringDetailInfo } from './GatheringDetailInfo' export { default as GatheringMeetingCard } from './GatheringMeetingCard' export { default as GatheringMeetingSection } from './GatheringMeetingSection' +export { default as MemberCard } from './MemberCard' diff --git a/src/features/gatherings/gatherings.api.ts b/src/features/gatherings/gatherings.api.ts index 40c728c..824f29c 100644 --- a/src/features/gatherings/gatherings.api.ts +++ b/src/features/gatherings/gatherings.api.ts @@ -2,6 +2,7 @@ import { apiClient, type ApiResponse } from '@/api' import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints' import type { + ApproveType, CreateGatheringRequest, CreateGatheringResponse, FavoriteGatheringListResponse, @@ -11,8 +12,12 @@ import type { GatheringJoinResponse, GatheringListResponse, GatheringMeetingListResponse, + GatheringMemberListResponse, + GatheringUpdateRequest, + GatheringUpdateResponse, GetGatheringBooksParams, GetGatheringMeetingsParams, + GetGatheringMembersParams, GetGatheringsParams, MeetingTabCountsResponse, } from './gatherings.types' @@ -158,3 +163,77 @@ export const getMeetingTabCounts = async (gatheringId: number) => { ) return response.data } + +/** + * 모임 멤버 목록 조회 (커서 기반 무한 스크롤) + * + * @param params - 조회 파라미터 + * @returns 멤버 목록 및 페이지네이션 정보 + */ +export const getGatheringMembers = async (params: GetGatheringMembersParams) => { + const { gatheringId, ...queryParams } = params + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.MEMBERS(gatheringId), + { params: queryParams } + ) + return response.data +} + +/** + * 모임 정보 수정 + * + * @param gatheringId - 모임 ID + * @param data - 수정할 모임 정보 + * @returns 수정된 모임 정보 + */ +export const updateGathering = async (gatheringId: number, data: GatheringUpdateRequest) => { + const response = await apiClient.patch>( + GATHERINGS_ENDPOINTS.DETAIL(gatheringId), + data + ) + return response.data +} + +/** + * 모임 삭제 + * + * @param gatheringId - 모임 ID + */ +export const deleteGathering = async (gatheringId: number) => { + const response = await apiClient.delete>( + GATHERINGS_ENDPOINTS.DETAIL(gatheringId) + ) + return response.data +} + +/** + * 가입 요청 승인/거절 + * + * @param gatheringId - 모임 ID + * @param memberId - 처리할 멤버의 유저 ID + * @param approveType - 승인(ACTIVE) 또는 거절(REJECTED) + */ +export const handleJoinRequest = async ( + gatheringId: number, + memberId: number, + approveType: ApproveType +) => { + const response = await apiClient.patch>( + GATHERINGS_ENDPOINTS.HANDLE_JOIN_REQUEST(gatheringId, memberId), + { approve_type: approveType } + ) + return response.data +} + +/** + * 멤버 삭제(강퇴) + * + * @param gatheringId - 모임 ID + * @param userId - 강퇴할 유저 ID + */ +export const removeMember = async (gatheringId: number, userId: number) => { + const response = await apiClient.delete>( + GATHERINGS_ENDPOINTS.REMOVE_MEMBER(gatheringId, userId) + ) + return response.data +} diff --git a/src/features/gatherings/gatherings.endpoints.ts b/src/features/gatherings/gatherings.endpoints.ts index e85c4db..ee0b1d3 100644 --- a/src/features/gatherings/gatherings.endpoints.ts +++ b/src/features/gatherings/gatherings.endpoints.ts @@ -18,4 +18,12 @@ export const GATHERINGS_ENDPOINTS = { BOOKS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/books`, /** 약속 탭별 카운트 조회 */ MEETING_TAB_COUNTS: `${API_PATHS.MEETINGS}/tab-counts`, + /** 멤버 목록 조회 */ + MEMBERS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/members`, + /** 가입 요청 승인/거절 */ + HANDLE_JOIN_REQUEST: (gatheringId: number, memberId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/join-requests/${memberId}`, + /** 멤버 삭제(강퇴) */ + REMOVE_MEMBER: (gatheringId: number, userId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/members/${userId}`, } as const diff --git a/src/features/gatherings/gatherings.types.ts b/src/features/gatherings/gatherings.types.ts index c1390fb..0b6d212 100644 --- a/src/features/gatherings/gatherings.types.ts +++ b/src/features/gatherings/gatherings.types.ts @@ -112,6 +112,10 @@ export interface GatheringMember { profileImageUrl: string | null /** 역할 */ role: GatheringUserRole + /** 멤버 상태 (멤버 목록 API에서만 제공) */ + memberStatus?: GatheringMemberStatus + /** 가입일 (ISO 8601, 멤버 목록 API에서만 제공, PENDING이면 null) */ + joinedAt?: string | null } /** 모임 상세 응답 */ @@ -218,3 +222,56 @@ export interface GetGatheringBooksParams { /** 페이지 크기 */ size?: number } + +// ========== 모임 설정 (수정/삭제/멤버 관리) ========== + +/** 모임 수정 요청 */ +export interface GatheringUpdateRequest { + /** 모임 이름 (필수, 최대 12자, 공백만 불가) */ + gatheringName: string + /** 모임 설명 (선택, 최대 150자) */ + description?: string +} + +/** 모임 수정 응답 */ +export interface GatheringUpdateResponse { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 모임 설명 */ + description: string | null + /** 수정 일시 (ISO 8601) */ + updatedAt: string +} + +// ========== 모임 멤버 목록 조회 ========== + +/** 멤버 목록 필터 상태 (GatheringMemberStatus에서 파생) */ +export type MemberStatusFilter = Extract + +/** 멤버 목록 커서 */ +export interface GatheringMemberListCursor { + gatheringMemberId: number +} + +/** 멤버 목록 응답 (커서 기반 페이지네이션) */ +export type GatheringMemberListResponse = CursorPaginatedResponse< + GatheringMember, + GatheringMemberListCursor +> + +/** 멤버 목록 조회 파라미터 */ +export interface GetGatheringMembersParams { + /** 모임 ID */ + gatheringId: number + /** 멤버 상태 필터 */ + status: MemberStatusFilter + /** 페이지 크기 (기본: 10) */ + pageSize?: number + /** 커서 - 마지막 항목의 모임 멤버 ID */ + cursorId?: number +} + +/** 가입 요청 승인/거절 타입 (GatheringMemberStatus에서 파생) */ +export type ApproveType = Extract diff --git a/src/features/gatherings/hooks/gatheringQueryKeys.ts b/src/features/gatherings/hooks/gatheringQueryKeys.ts index 3c85d0f..a1128c8 100644 --- a/src/features/gatherings/hooks/gatheringQueryKeys.ts +++ b/src/features/gatherings/hooks/gatheringQueryKeys.ts @@ -1,4 +1,4 @@ -import type { MeetingFilter } from '../gatherings.types' +import type { MeetingFilter, MemberStatusFilter } from '../gatherings.types' /** * 모임 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수 @@ -17,6 +17,11 @@ export const gatheringQueryKeys = { [...gatheringQueryKeys.meetings(gatheringId), filter] as const, meetingTabCounts: (gatheringId: number) => [...gatheringQueryKeys.meetings(gatheringId), 'tabCounts'] as const, + // 모임 멤버 관련 키 + members: (gatheringId: number) => + [...gatheringQueryKeys.detail(gatheringId), 'members'] as const, + membersByStatus: (gatheringId: number, status: MemberStatusFilter) => + [...gatheringQueryKeys.members(gatheringId), status] as const, // 모임 책장 관련 키 books: (gatheringId: number) => [...gatheringQueryKeys.detail(gatheringId), 'books'] as const, } as const diff --git a/src/features/gatherings/hooks/index.ts b/src/features/gatherings/hooks/index.ts index 1062003..3f4ab65 100644 --- a/src/features/gatherings/hooks/index.ts +++ b/src/features/gatherings/hooks/index.ts @@ -1,11 +1,17 @@ export * from './gatheringQueryKeys' export * from './useCreateGathering' +export * from './useDeleteGathering' export * from './useFavoriteGatherings' export * from './useGatheringBooks' export * from './useGatheringByInviteCode' export * from './useGatheringDetail' export * from './useGatheringMeetings' +export * from './useGatheringMembers' export * from './useGatherings' +export * from './useGatheringSettingForm' +export * from './useHandleJoinRequest' export * from './useJoinGathering' export * from './useMeetingTabCounts' +export * from './useRemoveMember' export * from './useToggleFavorite' +export * from './useUpdateGathering' diff --git a/src/features/gatherings/hooks/useDeleteGathering.ts b/src/features/gatherings/hooks/useDeleteGathering.ts new file mode 100644 index 0000000..79fc542 --- /dev/null +++ b/src/features/gatherings/hooks/useDeleteGathering.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { deleteGathering } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 삭제 mutation 훅 + */ +export const useDeleteGathering = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, number>({ + mutationFn: deleteGathering, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.all }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringMembers.ts b/src/features/gatherings/hooks/useGatheringMembers.ts new file mode 100644 index 0000000..2532809 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringMembers.ts @@ -0,0 +1,48 @@ +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getGatheringMembers } from '../gatherings.api' +import type { + GatheringMemberListCursor, + GatheringMemberListResponse, + MemberStatusFilter, +} from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** 페이지 파라미터 타입: undefined = 첫 페이지, number = 다음 커서 ID */ +type PageParam = number | undefined + +/** + * 모임 멤버 목록 조회 훅 (커서 기반 무한 스크롤) + * + * @param gatheringId - 모임 ID + * @param status - 멤버 상태 필터 (PENDING | ACTIVE) + */ +export const useGatheringMembers = (gatheringId: number, status: MemberStatusFilter) => { + return useInfiniteQuery< + GatheringMemberListResponse, + Error, + InfiniteData, + readonly (string | number)[], + PageParam + >({ + queryKey: gatheringQueryKeys.membersByStatus(gatheringId, status), + queryFn: async ({ pageParam }: { pageParam: PageParam }) => { + const response = await getGatheringMembers({ + gatheringId, + status, + pageSize: PAGE_SIZES.GATHERING_MEMBERS, + cursorId: pageParam, + }) + return response.data + }, + initialPageParam: undefined as PageParam, + getNextPageParam: ( + lastPage: GatheringMemberListResponse, + ): PageParam => { + if (!lastPage.hasNext || !lastPage.nextCursor) return undefined + return (lastPage.nextCursor as GatheringMemberListCursor).gatheringMemberId + }, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringSettingForm.ts b/src/features/gatherings/hooks/useGatheringSettingForm.ts new file mode 100644 index 0000000..004d98b --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringSettingForm.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from 'react' + +import type { GatheringDetailResponse } from '../gatherings.types' + +export const MAX_NAME_LENGTH = 12 +export const MAX_DESCRIPTION_LENGTH = 150 + +/** + * 모임 설정 폼 상태 관리 훅 + * + * gathering 데이터가 변경되면 폼을 리셋합니다. + */ +export const useGatheringSettingForm = (gathering: GatheringDetailResponse | undefined) => { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + + // gathering 데이터가 로드/변경되면 폼 리셋 + const gatheringId = gathering?.gatheringId + useEffect(() => { + if (gathering) { + setName(gathering.gatheringName) + setDescription(gathering.description ?? '') + } + // gatheringId 변경 시에만 리셋 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gatheringId]) + + const isValid = name.trim().length > 0 && name.length <= MAX_NAME_LENGTH + + const getFormData = useCallback( + () => ({ + gatheringName: name.trim(), + description: description.trim() || undefined, + }), + [name, description], + ) + + return { + name, + setName, + description, + setDescription, + isValid, + getFormData, + } +} diff --git a/src/features/gatherings/hooks/useHandleJoinRequest.ts b/src/features/gatherings/hooks/useHandleJoinRequest.ts new file mode 100644 index 0000000..eae3dc9 --- /dev/null +++ b/src/features/gatherings/hooks/useHandleJoinRequest.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { handleJoinRequest } from '../gatherings.api' +import type { ApproveType } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +type HandleJoinRequestVariables = { + gatheringId: number + memberId: number + approveType: ApproveType +} + +/** + * 가입 요청 승인/거절 mutation 훅 + */ +export const useHandleJoinRequest = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, HandleJoinRequestVariables>({ + mutationFn: ({ gatheringId, memberId, approveType }) => + handleJoinRequest(gatheringId, memberId, approveType), + onSuccess: (_, { gatheringId }) => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useRemoveMember.ts b/src/features/gatherings/hooks/useRemoveMember.ts new file mode 100644 index 0000000..6768fa2 --- /dev/null +++ b/src/features/gatherings/hooks/useRemoveMember.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { removeMember } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +type RemoveMemberVariables = { + gatheringId: number + userId: number +} + +/** + * 멤버 삭제(강퇴) mutation 훅 + */ +export const useRemoveMember = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, RemoveMemberVariables>({ + mutationFn: ({ gatheringId, userId }) => removeMember(gatheringId, userId), + onSuccess: (_, { gatheringId }) => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) + }, + }) +} diff --git a/src/features/gatherings/hooks/useUpdateGathering.ts b/src/features/gatherings/hooks/useUpdateGathering.ts new file mode 100644 index 0000000..cc77730 --- /dev/null +++ b/src/features/gatherings/hooks/useUpdateGathering.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError, type ApiResponse } from '@/api' + +import { updateGathering } from '../gatherings.api' +import type { GatheringUpdateRequest, GatheringUpdateResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +type UpdateGatheringVariables = { + gatheringId: number + data: GatheringUpdateRequest +} + +/** + * 모임 정보 수정 mutation 훅 + */ +export const useUpdateGathering = () => { + const queryClient = useQueryClient() + + return useMutation, ApiError, UpdateGatheringVariables>({ + mutationFn: ({ gatheringId, data }) => updateGathering(gatheringId, data), + onSuccess: (_, { gatheringId }) => { + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) + }, + }) +} diff --git a/src/features/gatherings/index.ts b/src/features/gatherings/index.ts index f1b27ed..d53a639 100644 --- a/src/features/gatherings/index.ts +++ b/src/features/gatherings/index.ts @@ -12,6 +12,7 @@ export * from './lib/meetingStatus' // Types export type { + ApproveType, CreateGatheringRequest, CreateGatheringResponse, FavoriteGatheringListResponse, @@ -27,12 +28,18 @@ export type { GatheringMeetingItem, GatheringMeetingListResponse, GatheringMember, + GatheringMemberListCursor, + GatheringMemberListResponse, GatheringMemberStatus, GatheringStatus, + GatheringUpdateRequest, + GatheringUpdateResponse, GatheringUserRole, GetGatheringBooksParams, GetGatheringMeetingsParams, + GetGatheringMembersParams, GetGatheringsParams, MeetingFilter, MeetingTabCountsResponse, + MemberStatusFilter, } from './gatherings.types' diff --git a/src/pages/Gatherings/GatheringSettingPage.tsx b/src/pages/Gatherings/GatheringSettingPage.tsx new file mode 100644 index 0000000..eca860f --- /dev/null +++ b/src/pages/Gatherings/GatheringSettingPage.tsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' + +import type { GatheringMember } from '@/features/gatherings' +import { + MAX_DESCRIPTION_LENGTH, + MAX_NAME_LENGTH, + MemberCard, + useDeleteGathering, + useGatheringDetail, + useGatheringMembers, + useGatheringSettingForm, + useHandleJoinRequest, + useRemoveMember, + useUpdateGathering, +} from '@/features/gatherings' +import SubPageHeader from '@/shared/components/SubPageHeader' +import { ROUTES } from '@/shared/constants' +import { + Button, + Container, + Input, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Textarea, +} from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + +type MemberTab = 'PENDING' | 'ACTIVE' + +export default function GatheringSettingPage() { + const { id } = useParams<{ id: string }>() + const gatheringId = Number(id) + const navigate = useNavigate() + const { openConfirm, openError, openAlert } = useGlobalModalStore() + + const { data: gathering, isLoading, error } = useGatheringDetail(gatheringId) + const { name, setName, description, setDescription, isValid, getFormData } = + useGatheringSettingForm(gathering) + + const [activeTab, setActiveTab] = useState('PENDING') + + const { + data: pendingData, + fetchNextPage: fetchNextPending, + hasNextPage: hasNextPending, + isFetchingNextPage: isFetchingNextPending, + } = useGatheringMembers(gatheringId, 'PENDING') + + const { + data: activeData, + fetchNextPage: fetchNextActive, + hasNextPage: hasNextActive, + isFetchingNextPage: isFetchingNextActive, + } = useGatheringMembers(gatheringId, 'ACTIVE') + + const pendingMembers = pendingData?.pages.flatMap((page) => page.items) ?? [] + const activeMembers = activeData?.pages.flatMap((page) => page.items) ?? [] + const pendingTotalCount = pendingData?.pages[0]?.totalCount ?? 0 + const activeTotalCount = activeData?.pages[0]?.totalCount ?? 0 + + const updateMutation = useUpdateGathering() + const deleteMutation = useDeleteGathering() + const joinRequestMutation = useHandleJoinRequest() + const removeMemberMutation = useRemoveMember() + + // 에러 처리 + useEffect(() => { + if (error) { + openError('오류', '모임 정보를 불러오는 데 실패했습니다.', () => { + navigate(ROUTES.GATHERINGS) + }) + } + }, [error, openError, navigate]) + + // 모임 정보 저장 + const handleSave = () => { + if (!isValid || updateMutation.isPending) return + + updateMutation.mutate( + { gatheringId, data: getFormData() }, + { + onSuccess: () => { + openAlert('알림', '모임 정보가 수정되었습니다.') + }, + onError: () => { + openError('오류', '모임 정보 수정에 실패했습니다.') + }, + } + ) + } + + // 모임 삭제 + const handleDeleteGathering = async () => { + const confirmed = await openConfirm( + '모임 삭제하기', + '모임의 모든 정보와 기록이 사라지며, 다시 되돌릴 수 없어요.\n정말 이 모임을 삭제할까요?', + { confirmText: '모임 삭제', variant: 'danger' } + ) + if (!confirmed) return + + deleteMutation.mutate(gatheringId, { + onSuccess: () => { + navigate(ROUTES.GATHERINGS, { replace: true }) + }, + onError: () => { + openError('오류', '모임 삭제에 실패했습니다.') + }, + }) + } + + // 가입 요청 승인 + const handleApprove = (member: GatheringMember) => { + joinRequestMutation.mutate( + { gatheringId, memberId: member.userId, approveType: 'ACTIVE' }, + { + onSuccess: () => { + openAlert('알림', `${member.nickname}님의 가입을 승인했습니다.`) + }, + onError: () => { + openError('오류', '가입 승인에 실패했습니다.') + }, + } + ) + } + + // 가입 요청 거절 + const handleReject = (member: GatheringMember) => { + joinRequestMutation.mutate( + { gatheringId, memberId: member.userId, approveType: 'REJECTED' }, + { + onSuccess: () => { + openAlert('알림', `${member.nickname}님의 가입을 거절했습니다.`) + }, + onError: () => { + openError('오류', '가입 거절에 실패했습니다.') + }, + } + ) + } + + // 멤버 삭제(강퇴) + const handleRemoveMember = async (member: GatheringMember) => { + const confirmed = await openConfirm( + `${member.nickname} 내보내기`, + `내보낸 멤버는 이 모임에 더 이상 접근할 수 없어요.\n정말 이 멤버를 내보낼까요?`, + { confirmText: '내보내기', variant: 'danger' } + ) + if (!confirmed) return + + removeMemberMutation.mutate( + { gatheringId, userId: member.userId }, + { + onSuccess: () => { + openAlert('알림', `${member.nickname}님을 모임에서 내보냈습니다.`) + }, + onError: () => { + openError('오류', '멤버 내보내기에 실패했습니다.') + }, + } + ) + } + + const handleMemberAction = (action: 'approve' | 'reject' | 'remove', member: GatheringMember) => { + switch (action) { + case 'approve': + handleApprove(member) + break + case 'reject': + handleReject(member) + break + case 'remove': + handleRemoveMember(member) + break + } + } + + if (isLoading) { + return ( +
+

로딩 중...

+
+ ) + } + + if (!gathering) return null + + return ( +
+ + +

독서모임 설정

+ {/* 독서모임 정보 섹션 */} + +
+ 독서모임 정보 +
+ + +
+
+ + setName(e.target.value)} + maxLength={MAX_NAME_LENGTH} + /> +
+

독서모임 설명

+