diff --git a/src/features/topics/components/ConfirmModalTopicCard.tsx b/src/features/topics/components/ConfirmModalTopicCard.tsx new file mode 100644 index 0000000..ffa8c87 --- /dev/null +++ b/src/features/topics/components/ConfirmModalTopicCard.tsx @@ -0,0 +1,49 @@ +import { useRef } from 'react' + +import { cn } from '@/shared/lib/utils' +import { Badge, Card } from '@/shared/ui' +import { NumberedCheckbox } from '@/shared/ui/NumberedCheckbox' + +type ConfirmModalTopicCardProps = { + title: string + topicTypeLabel: string + description: string + createdByNickname: string + topicId: number + isSelected: boolean +} + +export default function ConfirmModalTopicCard({ + title, + topicTypeLabel, + description, + createdByNickname, + topicId, + isSelected, +}: ConfirmModalTopicCardProps) { + const checkboxRef = useRef(null) + + return ( +
checkboxRef.current?.click()}> + +
e.stopPropagation()}> + +
+
+
+
+

{title}

+ + {topicTypeLabel} + +
+
+

{description}

+

제안 : {createdByNickname}

+
+
+
+ ) +} diff --git a/src/features/topics/components/ConfirmTopicModal.tsx b/src/features/topics/components/ConfirmTopicModal.tsx new file mode 100644 index 0000000..f12b01c --- /dev/null +++ b/src/features/topics/components/ConfirmTopicModal.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react' + +import { ConfirmModalTopicCard, TopicListSkeleton } from '@/features/topics/components' +import { useConfirmTopics, useProposedTopics } from '@/features/topics/hooks' +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, + TextButton, +} from '@/shared/ui' +import { NumberedCheckboxGroup } from '@/shared/ui/NumberedCheckbox' +import { useGlobalModalStore } from '@/store' + +export type ConfirmTopicModalProps = { + /** 모달 열림 상태 */ + open: boolean + /** 모달 열림 상태 변경 핸들러 */ + onOpenChange: (open: boolean) => void + gatheringId: number + meetingId: number +} + +export default function ConfirmTopicModal({ + open, + onOpenChange, + gatheringId, + meetingId, +}: ConfirmTopicModalProps) { + const [selectedTopicIds, setSelectedTopicIds] = useState([]) + + const { openConfirm, openError } = useGlobalModalStore() + const confirmMutation = useConfirmTopics() + + // 모달 전용 독립 데이터 - 한 번에 전체 로드 (pageSize 크게) + const { data: topicsInfiniteData, isLoading } = useProposedTopics({ + gatheringId, + meetingId, + pageSize: 100, + }) + + const topics = topicsInfiniteData?.pages.flatMap((page) => page.items) ?? [] + + const resetSelected = () => { + setSelectedTopicIds([]) + } + + const handleClose = () => { + resetSelected() + onOpenChange(false) + } + + const handleConfirm = async () => { + const confirmed = await openConfirm( + '주제 확정', + `한 번 확정하면 이후에는 순서를 바꾸거나 주제를 추가하기 어려워요. \n이대로 확정할까요?`, + { confirmText: '확정하기' } + ) + + if (!confirmed) return + + confirmMutation.mutate( + { + gatheringId, + meetingId, + topicIds: selectedTopicIds.map(Number), + }, + { + onSuccess: () => { + handleClose() + }, + onError: (error) => { + openError('확정 실패', error.userMessage) + }, + } + ) + } + + return ( + + e.preventDefault()}> + + +
+

주제 확정하기

+
+

확정할 주제를 순서대로 선택해주세요

+ {selectedTopicIds.length > 0 && ( + + 전체해제 + + )} +
+
+
+
+ + + {isLoading ? ( + + ) : ( + + {topics.map((topic) => ( + + ))} + + )} + + + +
+

+ 선택 {selectedTopicIds.length}개 +

+ +
+
+
+
+ ) +} diff --git a/src/features/topics/components/TopicHeader.tsx b/src/features/topics/components/TopicHeader.tsx index 329d88d..5f43a9d 100644 --- a/src/features/topics/components/TopicHeader.tsx +++ b/src/features/topics/components/TopicHeader.tsx @@ -8,6 +8,8 @@ type ProposedHeaderProps = { actions: { canConfirm: boolean; canSuggest: boolean } confirmedTopic: boolean confirmedTopicDate: string | null + proposedTopicsCount: number + onOpenChange: (open: boolean) => void } type ConfirmedHeaderProps = { @@ -50,8 +52,8 @@ export default function TopicHeader(props: TopicHeaderProps) {
- {props.actions.canConfirm && ( - )} diff --git a/src/features/topics/components/index.ts b/src/features/topics/components/index.ts index 5825bdb..e77446c 100644 --- a/src/features/topics/components/index.ts +++ b/src/features/topics/components/index.ts @@ -1,4 +1,6 @@ export { default as ConfirmedTopicList } from './ConfirmedTopicList' +export { default as ConfirmModalTopicCard } from './ConfirmModalTopicCard' +export { default as ConfirmTopicModal } from './ConfirmTopicModal' export { default as DefaultTopicCard } from './DefaultTopicCard' export { default as EmptyTopicList } from './EmptyTopicList' export { default as ProposedTopicList } from './ProposedTopicList' diff --git a/src/features/topics/hooks/index.ts b/src/features/topics/hooks/index.ts index 6681b5b..672efba 100644 --- a/src/features/topics/hooks/index.ts +++ b/src/features/topics/hooks/index.ts @@ -1,5 +1,6 @@ export * from './topicQueryKeys' export * from './useConfirmedTopics' +export * from './useConfirmTopics' export * from './useCreateTopic' export * from './useDeleteTopic' export * from './useLikeTopic' diff --git a/src/features/topics/hooks/useConfirmTopics.ts b/src/features/topics/hooks/useConfirmTopics.ts new file mode 100644 index 0000000..ca2a2c7 --- /dev/null +++ b/src/features/topics/hooks/useConfirmTopics.ts @@ -0,0 +1,60 @@ +/** + * @file useConfirmTopics.ts + * @description 주제 확정 mutation 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '@/api/errors' + +import { confirmTopics } from '../topics.api' +import type { ConfirmTopicsParams, ConfirmTopicsResponse } from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 주제 확정 mutation 훅 + * + * @description + * 선택한 주제들을 순서대로 확정하고 관련 쿼리 캐시를 무효화합니다. + * - 제안된 주제 리스트 캐시 무효화 + * - 확정된 주제 리스트 캐시 무효화 + * + * @example + * ```tsx + * const confirmMutation = useConfirmTopics() + * confirmMutation.mutate( + * { gatheringId: 1, meetingId: 2, topicIds: [3, 1, 2] }, + * { + * onSuccess: () => { + * console.log('주제가 확정되었습니다.') + * }, + * onError: (error) => { + * console.error('확정 실패:', error.userMessage) + * }, + * } + * ) + * ``` + */ +export const useConfirmTopics = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: ConfirmTopicsParams) => confirmTopics(params), + onSuccess: (_, variables) => { + // 제안된 주제 무효화 + queryClient.invalidateQueries({ + queryKey: topicQueryKeys.proposedList({ + gatheringId: variables.gatheringId, + meetingId: variables.meetingId, + }), + }) + // 확정된 주제 무효화 + queryClient.invalidateQueries({ + queryKey: topicQueryKeys.confirmedList({ + gatheringId: variables.gatheringId, + meetingId: variables.meetingId, + }), + }) + }, + }) +} diff --git a/src/features/topics/topics.api.ts b/src/features/topics/topics.api.ts index 703aad3..9459738 100644 --- a/src/features/topics/topics.api.ts +++ b/src/features/topics/topics.api.ts @@ -7,8 +7,10 @@ import { api } from '@/api/client' import { PAGE_SIZES } from '@/shared/constants' import { TOPICS_ENDPOINTS } from './topics.endpoints' -import { getMockConfirmedTopics, getMockProposedTopics } from './topics.mock' +import { getMockConfirmedTopics, getMockConfirmTopics, getMockProposedTopics } from './topics.mock' import type { + ConfirmTopicsParams, + ConfirmTopicsResponse, CreateTopicParams, CreateTopicResponse, DeleteTopicParams, @@ -211,3 +213,35 @@ export const likeTopicToggle = async (params: LikeTopicParams): Promise(TOPICS_ENDPOINTS.LIKE_TOGGLE(gatheringId, meetingId, topicId)) } + +/** + * 주제 확정 + * + * @description + * 선택한 주제들을 순서대로 확정합니다. + * + * @param params - 확정 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.topicIds - 확정할 주제 ID 목록 (순서대로) + * + * @returns 확정된 주제 정보 + */ +export const confirmTopics = async ( + params: ConfirmTopicsParams +): Promise => { + const { gatheringId, meetingId, topicIds } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockConfirmTopics(meetingId, topicIds) + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.post(TOPICS_ENDPOINTS.CONFIRM(gatheringId, meetingId), { + topicIds, + }) +} diff --git a/src/features/topics/topics.endpoints.ts b/src/features/topics/topics.endpoints.ts index 57e9232..391a01e 100644 --- a/src/features/topics/topics.endpoints.ts +++ b/src/features/topics/topics.endpoints.ts @@ -17,6 +17,9 @@ export const TOPICS_ENDPOINTS = { LIKE_TOGGLE: (gatheringId: number, meetingId: number, topicId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/${topicId}/likes`, + // 주제 확정 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/confirm) + CONFIRM: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/confirm`, // 주제 제안 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics) CREATE: (gatheringId: number, meetingId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics`, diff --git a/src/features/topics/topics.mock.ts b/src/features/topics/topics.mock.ts index 69feca6..e535579 100644 --- a/src/features/topics/topics.mock.ts +++ b/src/features/topics/topics.mock.ts @@ -5,6 +5,7 @@ import type { ConfirmedTopicItem, + ConfirmTopicsResponse, GetConfirmedTopicsResponse, GetProposedTopicsResponse, ProposedTopicItem, @@ -435,7 +436,7 @@ export const getMockProposedTopics = ( nextCursor, totalCount: cursorLikeCount === undefined ? mockProposedTopics.length : undefined, actions: { - canConfirm: false, + canConfirm: true, canSuggest: true, }, } @@ -489,3 +490,23 @@ export const getMockConfirmedTopics = ( }, } } + +/** + * 주제 확정 목데이터 반환 함수 + * + * @description + * 실제 API 호출을 시뮬레이션하여 주제 확정 응답 목데이터를 반환합니다. + */ +export const getMockConfirmTopics = ( + meetingId: number, + topicIds: number[] +): ConfirmTopicsResponse => { + return { + meetingId, + topicStatus: 'CONFIRMED', + topics: topicIds.map((topicId, index) => ({ + topicId, + confirmOrder: index + 1, + })), + } +} diff --git a/src/features/topics/topics.types.ts b/src/features/topics/topics.types.ts index e973a70..68e8b5a 100644 --- a/src/features/topics/topics.types.ts +++ b/src/features/topics/topics.types.ts @@ -35,7 +35,7 @@ export type ProposedTopicItem = { meetingId: number /** 주제 제목 */ title: string - /** 주제 설명 */ + /** 주제 설명 (Todo : 없을떄 null인지 빈값인지 체크해야함)*/ description: string /** 주제 타입 */ topicType: TopicType @@ -64,7 +64,7 @@ export type ConfirmedTopicItem = { topicId: number /** 주제 제목 */ title: string - /** 주제 설명 */ + /** 주제 설명 (Todo : 없을떄 null인지 빈값인지 체크해야함)*/ description: string /** 주제 타입 */ topicType: TopicType @@ -199,6 +199,33 @@ export type LikeTopicResponse = { newCount: number } +/** + * 주제 확정 요청 파라미터 + */ +export type ConfirmTopicsParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 확정할 주제 ID 목록 (순서대로) */ + topicIds: number[] +} + +/** + * 주제 확정 응답 + */ +export type ConfirmTopicsResponse = { + /** 약속 식별자 */ + meetingId: number + /** 주제 상태 */ + topicStatus: TopicStatus + /** 확정된 주제 목록 */ + topics: Array<{ + topicId: number + confirmOrder: number + }> +} + /** * 주제 제안 요청 파라미터 */ diff --git a/src/pages/Meetings/MeetingDetailPage.tsx b/src/pages/Meetings/MeetingDetailPage.tsx index 7686f4f..cbef6d5 100644 --- a/src/pages/Meetings/MeetingDetailPage.tsx +++ b/src/pages/Meetings/MeetingDetailPage.tsx @@ -15,6 +15,7 @@ import type { } from '@/features/topics' import { ConfirmedTopicList, + ConfirmTopicModal, ProposedTopicList, TopicHeader, useConfirmedTopics, @@ -26,6 +27,7 @@ export default function MeetingDetailPage() { const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>() const [activeTab, setActiveTab] = useState('PROPOSED') + const [isConfirmTopicOpen, setIsConfirmTopicOpen] = useState(false) const { data: meeting, isLoading, error } = useMeetingDetail(Number(meetingId)) @@ -109,7 +111,7 @@ export default function MeetingDetailPage() {
{/* 약속 로딩 적용 */} -
+

주제

+ {isConfirmTopicOpen && ( + + )} ) } diff --git a/src/shared/ui/NumberedCheckbox.tsx b/src/shared/ui/NumberedCheckbox.tsx index 4f288ae..b0c56a8 100644 --- a/src/shared/ui/NumberedCheckbox.tsx +++ b/src/shared/ui/NumberedCheckbox.tsx @@ -1,4 +1,4 @@ -import { createContext, type ReactNode, useContext } from 'react' +import { createContext, forwardRef, type ReactNode, useContext } from 'react' import { cn } from '@/shared/lib/utils' @@ -68,38 +68,41 @@ export interface NumberedCheckboxProps { disabled?: boolean } -function NumberedCheckbox({ id, className, children, disabled = false }: NumberedCheckboxProps) { - const { selected, toggle } = useNumberedCheckboxContext() +const NumberedCheckbox = forwardRef( + function NumberedCheckbox({ id, className, children, disabled = false }, ref) { + const { selected, toggle } = useNumberedCheckboxContext() - const index = selected.indexOf(id) - const isChecked = index !== -1 - const displayNumber = isChecked ? index + 1 : undefined + const index = selected.indexOf(id) + const isChecked = index !== -1 + const displayNumber = isChecked ? index + 1 : undefined - return ( -
- - {children && {children}} -
- ) -} + return ( +
+ + {children && {children}} +
+ ) + } +) export { NumberedCheckbox, NumberedCheckboxGroup }