diff --git a/components.json b/components.json
index 2b0833f..a197fe3 100644
--- a/components.json
+++ b/components.json
@@ -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": {}
}
diff --git a/package.json b/package.json
index 907772d..bdc1570 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 022fda6..0fc5d31 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
+ zustand:
+ specifier: ^5.0.10
+ version: 5.0.10(@types/react@18.2.14)(react@18.2.0)(use-sync-external-store@1.6.0(react@18.2.0))
devDependencies:
'@commitlint/cli':
specifier: ^20.3.1
@@ -2434,6 +2437,24 @@ packages:
zod@4.3.5:
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
+ zustand@5.0.10:
+ resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
snapshots:
'@babel/code-frame@7.27.1':
@@ -4565,3 +4586,9 @@ snapshots:
zod: 4.3.5
zod@4.3.5: {}
+
+ zustand@5.0.10(@types/react@18.2.14)(react@18.2.0)(use-sync-external-store@1.6.0(react@18.2.0)):
+ optionalDependencies:
+ '@types/react': 18.2.14
+ react: 18.2.0
+ use-sync-external-store: 1.6.0(react@18.2.0)
diff --git a/src/App.tsx b/src/App.tsx
index 24ddd3d..e3f3454 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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'
@@ -13,6 +14,7 @@ function App() {
return (
+
)
}
diff --git a/src/features/meetings/components/MeetingApprovalItem.tsx b/src/features/meetings/components/MeetingApprovalItem.tsx
new file mode 100644
index 0000000..978467f
--- /dev/null
+++ b/src/features/meetings/components/MeetingApprovalItem.tsx
@@ -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 (
+
+
+
{nickname}
+
+ {meetingName} | {bookName}
+
+
+ 약속 일시 : {formatDateTime(startDateTime)} ~ {formatDateTime(endDateTime)}
+
+
+
+
+ {meetingStatus === 'PENDING' ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/src/features/meetings/components/MeetingApprovalList.tsx b/src/features/meetings/components/MeetingApprovalList.tsx
new file mode 100644
index 0000000..6039ab0
--- /dev/null
+++ b/src/features/meetings/components/MeetingApprovalList.tsx
@@ -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 (
+
+ )
+ }
+
+ if (!data || data.items.length === 0) {
+ return (
+
+ )
+ }
+
+ const { items, totalPages, totalCount } = data
+ const showPagination = totalCount > pageSize
+
+ return (
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ {showPagination && (
+
setCurrentPage(page)}
+ />
+ )}
+
+ )
+}
diff --git a/src/features/meetings/components/index.ts b/src/features/meetings/components/index.ts
new file mode 100644
index 0000000..32ff009
--- /dev/null
+++ b/src/features/meetings/components/index.ts
@@ -0,0 +1,2 @@
+export { default as MeetingApprovalItem } from './MeetingApprovalItem'
+export { default as MeetingApprovalList } from './MeetingApprovalList'
diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts
new file mode 100644
index 0000000..b1c32e3
--- /dev/null
+++ b/src/features/meetings/hooks/index.ts
@@ -0,0 +1,6 @@
+export * from './meetingQueryKeys'
+export * from './useConfirmMeeting'
+export * from './useDeleteMeeting'
+export * from './useMeetingApprovals'
+export * from './useMeetingApprovalsCount'
+export * from './useRejectMeeting'
diff --git a/src/features/meetings/hooks/meetingQueryKeys.ts b/src/features/meetings/hooks/meetingQueryKeys.ts
new file mode 100644
index 0000000..4daf02a
--- /dev/null
+++ b/src/features/meetings/hooks/meetingQueryKeys.ts
@@ -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,
+}
diff --git a/src/features/meetings/hooks/useConfirmMeeting.ts b/src/features/meetings/hooks/useConfirmMeeting.ts
new file mode 100644
index 0000000..1fa80ac
--- /dev/null
+++ b/src/features/meetings/hooks/useConfirmMeeting.ts
@@ -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, ApiError, number>({
+ mutationFn: (meetingId: number) => confirmMeeting(meetingId),
+ onSuccess: () => {
+ // 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트)
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.approvals(),
+ })
+ },
+ })
+}
diff --git a/src/features/meetings/hooks/useDeleteMeeting.ts b/src/features/meetings/hooks/useDeleteMeeting.ts
new file mode 100644
index 0000000..4f09f3e
--- /dev/null
+++ b/src/features/meetings/hooks/useDeleteMeeting.ts
@@ -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, ApiError, number>({
+ mutationFn: (meetingId: number) => deleteMeeting(meetingId),
+ onSuccess: () => {
+ // 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트)
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.approvals(),
+ })
+ },
+ })
+}
diff --git a/src/features/meetings/hooks/useMeetingApprovals.ts b/src/features/meetings/hooks/useMeetingApprovals.ts
new file mode 100644
index 0000000..b8e5167
--- /dev/null
+++ b/src/features/meetings/hooks/useMeetingApprovals.ts
@@ -0,0 +1,54 @@
+/**
+ * @file useMeetingApprovals.ts
+ * @description 약속 승인 리스트 조회 훅
+ */
+
+import { useQuery } from '@tanstack/react-query'
+
+import type { ApiError } from '@/api'
+import type { PaginatedResponse } from '@/api/types'
+import {
+ getMeetingApprovals,
+ type GetMeetingApprovalsParams,
+ type MeetingApprovalItemType,
+} from '@/features/meetings'
+
+import { meetingQueryKeys } from './meetingQueryKeys'
+
+/**
+ * 약속 승인 리스트 조회 훅
+ *
+ * @description
+ * TanStack Query를 사용하여 약속 승인 리스트를 조회합니다.
+ * 페이지네이션과 상태(PENDING/CONFIRMED) 필터링을 지원합니다.
+ *
+ * @param params - 조회 파라미터
+ * @param params.gatheringId - 모임 식별자
+ * @param params.status - 약속 상태 (PENDING: 확정 대기, CONFIRMED: 확정 완료)
+ * @param params.page - 페이지 번호 (기본값: 0)
+ * @param params.size - 페이지 크기 (기본값: 10)
+ * @param params.sort - 정렬 기준 배열
+ *
+ * @returns TanStack Query 결과 객체
+ *
+ * @example
+ * const { data, isLoading } = useMeetingApprovals({
+ * gatheringId: 1,
+ * status: 'PENDING',
+ * page: 0,
+ * size: 10,
+ * })
+ */
+export const useMeetingApprovals = (params: GetMeetingApprovalsParams) => {
+ const isValidGatheringId =
+ !Number.isNaN(params.gatheringId) && params.gatheringId > 0
+
+ return useQuery, ApiError>({
+ queryKey: meetingQueryKeys.approvalList(params),
+ queryFn: () => getMeetingApprovals(params),
+ // gatheringId가 유효할 때만 쿼리 실행
+ enabled: isValidGatheringId,
+ // 캐시 데이터 10분간 유지 (전역 설정 staleTime: 5분 사용)
+ gcTime: 10 * 60 * 1000,
+ })
+}
diff --git a/src/features/meetings/hooks/useMeetingApprovalsCount.ts b/src/features/meetings/hooks/useMeetingApprovalsCount.ts
new file mode 100644
index 0000000..41ef067
--- /dev/null
+++ b/src/features/meetings/hooks/useMeetingApprovalsCount.ts
@@ -0,0 +1,80 @@
+/**
+ * @file useMeetingApprovalsCount.ts
+ * @description 약속 승인 카운트 조회 훅
+ */
+
+import { useQueries } from '@tanstack/react-query'
+
+import type { PaginatedResponse } from '@/api/types'
+import { getMeetingApprovals, type MeetingApprovalItemType } from '@/features/meetings'
+
+import { meetingQueryKeys } from './meetingQueryKeys'
+
+/**
+ * 약속 승인 카운트 일괄 조회 훅
+ *
+ * @description
+ * PENDING과 CONFIRMED 상태의 카운트를 병렬로 조회합니다.
+ * size=1로 요청하여 totalCount만 효율적으로 가져옵니다.
+ * 두 개의 쿼리를 useQueries로 한 번에 처리하여 코드 간결성을 높입니다.
+ *
+ * @param gatheringId - 모임 식별자 (유효하지 않은 경우 쿼리 비활성화)
+ *
+ * @returns 카운트 및 로딩/에러 상태 객체
+ * - pendingCount: PENDING 상태 카운트
+ * - confirmedCount: CONFIRMED 상태 카운트
+ * - isPendingLoading: PENDING 로딩 상태
+ * - isConfirmedLoading: CONFIRMED 로딩 상태
+ * - isLoading: 둘 중 하나라도 로딩 중인지 여부
+ * - pendingError: PENDING 에러 객체
+ * - confirmedError: CONFIRMED 에러 객체
+ * - isError: 둘 중 하나라도 에러가 발생했는지 여부
+ *
+ * @example
+ * const { pendingCount, confirmedCount, isLoading, isError } = useMeetingApprovalsCount(1)
+ */
+export const useMeetingApprovalsCount = (gatheringId: number) => {
+ const isValidGatheringId = !Number.isNaN(gatheringId) && gatheringId > 0
+
+ const results = useQueries({
+ queries: [
+ {
+ queryKey: meetingQueryKeys.approvalCount(gatheringId, 'PENDING'),
+ queryFn: () =>
+ getMeetingApprovals({
+ gatheringId,
+ status: 'PENDING',
+ page: 0,
+ size: 1,
+ }),
+ enabled: isValidGatheringId,
+ select: (data: PaginatedResponse) => data.totalCount,
+ gcTime: 10 * 60 * 1000,
+ },
+ {
+ queryKey: meetingQueryKeys.approvalCount(gatheringId, 'CONFIRMED'),
+ queryFn: () =>
+ getMeetingApprovals({
+ gatheringId,
+ status: 'CONFIRMED',
+ page: 0,
+ size: 1,
+ }),
+ enabled: isValidGatheringId,
+ select: (data: PaginatedResponse) => data.totalCount,
+ gcTime: 10 * 60 * 1000,
+ },
+ ],
+ })
+
+ return {
+ pendingCount: results[0].data,
+ confirmedCount: results[1].data,
+ isPendingLoading: results[0].isLoading,
+ isConfirmedLoading: results[1].isLoading,
+ isLoading: results[0].isLoading || results[1].isLoading,
+ pendingError: results[0].error,
+ confirmedError: results[1].error,
+ isError: results[0].isError || results[1].isError,
+ }
+}
diff --git a/src/features/meetings/hooks/useRejectMeeting.ts b/src/features/meetings/hooks/useRejectMeeting.ts
new file mode 100644
index 0000000..a1bf449
--- /dev/null
+++ b/src/features/meetings/hooks/useRejectMeeting.ts
@@ -0,0 +1,37 @@
+/**
+ * @file useRejectMeeting.ts
+ * @description 약속 거부 mutation 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import type { ApiError, ApiResponse } from '@/api'
+import { rejectMeeting, type RejectMeetingResponse } from '@/features/meetings'
+
+import { meetingQueryKeys } from './meetingQueryKeys'
+
+/**
+ * 약속 거부 mutation 훅
+ *
+ * @description
+ * 약속을 거부하고 관련 쿼리 캐시를 무효화합니다.
+ * - 약속 승인 리스트 캐시 무효화
+ * - 약속 승인 카운트 캐시 무효화
+ *
+ * @example
+ * const rejectMutation = useRejectMeeting()
+ * rejectMutation.mutate(meetingId)
+ */
+export const useRejectMeeting = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation, ApiError, number>({
+ mutationFn: (meetingId: number) => rejectMeeting(meetingId),
+ onSuccess: () => {
+ // 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트)
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.approvals(),
+ })
+ },
+ })
+}
diff --git a/src/features/meetings/index.ts b/src/features/meetings/index.ts
new file mode 100644
index 0000000..5f5ab81
--- /dev/null
+++ b/src/features/meetings/index.ts
@@ -0,0 +1,20 @@
+// Components
+export * from './components'
+
+// Hooks
+export * from './hooks'
+
+// Utils
+export * from './lib'
+
+// API
+export * from './meetings.api'
+
+// Types
+export type {
+ ConfirmMeetingResponse,
+ GetMeetingApprovalsParams,
+ MeetingApprovalItem as MeetingApprovalItemType,
+ MeetingStatus,
+ RejectMeetingResponse,
+} from './meetings.types'
diff --git a/src/features/meetings/lib/formatDateTime.ts b/src/features/meetings/lib/formatDateTime.ts
new file mode 100644
index 0000000..401da3b
--- /dev/null
+++ b/src/features/meetings/lib/formatDateTime.ts
@@ -0,0 +1,7 @@
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+
+export default function formatDateTime(dateTimeString: string): string {
+ const date = new Date(dateTimeString)
+ return format(date, 'yy.MM.dd(eee) HH:mm', { locale: ko })
+}
diff --git a/src/features/meetings/lib/index.ts b/src/features/meetings/lib/index.ts
new file mode 100644
index 0000000..4f2cf1a
--- /dev/null
+++ b/src/features/meetings/lib/index.ts
@@ -0,0 +1 @@
+export { default as formatDateTime } from './formatDateTime'
diff --git a/src/features/meetings/meetings.api.ts b/src/features/meetings/meetings.api.ts
new file mode 100644
index 0000000..6840f7a
--- /dev/null
+++ b/src/features/meetings/meetings.api.ts
@@ -0,0 +1,129 @@
+/**
+ * @file meetings.api.ts
+ * @description Meeting API 요청 함수
+ */
+
+import { api, apiClient } from '@/api/client'
+import type { ApiResponse, PaginatedResponse } from '@/api/types'
+import { getMockMeetingApprovals } from '@/features/meetings/meetings.mock'
+import type {
+ ConfirmMeetingResponse,
+ GetMeetingApprovalsParams,
+ MeetingApprovalItem,
+ RejectMeetingResponse,
+} from '@/features/meetings/meetings.types'
+import { PAGE_SIZES } from '@/shared/constants'
+
+/**
+ * 목데이터 사용 여부
+ * @description 로그인 기능 개발 전까지 true로 설정하여 목데이터 사용
+ * TODO: 로그인 기능 완료 후 false로 변경하여 실제 API 호출
+ */
+const USE_MOCK_DATA = true
+
+/**
+ * 약속 승인 리스트 조회
+ *
+ * @description
+ * 모임의 약속 승인 대기/완료 리스트를 페이지네이션으로 조회합니다.
+ *
+ * @param params - 조회 파라미터
+ * @param params.gatheringId - 모임 식별자
+ * @param params.status - 약속 상태 (PENDING: 확정 대기, CONFIRMED: 확정 완료)
+ * @param params.page - 페이지 번호 (기본값: 0)
+ * @param params.size - 페이지 크기 (기본값: 10)
+ * @param params.sort - 정렬 기준 배열
+ *
+ * @returns 약속 승인 리스트 페이지네이션 응답
+ * ```
+ */
+export const getMeetingApprovals = async (
+ params: GetMeetingApprovalsParams
+): Promise> => {
+ const { gatheringId, status, page = 0, size = PAGE_SIZES.MEETING_APPROVALS, sort } = params
+
+ // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용
+ // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거
+ if (USE_MOCK_DATA) {
+ // 실제 API 호출을 시뮬레이션하기 위한 지연
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return getMockMeetingApprovals(status, page, size)
+ }
+
+ // 실제 API 호출 (로그인 완료 후 사용)
+ return api.get>(
+ `/api/gatherings/${gatheringId}/meetings/approvals`,
+ {
+ params: {
+ status,
+ page,
+ size,
+ sort,
+ },
+ }
+ )
+}
+
+/**
+ * 약속 거부
+ *
+ * @description
+ * 약속을 거부합니다. (PENDING 상태만 거부 가능)
+ *
+ * @param meetingId - 약속 ID
+ *
+ * @returns 거부된 약속 정보와 서버 메시지
+ *
+ * @throws
+ * - M009: 약속 상태를 변경할 수 없습니다.
+ * - M001: 약속을 찾을 수 없습니다.
+ */
+export const rejectMeeting = async (meetingId: number) => {
+ const response = await apiClient.post>(
+ `/api/meetings/${meetingId}/reject`
+ )
+ return response.data
+}
+
+/**
+ * 약속 승인
+ *
+ * @description
+ * 약속을 승인합니다. (PENDING 상태만 승인 가능)
+ *
+ * @param meetingId - 약속 ID
+ *
+ * @returns 승인된 약속 정보와 서버 메시지
+ *
+ * @throws
+ * - M009: 약속 상태를 변경할 수 없습니다.
+ * - M001: 약속을 찾을 수 없습니다.
+ */
+export const confirmMeeting = async (meetingId: number) => {
+ const response = await apiClient.post>(
+ `/api/meetings/${meetingId}/confirm`
+ )
+ return response.data
+}
+
+/**
+ * 약속 삭제
+ *
+ * @description
+ * 약속을 삭제합니다.
+ * 권한: 모임장만 가능
+ * 제약: 약속 시작 24시간 이내 삭제 불가
+ *
+ * @param meetingId - 약속 ID
+ *
+ * @returns 삭제 성공 메시지
+ *
+ * @throws
+ * - M015: 약속 시작 24시간 이내에는 삭제할 수 없습니다.
+ * - ACCESS_DENIED: 접근 권한이 없습니다.
+ * - M001: 약속을 찾을 수 없습니다.
+ */
+export const deleteMeeting = async (meetingId: number) => {
+ const response = await apiClient.delete>(`/api/meetings/${meetingId}`)
+ return response.data
+}
diff --git a/src/features/meetings/meetings.mock.ts b/src/features/meetings/meetings.mock.ts
new file mode 100644
index 0000000..e14a6eb
--- /dev/null
+++ b/src/features/meetings/meetings.mock.ts
@@ -0,0 +1,199 @@
+/**
+ * @file meetings.mock.ts
+ * @description Meeting API 목데이터
+ */
+
+import type { PaginatedResponse } from '@/api/types'
+import type { MeetingApprovalItem } from '@/features/meetings/meetings.types'
+
+/**
+ * 약속 승인 리스트 목데이터 (확정 대기)
+ */
+const mockPendingMeetings: MeetingApprovalItem[] = [
+ {
+ meetingId: 1,
+ meetingName: '1차 독서모임',
+ bookName: '클린 코드',
+ nickname: '독서왕김민수',
+ startDateTime: '2026-02-01T14:00:00',
+ endDateTime: '2026-02-01T16:00:00',
+ meetingStatus: 'PENDING',
+ },
+ {
+ meetingId: 2,
+ meetingName: '2차 독서모임',
+ bookName: '리팩터링',
+ nickname: '코드리뷰어',
+ startDateTime: '2026-02-08T14:00:00',
+ endDateTime: '2026-02-08T16:00:00',
+ meetingStatus: 'PENDING',
+ },
+ {
+ meetingId: 3,
+ meetingName: '3차 독서모임',
+ bookName: '오브젝트',
+ nickname: '객체지향전문가',
+ startDateTime: '2026-02-15T14:00:00',
+ endDateTime: '2026-02-15T16:00:00',
+ meetingStatus: 'PENDING',
+ },
+ {
+ meetingId: 4,
+ meetingName: '4차 독서모임',
+ bookName: '테스트 주도 개발',
+ nickname: 'TDD실천가',
+ startDateTime: '2026-02-22T14:00:00',
+ endDateTime: '2026-02-22T16:00:00',
+ meetingStatus: 'PENDING',
+ },
+ {
+ meetingId: 5,
+ meetingName: '5차 독서모임',
+ bookName: '도메인 주도 설계',
+ nickname: 'DDD마스터',
+ startDateTime: '2026-03-01T14:00:00',
+ endDateTime: '2026-03-01T16:00:00',
+ meetingStatus: 'PENDING',
+ },
+]
+
+/**
+ * 약속 승인 리스트 목데이터 (확정 완료)
+ */
+const mockConfirmedMeetings: MeetingApprovalItem[] = [
+ {
+ meetingId: 11,
+ meetingName: '킥오프 모임',
+ bookName: '실용주의 프로그래머',
+ nickname: '프로그래머박지성',
+ startDateTime: '2026-01-11T14:00:00',
+ endDateTime: '2026-01-11T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 12,
+ meetingName: '정기 모임',
+ bookName: '함께 자라기',
+ nickname: '성장하는개발자',
+ startDateTime: '2026-01-18T14:00:00',
+ endDateTime: '2026-01-18T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 13,
+ meetingName: '심화 토론',
+ bookName: '이펙티브 타입스크립트',
+ nickname: '타입스크립트러버',
+ startDateTime: '2026-01-25T14:00:00',
+ endDateTime: '2026-01-25T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 14,
+ meetingName: '독서 토론 모임',
+ bookName: '클린 아키텍처',
+ nickname: '아키텍트김철수',
+ startDateTime: '2026-02-01T14:00:00',
+ endDateTime: '2026-02-01T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 15,
+ meetingName: '주말 특강',
+ bookName: '소프트웨어 장인',
+ nickname: '장인정신실천가',
+ startDateTime: '2026-02-08T10:00:00',
+ endDateTime: '2026-02-08T12:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 16,
+ meetingName: '코드 리뷰 세션',
+ bookName: '리팩터링 2판',
+ nickname: '리팩터링마스터',
+ startDateTime: '2026-02-15T14:00:00',
+ endDateTime: '2026-02-15T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 17,
+ meetingName: '알고리즘 스터디',
+ bookName: '알고리즘 문제 해결 전략',
+ nickname: '알고리즘천재',
+ startDateTime: '2026-02-22T14:00:00',
+ endDateTime: '2026-02-22T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 18,
+ meetingName: '디자인 패턴 스터디',
+ bookName: 'GoF의 디자인 패턴',
+ nickname: '패턴연구가',
+ startDateTime: '2026-03-01T14:00:00',
+ endDateTime: '2026-03-01T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 19,
+ meetingName: 'TDD 실습',
+ bookName: '테스트 주도 개발',
+ nickname: 'TDD전도사',
+ startDateTime: '2026-03-08T14:00:00',
+ endDateTime: '2026-03-08T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 20,
+ meetingName: '함수형 프로그래밍',
+ bookName: '함수형 자바스크립트',
+ nickname: 'FP러버',
+ startDateTime: '2026-03-15T14:00:00',
+ endDateTime: '2026-03-15T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 21,
+ meetingName: 'DevOps 세미나',
+ bookName: 'DevOps 핸드북',
+ nickname: 'DevOps엔지니어',
+ startDateTime: '2026-03-22T14:00:00',
+ endDateTime: '2026-03-22T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+ {
+ meetingId: 22,
+ meetingName: '마이크로서비스 아키텍처',
+ bookName: '마이크로서비스 패턴',
+ nickname: 'MSA전문가',
+ startDateTime: '2026-03-29T14:00:00',
+ endDateTime: '2026-03-29T16:00:00',
+ meetingStatus: 'CONFIRMED',
+ },
+]
+
+/**
+ * 목데이터 반환 함수
+ *
+ * @description
+ * 실제 API 호출을 시뮬레이션하여 목데이터를 페이지네이션 형태로 반환합니다.
+ */
+export const getMockMeetingApprovals = (
+ status: 'PENDING' | 'CONFIRMED',
+ page: number = 0,
+ size: number = 10
+): PaginatedResponse => {
+ const mockData = status === 'PENDING' ? mockPendingMeetings : mockConfirmedMeetings
+
+ // 페이지네이션 처리
+ const start = page * size
+ const end = start + size
+ const items = mockData.slice(start, end)
+
+ return {
+ items,
+ totalCount: mockData.length,
+ currentPage: page,
+ pageSize: size,
+ totalPages: Math.ceil(mockData.length / size),
+ }
+}
diff --git a/src/features/meetings/meetings.types.ts b/src/features/meetings/meetings.types.ts
new file mode 100644
index 0000000..857fafe
--- /dev/null
+++ b/src/features/meetings/meetings.types.ts
@@ -0,0 +1,69 @@
+/**
+ * @file types.ts
+ * @description Meeting API 관련 타입 정의
+ */
+
+/**
+ * 약속 상태 타입
+ */
+export type MeetingStatus = 'PENDING' | 'CONFIRMED'
+
+/**
+ * 약속 승인 아이템 타입
+ */
+export type MeetingApprovalItem = {
+ /** 약속 ID */
+ meetingId: number
+ /** 약속 이름 */
+ meetingName: string
+ /** 책 이름 */
+ bookName: string
+ /** 약속 신청자 닉네임 */
+ nickname: string
+ /** 시작 일시 (ISO 8601 형식) */
+ startDateTime: string
+ /** 종료 일시 (ISO 8601 형식) */
+ endDateTime: string
+ /** 약속 상태 */
+ meetingStatus: MeetingStatus
+}
+
+/**
+ * 약속 승인 리스트 조회 요청 파라미터
+ */
+export type GetMeetingApprovalsParams = {
+ /** 모임 식별자 */
+ gatheringId: number
+ /** 약속 상태 */
+ status: MeetingStatus
+ /** 페이지 번호 (0부터 시작) */
+ page?: number
+ /** 페이지 크기 */
+ size?: number
+ /** 정렬 기준 */
+ sort?: string[]
+}
+
+/**
+ * 약속 거부 응답 타입
+ */
+export type RejectMeetingResponse = {
+ /** 약속 ID */
+ meetingId: number
+ /** 약속 상태 */
+ meetingStatus: 'REJECTED'
+ /** 확정 시각 */
+ confirmedAt: null
+}
+
+/**
+ * 약속 승인 응답 타입
+ */
+export type ConfirmMeetingResponse = {
+ /** 약속 ID */
+ meetingId: number
+ /** 약속 상태 */
+ meetingStatus: 'CONFIRMED'
+ /** 확정 시각 (ISO 8601 형식) */
+ confirmedAt: string
+}
diff --git a/src/pages/ComponentGuide/ComponentGuidePage.tsx b/src/pages/ComponentGuide/ComponentGuidePage.tsx
index 82cab03..42c679b 100644
--- a/src/pages/ComponentGuide/ComponentGuidePage.tsx
+++ b/src/pages/ComponentGuide/ComponentGuidePage.tsx
@@ -30,8 +30,8 @@ import {
Pagination,
SearchField,
Select,
- type StarRatingRange,
StarRatingFilter,
+ type StarRatingRange,
Switch,
Tabs,
TabsContent,
diff --git a/src/pages/Meetings/MeetingSettingPage.tsx b/src/pages/Meetings/MeetingSettingPage.tsx
new file mode 100644
index 0000000..1c3d1e3
--- /dev/null
+++ b/src/pages/Meetings/MeetingSettingPage.tsx
@@ -0,0 +1,85 @@
+import { useEffect, useState } from 'react'
+import { useParams } from 'react-router-dom'
+
+import {
+ MeetingApprovalList,
+ type MeetingStatus,
+ useMeetingApprovalsCount,
+} from '@/features/meetings'
+import { Container } from '@/shared/ui/Container'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/Tabs'
+import { useGlobalModalStore } from '@/store'
+
+type MeetingTab = Extract
+
+export default function MeetingSettingPage() {
+ const { id } = useParams<{ id: string }>()
+ const gatheringId = Number(id)
+ const [activeTab, setActiveTab] = useState('PENDING')
+ const { openError } = useGlobalModalStore()
+
+ // 각 탭의 totalCount만 가져오기
+ const {
+ pendingCount,
+ confirmedCount,
+ isPendingLoading,
+ isConfirmedLoading,
+ pendingError,
+ confirmedError,
+ } = useMeetingApprovalsCount(gatheringId)
+
+ // 에러 발생 시 모달 표시
+ useEffect(() => {
+ if (pendingError) {
+ openError('오류', '확정 대기 약속 수를 불러오는 데 실패했습니다.')
+ }
+ if (confirmedError) {
+ openError('오류', '확정 완료 약속 수를 불러오는 데 실패했습니다.')
+ }
+ }, [pendingError, confirmedError, openError])
+
+ return (
+
+ {/* 공통컴포넌트로 대체 예정 */}
+ {/*
뒤로가기
+
약속 설정
*/}
+
+
+ 약속 관리
+
+ setActiveTab(value as MeetingTab)}
+ className="gap-0"
+ >
+
+
+ 확정 대기
+
+
+ 확정 완료
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/Meetings/index.ts b/src/pages/Meetings/index.ts
index 852c7ca..56e962a 100644
--- a/src/pages/Meetings/index.ts
+++ b/src/pages/Meetings/index.ts
@@ -1 +1,2 @@
export { default as MeetingListPage } from './MeetingListPage'
+export { default as MeetingSettingPage } from './MeetingSettingPage'
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 9769dbd..fa78a49 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -9,6 +9,7 @@ import {
HomePage,
LoginPage,
MeetingListPage,
+ MeetingSettingPage,
OnboardingPage,
RecordListPage,
} from '@/pages'
@@ -81,6 +82,10 @@ export const router = createBrowserRouter([
path: ROUTES.MEETINGS,
element: ,
},
+ {
+ path: `${ROUTES.GATHERINGS}/:id/${ROUTES.MEETING_SETTING}`,
+ element: ,
+ },
{
path: ROUTES.RECORDS,
element: ,
diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts
index 7fc55b9..a72805d 100644
--- a/src/shared/constants/routes.ts
+++ b/src/shared/constants/routes.ts
@@ -19,6 +19,9 @@ export const ROUTES = {
// Meetings
MEETINGS: '/meetings',
MEETING_DETAIL: (id: number | string) => `/meetings/${id}`,
+ MEETING_CREATE: '/meetings/create',
+ MEETING_UPDATE: '/meetings/update',
+ MEETING_SETTING: '/meetings/setting',
// Records
RECORDS: '/records',
diff --git a/src/shared/ui/GlobalModalHost.tsx b/src/shared/ui/GlobalModalHost.tsx
new file mode 100644
index 0000000..cf59206
--- /dev/null
+++ b/src/shared/ui/GlobalModalHost.tsx
@@ -0,0 +1,78 @@
+/**
+ * @file GlobalModalHost.tsx
+ * @description 전역 모달 호스트 컴포넌트
+ */
+
+import { Button } from '@/shared/ui/Button'
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalTitle,
+} from '@/shared/ui/Modal'
+import { useGlobalModalStore } from '@/store'
+
+/**
+ * 전역 모달 호스트
+ *
+ * @description
+ * Store의 모달 상태를 구독하여 alert, error, confirm 모달을 렌더링합니다.
+ * App 또는 Layout 컴포넌트에서 한 번만 사용하세요.
+ *
+ * @example
+ * ```tsx
+ * // App.tsx
+ * function App() {
+ * return (
+ * <>
+ *
+ *
+ * >
+ * )
+ * }
+ *
+ * // 사용
+ * const { openAlert, openError, openConfirm } = useGlobalModalStore()
+ *
+ * openAlert('제목', '메시지')
+ * openError('오류 메시지')
+ * const confirmed = await openConfirm('제목', '메시지')
+ * ```
+ */
+export function GlobalModalHost() {
+ const { isOpen, type, title, description, buttons } = useGlobalModalStore()
+
+ if (!isOpen) {
+ return null
+ }
+
+ const footerVariant = type === 'confirm' ? 'double' : 'full'
+ //에러, 얼럿일 경우 디자인 맞춰서 수정해야 함
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+ {buttons.map((button, index) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/store/globalModalStore.ts b/src/store/globalModalStore.ts
new file mode 100644
index 0000000..34cc5e5
--- /dev/null
+++ b/src/store/globalModalStore.ts
@@ -0,0 +1,142 @@
+/**
+ * @file globalModalStore.ts
+ * @description 전역 모달 상태 관리 스토어 (Zustand)
+ */
+
+import { create } from 'zustand'
+
+import type { ButtonProps } from '@/shared/ui/Button'
+
+/** 모달 타입 */
+export type ModalType = 'alert' | 'error' | 'confirm'
+
+/** 모달 버튼 variant (Button 컴포넌트의 variant와 호환) */
+export type ModalButtonVariant = Extract
+
+/** 모달 버튼 설정 */
+export type ModalButton = {
+ /** 버튼 텍스트 */
+ text: string
+ /** 버튼 variant */
+ variant?: ModalButtonVariant
+ /** 클릭 핸들러 */
+ onClick?: () => void
+}
+
+/** 모달 상태 */
+export type ModalState = {
+ /** 모달 열림 여부 */
+ isOpen: boolean
+ /** 모달 타입 */
+ type: ModalType | null
+ /** 모달 제목 */
+ title: string
+ /** 모달 설명 */
+ description: string
+ /** 모달 버튼 목록 */
+ buttons: ModalButton[]
+}
+
+/** Confirm 모달 옵션 */
+export type ConfirmModalOptions = {
+ /** 확인 버튼 텍스트 (기본값: '확인') */
+ confirmText?: string
+ /** 확인 버튼 variant (기본값: 'primary') */
+ variant?: Extract
+}
+
+/** 전역 모달 스토어 타입 */
+type GlobalModalStore = ModalState & {
+ /** Alert 모달 열기 */
+ openAlert: (title: string, description: string) => void
+ /** Error 모달 열기 */
+ openError: (title: string, description: string, onClose?: () => void) => void
+ /** Confirm 모달 열기 (Promise 반환) */
+ openConfirm: (title: string, description: string, options?: ConfirmModalOptions) => Promise
+ /** 모달 닫기 */
+ close: () => void
+}
+
+const initialState: ModalState = {
+ isOpen: false,
+ type: null,
+ title: '',
+ description: '',
+ buttons: [],
+}
+
+export const useGlobalModalStore = create((set, get) => ({
+ ...initialState,
+
+ openAlert: (title: string, description: string) => {
+ set({
+ isOpen: true,
+ type: 'alert',
+ title,
+ description,
+ buttons: [
+ {
+ text: '확인',
+ variant: 'primary',
+ onClick: () => get().close(),
+ },
+ ],
+ })
+ },
+
+ openError: (title: string, description: string, onClose?: () => void) => {
+ set({
+ isOpen: true,
+ type: 'error',
+ title,
+ description,
+ buttons: [
+ {
+ text: '확인',
+ variant: 'primary',
+ onClick: () => {
+ get().close()
+ onClose?.()
+ },
+ },
+ ],
+ })
+ },
+
+ openConfirm: (title: string, description: string, options: ConfirmModalOptions = {}) => {
+ return new Promise((resolve) => {
+ const handleConfirm = () => {
+ resolve(true)
+ get().close()
+ }
+
+ const handleCancel = () => {
+ resolve(false)
+ get().close()
+ }
+
+ set({
+ isOpen: true,
+ type: 'confirm',
+ title,
+ description,
+ buttons: [
+ {
+ text: '취소',
+ variant: 'secondary',
+ onClick: handleCancel,
+ },
+ {
+ text: options?.confirmText || '확인',
+ variant: options?.variant || 'primary',
+ onClick: handleConfirm,
+ },
+ ],
+ })
+ })
+ },
+
+ close: () => {
+ set(initialState)
+ },
+}))
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 0000000..9b52ed1
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1,8 @@
+export {
+ type ConfirmModalOptions,
+ type ModalButton,
+ type ModalButtonVariant,
+ type ModalState,
+ type ModalType,
+ useGlobalModalStore,
+} from './globalModalStore'