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
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