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
12 changes: 6 additions & 6 deletions components.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"css": "@/shared/styles/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
"components": "@/shared",
"utils": "@/shared/lib/utils",
"ui": "@/shared/ui",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
},
"registries": {}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@
"react-dom": "18.2.0",
"react-router-dom": "^6.30.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"zustand": "^5.0.10"
},
"packageManager": "pnpm@10.28.0"
}
27 changes: 27 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RouterProvider } from 'react-router-dom'

import { setupInterceptors } from '@/api'
import { queryClient } from '@/shared/lib/tanstack-query'
import { GlobalModalHost } from '@/shared/ui/GlobalModalHost'

import { router } from './routes'

Expand All @@ -13,6 +14,7 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<GlobalModalHost />
</QueryClientProvider>
)
}
Expand Down
108 changes: 108 additions & 0 deletions src/features/meetings/components/MeetingApprovalItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @file MeetingApprovalItem.tsx
* @description 약속 승인 아이템 컴포넌트
*/

import {
formatDateTime,
type MeetingApprovalItemType,
useConfirmMeeting,
useDeleteMeeting,
useRejectMeeting,
} from '@/features/meetings'
import { Button } from '@/shared/ui/Button'
import { useGlobalModalStore } from '@/store'

export type MeetingApprovalItemProps = {
/** 약속 승인 아이템 데이터 */
item: MeetingApprovalItemType
}

/**
* 약속 승인 아이템 컴포넌트
*
* @description
* 약속 승인 리스트의 개별 아이템을 렌더링합니다.
*/
export default function MeetingApprovalItem({ item }: MeetingApprovalItemProps) {
const { meetingName, bookName, nickname, startDateTime, endDateTime, meetingStatus, meetingId } =
item

const confirmMutation = useConfirmMeeting()
const rejectMutation = useRejectMeeting()
const deleteMutation = useDeleteMeeting()
const isPending =
confirmMutation.isPending || rejectMutation.isPending || deleteMutation.isPending
const { openConfirm, openError } = useGlobalModalStore()

const handleApprove = async () => {
if (isPending) return
const confirmed = await openConfirm('약속 승인', '약속을 승인 하시겠습니까?')
if (!confirmed) return

confirmMutation.mutate(meetingId, {
onError: (error) => openError('에러', error.userMessage),
})
}

const handleReject = async () => {
if (isPending) return
const confirmed = await openConfirm('약속 거절', '약속을 거절 하시겠습니까?')
if (!confirmed) return

rejectMutation.mutate(meetingId, {
onError: (error) => openError('에러', error.userMessage),
})
}

const handleDelete = async () => {
if (isPending) return
const confirmed = await openConfirm(
'약속 삭제',
'삭제된 약속은 리스트에서 사라지며 복구할 수 없어요.\n정말 약속을 삭제하시겠어요?',
{ confirmText: '삭제', variant: 'danger' }
)
if (!confirmed) return

deleteMutation.mutate(meetingId, {
onError: (error) => openError('에러', error.userMessage),
})
}

return (
<li className="flex items-center justify-between border-b gap-medium py-large border-grey-300 last:border-b-0">
<div className="flex flex-col gap-xtiny">
<p className="typo-body4 text-grey-600">{nickname}</p>
<p className="text-black typo-subtitle2">
{meetingName} | {bookName}
</p>
<p className="typo-body4 text-grey-600">
약속 일시 : {formatDateTime(startDateTime)} ~ {formatDateTime(endDateTime)}
</p>
</div>

<div className="flex gap-small shrink-0">
{meetingStatus === 'PENDING' ? (
<>
<Button
variant="secondary"
outline
size="small"
onClick={handleReject}
disabled={isPending}
>
거절
</Button>
<Button variant="primary" size="small" onClick={handleApprove} disabled={isPending}>
수락
</Button>
</>
) : (
<Button variant="danger" outline size="small" onClick={handleDelete} disabled={isPending}>
삭제
</Button>
)}
</div>
</li>
)
}
77 changes: 77 additions & 0 deletions src/features/meetings/components/MeetingApprovalList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @file MeetingApprovalList.tsx
* @description 약속 승인 리스트 컴포넌트
*/

import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'

import { MeetingApprovalItem, type MeetingStatus, useMeetingApprovals } from '@/features/meetings'
import { PAGE_SIZES } from '@/shared/constants'
import { Pagination } from '@/shared/ui/Pagination'
import { useGlobalModalStore } from '@/store'

export type MeetingApprovalListProps = {
/** 모임 식별자 */
gatheringId: number
/** 약속 상태 (PENDING: 확정 대기, CONFIRMED: 확정 완료) */
status: MeetingStatus
}
export default function MeetingApprovalList({ gatheringId, status }: MeetingApprovalListProps) {
const navigate = useNavigate()
const [currentPage, setCurrentPage] = useState(0)
const pageSize = PAGE_SIZES.MEETING_APPROVALS
const { openError } = useGlobalModalStore()

const { data, isLoading, isError, error } = useMeetingApprovals({
gatheringId,
status,
page: currentPage,
size: pageSize,
})

useEffect(() => {
if (isError) {
openError('에러', error.userMessage, () => {
navigate('/', { replace: true })
})
}
}, [isError, openError, error, navigate])

if (isLoading) {
return (
<div className="flex items-center justify-center py-large">
<p className="typo-body3 text-grey-600">로딩 중...</p>
</div>
)
}

if (!data || data.items.length === 0) {
return (
<div className="flex items-center justify-center py-large">
<p className="typo-body3 text-grey-600">약속이 없습니다.</p>
</div>
)
}

const { items, totalPages, totalCount } = data
const showPagination = totalCount > pageSize

return (
<div className="flex flex-col gap-medium">
<ul>
{items.map((item) => (
<MeetingApprovalItem key={item.meetingId} item={item} />
))}
</ul>

{showPagination && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={(page: number) => setCurrentPage(page)}
/>
)}
</div>
)
}
2 changes: 2 additions & 0 deletions src/features/meetings/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as MeetingApprovalItem } from './MeetingApprovalItem'
export { default as MeetingApprovalList } from './MeetingApprovalList'
6 changes: 6 additions & 0 deletions src/features/meetings/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './meetingQueryKeys'
export * from './useConfirmMeeting'
export * from './useDeleteMeeting'
export * from './useMeetingApprovals'
export * from './useMeetingApprovalsCount'
export * from './useRejectMeeting'
25 changes: 25 additions & 0 deletions src/features/meetings/hooks/meetingQueryKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @file meetingQueryKeys.ts
* @description 약속 관련 Query Key Factory
*/

import type { GetMeetingApprovalsParams } from '@/features/meetings'

/**
* Query Key Factory
*
* @description
* 약속 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수
*/
export const meetingQueryKeys = {
all: ['meetings'] as const,

// 약속 승인 리스트 관련
approvals: () => [...meetingQueryKeys.all, 'approvals'] as const,
approvalLists: () => [...meetingQueryKeys.approvals(), 'list'] as const,
approvalList: (params: GetMeetingApprovalsParams) =>
[...meetingQueryKeys.approvalLists(), params] as const,
approvalCounts: () => [...meetingQueryKeys.approvals(), 'count'] as const,
approvalCount: (gatheringId: number, status: GetMeetingApprovalsParams['status']) =>
[...meetingQueryKeys.approvalCounts(), gatheringId, status] as const,
}
38 changes: 38 additions & 0 deletions src/features/meetings/hooks/useConfirmMeeting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @file useConfirmMeeting.ts
* @description 약속 승인 mutation 훅
*/

import { useMutation, useQueryClient } from '@tanstack/react-query'

import { ApiError } from '@/api/errors'
import type { ApiResponse } from '@/api/types'
import { confirmMeeting, type ConfirmMeetingResponse } from '@/features/meetings'

import { meetingQueryKeys } from './meetingQueryKeys'

/**
* 약속 승인 mutation 훅
*
* @description
* 약속을 승인하고 관련 쿼리 캐시를 무효화합니다.
* - 약속 승인 리스트 캐시 무효화
* - 약속 승인 카운트 캐시 무효화
*
* @example
* const confirmMutation = useConfirmMeeting()
* confirmMutation.mutate(meetingId)
*/
export const useConfirmMeeting = () => {
const queryClient = useQueryClient()

return useMutation<ApiResponse<ConfirmMeetingResponse>, ApiError, number>({
mutationFn: (meetingId: number) => confirmMeeting(meetingId),
onSuccess: () => {
// 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트)
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.approvals(),
})
},
})
}
38 changes: 38 additions & 0 deletions src/features/meetings/hooks/useDeleteMeeting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @file useDeleteMeeting.ts
* @description 약속 삭제 mutation 훅
*/

import { useMutation, useQueryClient } from '@tanstack/react-query'

import { ApiError } from '@/api/errors'
import type { ApiResponse } from '@/api/types'
import { deleteMeeting } from '@/features/meetings'

import { meetingQueryKeys } from './meetingQueryKeys'

/**
* 약속 삭제 mutation 훅
*
* @description
* 약속을 삭제하고 관련 쿼리 캐시를 무효화합니다.
* - 약속 승인 리스트 캐시 무효화
* - 약속 승인 카운트 캐시 무효화
*
* @example
* const deleteMutation = useDeleteMeeting()
* deleteMutation.mutate(meetingId)
*/
export const useDeleteMeeting = () => {
const queryClient = useQueryClient()

return useMutation<ApiResponse<null>, ApiError, number>({
mutationFn: (meetingId: number) => deleteMeeting(meetingId),
onSuccess: () => {
// 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트)
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.approvals(),
})
},
})
}
Loading