diff --git a/src/api/service/group-service/index.ts b/src/api/service/group-service/index.ts index 5259be90..1edeace1 100644 --- a/src/api/service/group-service/index.ts +++ b/src/api/service/group-service/index.ts @@ -6,6 +6,7 @@ import { GetGroupDetailsResponse, GetGroupsPayload, GetGroupsResponse, + GetJoinRequestsResponse, GetMyGroupsPayload, GetMyGroupsResponse, GroupIdParams, @@ -94,6 +95,29 @@ export const groupServiceRemote = () => ({ ); }, + // 가입 신청 목록 조회 (GET /api/v2/groups/{groupId}/attendance?status=PENDING) + getJoinRequests: (params: GroupIdParams, status: string = 'PENDING') => { + const queryParams = new URLSearchParams(); + queryParams.append('status', status); + return apiV2.get( + `/groups/${params.groupId}/attendance?${queryParams.toString()}`, + ); + }, + + // 승인 (POST /api/v2/groups/{groupId}/attendance/{targetUserId}/approve) + approveJoinRequest: (params: KickGroupMemberParams) => { + return apiV2.post( + `/groups/${params.groupId}/attendance/${params.targetUserId}/approve`, + ); + }, + + // 거절 (POST /api/v2/groups/{groupId}/attendance/{targetUserId}/reject) + rejectJoinRequest: (params: KickGroupMemberParams) => { + return apiV2.post( + `/groups/${params.groupId}/attendance/${params.targetUserId}/reject`, + ); + }, + uploadGroupImages: (payload: FormData) => { return apiV2.post('/groups/images/upload', payload); }, diff --git a/src/app/pending/[groupId]/page.tsx b/src/app/pending/[groupId]/page.tsx new file mode 100644 index 00000000..f3526b73 --- /dev/null +++ b/src/app/pending/[groupId]/page.tsx @@ -0,0 +1,38 @@ +import { redirect } from 'next/navigation'; + +import { API } from '@/api'; +import { + GroupPendingHeader, + GroupPendingMembers, + GroupPendingSummary, +} from '@/components/pages/pending'; +import { GetJoinRequestsResponse, GroupUserV2Status } from '@/types/service/group'; + +interface Props { + params: Promise<{ groupId: string }>; +} + +const PENDING_STATUS: GroupUserV2Status = 'PENDING'; + +/** + * 가입 신청 목록 조회 페이지 + */ +export default async function PendingMembersPage({ params }: Props) { + const { groupId } = await params; + + let joinRequestsData: GetJoinRequestsResponse; + + try { + joinRequestsData = await API.groupService.getJoinRequests({ groupId }, PENDING_STATUS); + } catch { + redirect('/'); + } + + return ( + <> + + + + + ); +} diff --git a/src/app/schedule/_components/meeting-list.tsx b/src/app/schedule/_components/meeting-list.tsx index ad59d960..413d886e 100644 --- a/src/app/schedule/_components/meeting-list.tsx +++ b/src/app/schedule/_components/meeting-list.tsx @@ -107,7 +107,7 @@ export const MeetingList = ({ key={meeting.id} dateTime={formatDateTime(meeting.startTime)} images={meeting.images} - isClosed={!meeting.joinable} + isFinished={meeting.status === 'FINISHED'} leaveAndChatActions={ showActions ? { diff --git a/src/components/pages/group-list/index.tsx b/src/components/pages/group-list/index.tsx index 8288f1ad..85d5e860 100644 --- a/src/components/pages/group-list/index.tsx +++ b/src/components/pages/group-list/index.tsx @@ -55,7 +55,7 @@ export default function GroupList({ initialData, initialKeyword }: GroupListProp const hasNoItems = items.length === 0 && !error; return ( -
+
{error && items.length === 0 && (
@@ -99,7 +99,7 @@ export default function GroupList({ initialData, initialKeyword }: GroupListProp key={meeting.id} dateTime={formatDateTime(meeting.startTime)} images={meeting.images} - isClosed={!meeting.joinable} + isFinished={meeting.status === 'FINISHED'} isPending={meeting.myMembership?.status === 'PENDING'} location={meeting.location} maxParticipants={meeting.maxParticipants} diff --git a/src/components/pages/group-pending/summary/index.tsx b/src/components/pages/group-pending/summary/index.tsx new file mode 100644 index 00000000..79fee23e --- /dev/null +++ b/src/components/pages/group-pending/summary/index.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { DEFAULT_GROUP_IMAGE } from 'constants/default-images'; + +import { ImageWithFallback } from '@/components/ui'; + +interface Props { + thumbnail?: string | null; + title?: string; + pendingCount?: number; +} + +export const GroupPendingSummary = ({ thumbnail, title, pendingCount }: Props) => { + return ( +
+
+ +
+ +
+

{title}

+ +
+ 신청한 유저 + {pendingCount ?? 0} + +
+
+
+ ); +}; diff --git a/src/components/pages/pending/index.ts b/src/components/pages/pending/index.ts new file mode 100644 index 00000000..e0e1e1fa --- /dev/null +++ b/src/components/pages/pending/index.ts @@ -0,0 +1,3 @@ +export { GroupPendingHeader } from './pending-header'; +export { GroupPendingMembers } from './pending-members'; +export { GroupPendingSummary } from './pending-summary'; diff --git a/src/components/pages/pending/pending-header/index.tsx b/src/components/pages/pending/pending-header/index.tsx new file mode 100644 index 00000000..a7d595fc --- /dev/null +++ b/src/components/pages/pending/pending-header/index.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Icon } from '@/components/icon'; + +export const GroupPendingHeader = () => { + const router = useRouter(); + + const handleBackClick = () => { + router.back(); + }; + + return ( +
+ +

참여 신청

+
+ ); +}; diff --git a/src/components/pages/pending/pending-members/index.tsx b/src/components/pages/pending/pending-members/index.tsx new file mode 100644 index 00000000..24547724 --- /dev/null +++ b/src/components/pages/pending/pending-members/index.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { useCallback, useEffect } from 'react'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { EmptyState } from '@/components/layout/empty-state'; +import { GetJoinRequestsResponse } from '@/types/service/group'; + +import { PendingMemberCard } from './pending-member-card'; + +interface Props { + groupId: string; +} + +export const GroupPendingMembers = ({ groupId }: Props) => { + const router = useRouter(); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['joinRequests', groupId, 'PENDING'], + queryFn: () => API.groupService.getJoinRequests({ groupId }, 'PENDING'), + }); + + const isForbidden = + error && typeof error === 'object' && 'status' in error && error.status === 403; + + useEffect(() => { + if (isForbidden) { + router.replace('/'); + } + }, [isForbidden, router]); + + const approveMutation = useMutation({ + mutationFn: (targetUserId: string) => + API.groupService.approveJoinRequest({ groupId, targetUserId }), + onSuccess: async () => { + // 가입 신청 목록 캐시 무효화 및 자동 refetch + // GroupPendingSummary의 count도 자동으로 업데이트됨 + await queryClient.invalidateQueries({ + queryKey: ['joinRequests', groupId, 'PENDING'], + refetchType: 'active', // 활성화된 모든 쿼리 자동 refetch + }); + // 모임 상세 정보도 갱신 + await queryClient.invalidateQueries({ queryKey: ['groupDetails', groupId] }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: (targetUserId: string) => + API.groupService.rejectJoinRequest({ groupId, targetUserId }), + onSuccess: async () => { + // 가입 신청 목록 캐시 무효화 및 모든 활성 쿼리 refetch + // GroupPendingSummary의 count도 자동으로 업데이트됨 + await queryClient.invalidateQueries({ + queryKey: ['joinRequests', groupId, 'PENDING'], + refetchType: 'active', // 활성화된 모든 쿼리 자동 refetch + }); + }, + }); + + const handleApprove = useCallback( + (targetUserId: string) => { + approveMutation.mutate(targetUserId); + }, + [approveMutation], + ); + + const handleReject = useCallback( + (targetUserId: string) => { + rejectMutation.mutate(targetUserId); + }, + [rejectMutation], + ); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (isForbidden) { + return null; + } + + if (error && (!('status' in error) || error.status !== 403)) { + return ( +
+
데이터를 불러오는데 실패했습니다.
+
+ ); + } + + if (!data || data.items.length === 0) { + return ( +
+ 승인 대기 중인 멤버가 없습니다 +
+ ); + } + + return ( +
+
    + {data.items.map((member) => ( +
  • + handleApprove(String(member.userId))} + onReject={() => handleReject(String(member.userId))} + /> +
  • + ))} +
+
+ ); +}; diff --git a/src/components/pages/pending/pending-members/pending-member-card/index.tsx b/src/components/pages/pending/pending-members/pending-member-card/index.tsx new file mode 100644 index 00000000..6cce7fb1 --- /dev/null +++ b/src/components/pages/pending/pending-members/pending-member-card/index.tsx @@ -0,0 +1,57 @@ +'use client'; + +import Link from 'next/link'; + +import { Button, ImageWithFallback } from '@/components/ui'; +import { GetJoinRequestsResponse } from '@/types/service/group'; + +type JoinRequestItem = GetJoinRequestsResponse['items'][number]; + +interface Props { + member: JoinRequestItem; + onReject: () => void; + onApprove: () => void; +} + +export const PendingMemberCard = ({ member, onReject, onApprove }: Props) => { + const profileUrl = `/profile/${member.userId}`; + + return ( +
+ + + +
+

{member.nickName}

+
+ + + {member.joinRequestMessage && ( +

+ {member.joinRequestMessage} +

+ )} + +
+ + +
+
+ ); +}; diff --git a/src/components/pages/pending/pending-summary/index.tsx b/src/components/pages/pending/pending-summary/index.tsx new file mode 100644 index 00000000..e9d05754 --- /dev/null +++ b/src/components/pages/pending/pending-summary/index.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; + +import { useQuery } from '@tanstack/react-query'; +import { DEFAULT_GROUP_IMAGE } from 'constants/default-images'; + +import { API } from '@/api'; +import { ImageWithFallback } from '@/components/ui'; +import { GetJoinRequestsResponse } from '@/types/service/group'; + +interface Props { + groupId: string; + initialData?: GetJoinRequestsResponse; +} + +export const GroupPendingSummary = ({ groupId, initialData }: Props) => { + const { data } = useQuery({ + queryKey: ['joinRequests', groupId, 'PENDING'], + queryFn: () => API.groupService.getJoinRequests({ groupId }, 'PENDING'), + initialData, + }); + + const pendingCount = data?.count ?? 0; + const title = data?.groupTitle ?? ''; + const thumbnail = data?.thumbnail100x100Url ?? null; + + return ( + +
+ +
+ +
+

{title}

+ +
+ 신청한 유저 + {pendingCount} + +
+
+ + ); +}; diff --git a/src/components/shared/card/card-thumbnail/index.tsx b/src/components/shared/card/card-thumbnail/index.tsx index 9b83fa65..619ae010 100644 --- a/src/components/shared/card/card-thumbnail/index.tsx +++ b/src/components/shared/card/card-thumbnail/index.tsx @@ -6,10 +6,10 @@ type CardThumbnailProps = { title: string; thumbnail?: string; isPending?: boolean; - isClosed?: boolean; + isFinished?: boolean; }; -export const CardThumbnail = ({ title, thumbnail, isPending, isClosed }: CardThumbnailProps) => { +export const CardThumbnail = ({ title, thumbnail, isPending, isFinished }: CardThumbnailProps) => { return (
대기중
)} - {isClosed && ( + {isFinished && ( <>
diff --git a/src/components/shared/card/index.tsx b/src/components/shared/card/index.tsx index da599ce8..b49361bb 100644 --- a/src/components/shared/card/index.tsx +++ b/src/components/shared/card/index.tsx @@ -26,7 +26,7 @@ type CardProps = { }; tabType?: 'current' | 'myPost' | 'past'; isPending?: boolean; - isClosed?: boolean; + isFinished?: boolean; }; const calculateProgress = (count: number, max: number): number => { @@ -53,7 +53,7 @@ const Card = ({ leaveAndChatActions, tabType, isPending, - isClosed, + isFinished, }: CardProps) => { const thumbnail = images?.[0]; const cardTags = convertToCardTags(tags); @@ -72,7 +72,7 @@ const Card = ({