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 ( +
    + + + {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'