Skip to content
24 changes: 24 additions & 0 deletions src/api/service/group-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
GetGroupDetailsResponse,
GetGroupsPayload,
GetGroupsResponse,
GetJoinRequestsResponse,
GetMyGroupsPayload,
GetMyGroupsResponse,
GroupIdParams,
Expand Down Expand Up @@ -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<GetJoinRequestsResponse>(
`/groups/${params.groupId}/attendance?${queryParams.toString()}`,
);
},

// 승인 (POST /api/v2/groups/{groupId}/attendance/{targetUserId}/approve)
approveJoinRequest: (params: KickGroupMemberParams) => {
return apiV2.post<GetGroupDetailsResponse>(
`/groups/${params.groupId}/attendance/${params.targetUserId}/approve`,
);
},

// 거절 (POST /api/v2/groups/{groupId}/attendance/{targetUserId}/reject)
rejectJoinRequest: (params: KickGroupMemberParams) => {
return apiV2.post<GetGroupDetailsResponse>(
`/groups/${params.groupId}/attendance/${params.targetUserId}/reject`,
);
},

uploadGroupImages: (payload: FormData) => {
return apiV2.post<PreUploadGroupImageResponse>('/groups/images/upload', payload);
},
Expand Down
38 changes: 38 additions & 0 deletions src/app/pending/[groupId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<GroupPendingHeader />
<GroupPendingSummary groupId={groupId} initialData={joinRequestsData} />
<GroupPendingMembers groupId={groupId} />
</>
);
}
2 changes: 1 addition & 1 deletion src/app/schedule/_components/meeting-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
? {
Expand Down
4 changes: 2 additions & 2 deletions src/components/pages/group-list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default function GroupList({ initialData, initialKeyword }: GroupListProp
const hasNoItems = items.length === 0 && !error;

return (
<section className='min-h-screen bg-[#F1F5F9]'>
<section className='min-h-[calc(100vh-168px)] bg-[#F1F5F9]'>
<div className='flex w-full flex-col px-4'>
{error && items.length === 0 && (
<div className='py-4'>
Expand Down Expand Up @@ -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}
Expand Down
39 changes: 39 additions & 0 deletions src/components/pages/group-pending/summary/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex h-22 items-center gap-3 border-b border-gray-200 bg-white p-4'>
<div className='relative h-14 w-14 shrink-0 overflow-hidden rounded-[10px] bg-gray-200'>
<ImageWithFallback
width={56}
className='h-full w-full object-cover'
alt={title || '모임 썸네일'}
fallbackSrc={DEFAULT_GROUP_IMAGE}
height={56}
src={thumbnail ?? ''}
unoptimized
/>
</div>

<div className='flex min-w-0 flex-1 flex-col justify-between'>
<h3 className='text-text-md-semibold mt-[5px] h-6 truncate text-gray-800'>{title}</h3>

<div className='text-text-sm-medium mb-[5px] flex h-5 items-center'>
<span className='text-gray-600'>신청한 유저</span>
<span className='text-mint-600 ml-1'>{pendingCount ?? 0}</span>
<span className='text-gray-600'>명</span>
</div>
</div>
</div>
);
};
3 changes: 3 additions & 0 deletions src/components/pages/pending/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { GroupPendingHeader } from './pending-header';
export { GroupPendingMembers } from './pending-members';
export { GroupPendingSummary } from './pending-summary';
26 changes: 26 additions & 0 deletions src/components/pages/pending/pending-header/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='sticky top-14 z-100 flex h-12 items-center justify-center border-b border-gray-200 bg-white'>
<button
className='absolute left-5 flex items-center justify-center'
aria-label='뒤로 가기'
onClick={handleBackClick}
>
<Icon id='chevron-left-2' className='text-gray-500' />
</button>
<h2 className='text-text-md-bold text-gray-800'>참여 신청</h2>
</div>
);
};
122 changes: 122 additions & 0 deletions src/components/pages/pending/pending-members/index.tsx
Original file line number Diff line number Diff line change
@@ -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<GetJoinRequestsResponse>({
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 (
<section className='relative h-[calc(100vh-192px)]'>
<div className='flex-center h-full'>로딩 중...</div>
</section>
);
}

if (isForbidden) {
return null;
}

if (error && (!('status' in error) || error.status !== 403)) {
return (
<section className='relative h-[calc(100vh-192px)]'>
<div className='flex-center h-full text-gray-600'>데이터를 불러오는데 실패했습니다.</div>
</section>
);
}

if (!data || data.items.length === 0) {
return (
<section className='relative h-[calc(100vh-192px)]'>
<EmptyState>승인 대기 중인 멤버가 없습니다</EmptyState>
</section>
);
}

return (
<section className='mt-5 px-4 pb-5'>
<ul className='space-y-4'>
{data.items.map((member) => (
<li key={`${member.userId}-${member.groupUserId}-${member.joinedAt}`}>
<PendingMemberCard
member={member}
onApprove={() => handleApprove(String(member.userId))}
onReject={() => handleReject(String(member.userId))}
/>
</li>
))}
</ul>
</section>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<div className='bg-mono-white rounded-3xl px-5 py-[26px] shadow-sm'>
<Link href={profileUrl} className='flex gap-3'>
<ImageWithFallback
width={40}
className='object-fit h-10 w-10 shrink-0 rounded-full'
alt={`${member.nickName} 프로필`}
draggable={false}
height={40}
src={member.profileImage ?? ''}
/>

<div className='min-w-0 flex-1'>
<h4 className='text-text-md-semibold h-6 text-gray-800'>{member.nickName}</h4>
</div>
</Link>

{member.joinRequestMessage && (
<p className='text-text-md-medium mt-4 line-clamp-2 max-h-12 min-h-6 text-gray-600'>
{member.joinRequestMessage}
</p>
)}

<div className='mt-4 flex gap-2'>
<Button className='flex-1' size='sm' variant='tertiary' onClick={onReject}>
거절하기
</Button>
<Button
className='bg-mint-500 text-text-sm-bold text-mono-white hover:bg-mint-600 active:bg-mint-700 flex-1'
size='sm'
variant='primary'
onClick={onApprove}
>
수락하기
</Button>
</div>
</div>
);
};
Loading