diff --git a/src/features/gatherings/components/MemberCard.tsx b/src/features/gatherings/components/MemberCard.tsx
new file mode 100644
index 0000000..b1a6eac
--- /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'
+
+export 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.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..9531209 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, type MemberCardAction } 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..d72312b 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,10 @@ 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..4b66280
--- /dev/null
+++ b/src/features/gatherings/hooks/useGatheringMembers.ts
@@ -0,0 +1,43 @@
+import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
+
+import { PAGE_SIZES } from '@/shared/constants'
+
+import { getGatheringMembers } from '../gatherings.api'
+import type { 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.gatheringMemberId
+ },
+ enabled: gatheringId > 0,
+ })
+}
diff --git a/src/features/gatherings/hooks/useGatheringSettingForm.ts b/src/features/gatherings/hooks/useGatheringSettingForm.ts
new file mode 100644
index 0000000..e152a31
--- /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.trim().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..c8de265
--- /dev/null
+++ b/src/features/gatherings/hooks/useUpdateGathering.ts
@@ -0,0 +1,27 @@
+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) })
+ queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.lists() })
+ },
+ })
+}
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..20aea74
--- /dev/null
+++ b/src/pages/Gatherings/GatheringSettingPage.tsx
@@ -0,0 +1,328 @@
+import { useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+
+import type { GatheringMember, MemberCardAction } 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 parsedId = id ? Number(id) : NaN
+ const gatheringId = Number.isFinite(parsedId) ? parsedId : 0
+ 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: MemberCardAction, member: GatheringMember) => {
+ switch (action) {
+ case 'approve':
+ handleApprove(member)
+ break
+ case 'reject':
+ handleReject(member)
+ break
+ case 'remove':
+ handleRemoveMember(member)
+ break
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (!gathering || gathering.currentUserRole !== 'LEADER') return null
+
+ return (
+
+
+
+
독서모임 설정
+ {/* 독서모임 정보 섹션 */}
+
+
+
독서모임 정보
+
+
+
+
+
+
+ setName(e.target.value)}
+ maxLength={MAX_NAME_LENGTH}
+ />
+
+
+
+ {/* 멤버 관리 섹션 */}
+
+ 멤버 관리
+
+ setActiveTab(value as MemberTab)}
+ className="gap-0"
+ >
+
+
+ 승인 대기
+
+
+ 승인 완료
+
+
+
+ {pendingMembers.length === 0 ? (
+
+
+ 승인 대기 중인 멤버가 없어요.
+
+
+ ) : (
+
+
+ {pendingMembers.map((member) => (
+
+ ))}
+
+ {hasNextPending && (
+
+ )}
+
+ )}
+
+
+ {activeMembers.length === 0 ? (
+
+ ) : (
+
+
+ {activeMembers.map((member) => (
+
+ ))}
+
+ {hasNextActive && (
+
+ )}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/pages/Gatherings/index.ts b/src/pages/Gatherings/index.ts
index 29c0d51..df3c1e7 100644
--- a/src/pages/Gatherings/index.ts
+++ b/src/pages/Gatherings/index.ts
@@ -1,4 +1,5 @@
export { default as CreateGatheringPage } from './CreateGatheringPage'
export { default as GatheringDetailPage } from './GatheringDetailPage'
export { default as GatheringListPage } from './GatheringListPage'
+export { default as GatheringSettingPage } from './GatheringSettingPage'
export { default as InvitePage } from './InvitePage'
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 62d4717..622d90f 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -8,6 +8,7 @@ import {
CreateGatheringPage,
GatheringDetailPage,
GatheringListPage,
+ GatheringSettingPage,
HomePage,
InvitePage,
LoginPage,
@@ -108,6 +109,10 @@ export const router = createBrowserRouter([
{
element: ,
children: [
+ {
+ path: `${ROUTES.GATHERINGS}/:id/settings`,
+ element: ,
+ },
{
path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId`,
element: ,
diff --git a/src/shared/constants/pagination.ts b/src/shared/constants/pagination.ts
index 1929158..4de896c 100644
--- a/src/shared/constants/pagination.ts
+++ b/src/shared/constants/pagination.ts
@@ -26,4 +26,6 @@ export const PAGE_SIZES = {
GATHERING_MEETINGS: 5,
/** 모임 책장 페이지 사이즈 */
GATHERING_BOOKS: 12,
+ /** 모임 멤버 목록 페이지 사이즈 */
+ GATHERING_MEMBERS: 9,
} as const