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
81 changes: 81 additions & 0 deletions src/features/gatherings/components/MemberCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-between rounded-base border border-grey-300 p-base">
<div className="flex items-center gap-small">
{member.profileImageUrl ? (
<img
src={member.profileImageUrl}
alt={member.nickname}
className="size-10 rounded-full object-cover"
/>
) : (
<div className="flex size-10 items-center justify-center rounded-full bg-grey-400 text-white typo-body2">
{member.nickname.charAt(0)}
</div>
)}
<div className="flex flex-col">
<span className="typo-subtitle5 text-black">{member.nickname}</span>
{dateLabel && <span className="typo-body4 text-grey-600">{dateLabel}</span>}
</div>
</div>
<div className="flex items-center gap-xsmall">
{actions.includes('reject') && (
<Button
variant="secondary"
outline
size="small"
disabled={disabled}
onClick={() => onAction('reject', member)}
>
거절
</Button>
)}
{actions.includes('approve') && (
<Button
variant="primary"
size="small"
disabled={disabled}
onClick={() => onAction('approve', member)}
>
승인
</Button>
)}
{actions.includes('remove') && (
<Button
variant="danger"
outline
size="small"
disabled={disabled}
onClick={() => onAction('remove', member)}
>
삭제
</Button>
)}
</div>
</div>
)
}
1 change: 1 addition & 0 deletions src/features/gatherings/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
79 changes: 79 additions & 0 deletions src/features/gatherings/gatherings.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { apiClient, type ApiResponse } from '@/api'

import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints'
import type {
ApproveType,
CreateGatheringRequest,
CreateGatheringResponse,
FavoriteGatheringListResponse,
Expand All @@ -11,8 +12,12 @@ import type {
GatheringJoinResponse,
GatheringListResponse,
GatheringMeetingListResponse,
GatheringMemberListResponse,
GatheringUpdateRequest,
GatheringUpdateResponse,
GetGatheringBooksParams,
GetGatheringMeetingsParams,
GetGatheringMembersParams,
GetGatheringsParams,
MeetingTabCountsResponse,
} from './gatherings.types'
Expand Down Expand Up @@ -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<ApiResponse<GatheringMemberListResponse>>(
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<ApiResponse<GatheringUpdateResponse>>(
GATHERINGS_ENDPOINTS.DETAIL(gatheringId),
data
)
return response.data
}

/**
* 모임 삭제
*
* @param gatheringId - 모임 ID
*/
export const deleteGathering = async (gatheringId: number) => {
const response = await apiClient.delete<ApiResponse<null>>(
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<ApiResponse<null>>(
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<ApiResponse<null>>(
GATHERINGS_ENDPOINTS.REMOVE_MEMBER(gatheringId, userId)
)
return response.data
}
8 changes: 8 additions & 0 deletions src/features/gatherings/gatherings.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 57 additions & 0 deletions src/features/gatherings/gatherings.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export interface GatheringMember {
profileImageUrl: string | null
/** 역할 */
role: GatheringUserRole
/** 멤버 상태 (멤버 목록 API에서만 제공) */
memberStatus?: GatheringMemberStatus
/** 가입일 (ISO 8601, 멤버 목록 API에서만 제공, PENDING이면 null) */
joinedAt?: string | null
}

/** 모임 상세 응답 */
Expand Down Expand Up @@ -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<GatheringMemberStatus, 'PENDING' | 'ACTIVE'>

/** 멤버 목록 커서 */
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<GatheringMemberStatus, 'ACTIVE' | 'REJECTED'>
6 changes: 5 additions & 1 deletion src/features/gatherings/hooks/gatheringQueryKeys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MeetingFilter } from '../gatherings.types'
import type { MeetingFilter, MemberStatusFilter } from '../gatherings.types'

/**
* 모임 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수
Expand All @@ -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
6 changes: 6 additions & 0 deletions src/features/gatherings/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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'
20 changes: 20 additions & 0 deletions src/features/gatherings/hooks/useDeleteGathering.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<null>, ApiError, number>({
mutationFn: deleteGathering,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.all })
},
})
}
43 changes: 43 additions & 0 deletions src/features/gatherings/hooks/useGatheringMembers.ts
Original file line number Diff line number Diff line change
@@ -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<GatheringMemberListResponse, PageParam>,
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,
})
}
Loading
Loading