diff --git a/src/pages/ChatRoomPage.tsx b/src/pages/ChatRoomPage.tsx index 5710ae6..a6757db 100644 --- a/src/pages/ChatRoomPage.tsx +++ b/src/pages/ChatRoomPage.tsx @@ -5,10 +5,11 @@ import { Client } from '@stomp/stompjs' import type { IMessage } from '@stomp/stompjs' import SockJS from 'sockjs-client' import toast from 'react-hot-toast' -import { ChevronLeft, Menu, X, LogOut, User, Users, Crown } from 'lucide-react' +import { ChevronLeft, Menu, X, LogOut, User, Users, Crown, MoreHorizontal } from 'lucide-react' import MessageBubble from '@/components/chat/MessageBubble' import MessageInput from '@/components/chat/MessageInput' import { getChatMessages, markAsRead, leaveChatRoom, getChatRoomMembers } from '@/services/chatApi' +import { kickRoommate } from '@/services/roomApi' import { useChatStore } from '@/store/chatStore' import { API_BASE_URL } from '@/utils/api' import apiClient from '@/services/apiClient' @@ -20,6 +21,17 @@ interface RoomMember { isHost: boolean } +interface RoomMemberWithRoomMeta extends RoomMember { + confirmStatus?: 'PENDING' | 'ACCEPTED' | 'COMPLETED' + isMe?: boolean +} + +interface NotificationMessage { + type: 'KICKED_FROM_ROOM' | 'ROOM_DELETED' + roomNo: string + chatRoomNo: string +} + const ChatRoomPage = () => { const { chatRoomNo } = useParams<{ chatRoomNo: string }>() const navigate = useNavigate() @@ -32,6 +44,7 @@ const ChatRoomPage = () => { prependMessages, appendMessage, updateRoomOnNewMessage, + removeChatRoom, } = useChatStore() const currentRoom = chatRooms.find(r => r.chatRoomNo === chatRoomNo) @@ -50,13 +63,18 @@ const ChatRoomPage = () => { const [connected, setConnected] = useState(false) const [showInfoPanel, setShowInfoPanel] = useState(false) const [showLeaveConfirm, setShowLeaveConfirm] = useState(false) - const [members, setMembers] = useState([]) + const [members, setMembers] = useState([]) + const [openMemberMenuUserNo, setOpenMemberMenuUserNo] = useState(null) + const [roommateToKick, setRoommateToKick] = useState(null) const [isWideLayout, setIsWideLayout] = useState(false) const [containerRightOffset, setContainerRightOffset] = useState(0) const stompClientRef = useRef(null) const containerRef = useRef(null) const messagesEndRef = useRef(null) + const fetchMembersRef = useRef<() => Promise>(() => Promise.resolve()) + const markAsReadAndSyncRef = useRef<() => Promise>(() => Promise.resolve()) + const fetchMessagesRef = useRef<(opts?: { silent?: boolean }) => Promise>(() => Promise.resolve()) const messagesContainerRef = useRef(null) const myUserNoRef = useRef(myUserNo) @@ -82,6 +100,14 @@ const ChatRoomPage = () => { myUserNoRef.current = myUserNo }, [myUserNo]) + useEffect(() => { + setMembers([]) + setOpenMemberMenuUserNo(null) + setRoommateToKick(null) + setShowInfoPanel(false) + setShowLeaveConfirm(false) + }, [chatRoomNo]) + const fetchMessages = useCallback(async (options?: { silent?: boolean }) => { if (!chatRoomNo) return if (!options?.silent) { @@ -152,19 +178,66 @@ const ChatRoomPage = () => { }, []) // 멤버 목록 조회 - const fetchMembers = useCallback(() => { - if (!chatRoomNo || isDirect) return - getChatRoomMembers(chatRoomNo) - .then(res => setMembers(res.data)) - .catch(() => { }) - }, [chatRoomNo, isDirect]) + const fetchMembers = useCallback(async () => { + if (!chatRoomNo || isDirect || !currentRoom?.roomNo) return + + try { + const [chatMembersRes, roommatesRes] = await Promise.all([ + getChatRoomMembers(chatRoomNo), + apiClient.get('/api/rooms/me/roommates'), + ]) + + const chatMembers = Array.isArray(chatMembersRes.data) ? chatMembersRes.data : [] + const roommates = Array.isArray(roommatesRes.data) ? roommatesRes.data : [] + + const roommateMap = new Map( + roommates.map((mate: { userNo: string | number; confirmStatus?: string; isMe?: boolean }) => [ + String(mate.userNo), + { + confirmStatus: mate.confirmStatus, + isMe: mate.isMe, + }, + ]) + ) + + const merged: RoomMemberWithRoomMeta[] = chatMembers.map((member: RoomMember) => { + const extra = roommateMap.get(String(member.userNo)) + return { + ...member, + confirmStatus: extra?.confirmStatus as RoomMemberWithRoomMeta['confirmStatus'], + isMe: extra?.isMe, + } + }) + + setMembers(merged) + } catch { + // noop + } + }, [chatRoomNo, isDirect, currentRoom?.roomNo]) + + const amIHost = members.some((member) => member.userNo === myUserNo && member.isHost) + const isRoomCompleted = members.length > 0 && members.every( + (member) => member.confirmStatus === 'COMPLETED' + ) + + const canKickMember = (member: RoomMemberWithRoomMeta) => + amIHost && + !isRoomCompleted && + !member.isMe && + !member.isHost && + currentRoom?.chatRoomType === 'GROUP' + + // ref를 최신 함수로 동기화 (STOMP 클로저에서 stale 방지) + useEffect(() => { fetchMembersRef.current = fetchMembers }, [fetchMembers]) + useEffect(() => { markAsReadAndSyncRef.current = markAsReadAndSync }, [markAsReadAndSync]) + useEffect(() => { fetchMessagesRef.current = fetchMessages }, [fetchMessages]) // 패널 열릴 때 멤버 목록 로드 useEffect(() => { if (showInfoPanel && !isDirect) { - fetchMembers() + void fetchMembers() } - }, [showInfoPanel]) + }, [showInfoPanel, isDirect, fetchMembers]) // STOMP 연결 useEffect(() => { @@ -182,13 +255,13 @@ const ChatRoomPage = () => { updateRoomOnNewMessage(chatRoomNo, message) // 시스템 메시지(입장/퇴장)이면 멤버 목록 갱신 - if (message.messageType === 'SYSTEM' && showInfoPanelRef.current && !isDirect) { - fetchMembers() + if (message.messageType === 'SYSTEM' && showInfoPanelRef.current) { + void fetchMembersRef.current() } // 채팅방을 보고 있는 중이면 즉시 읽음 처리 if (document.visibilityState === 'visible') { - void markAsReadAndSync() + void markAsReadAndSyncRef.current() } // 하단 근처에 있을 때만 자동 스크롤 @@ -203,7 +276,31 @@ const ChatRoomPage = () => { }) client.subscribe(`/topic/chat-room/${chatRoomNo}/read`, () => { - void fetchMessages({ silent: true }) + void fetchMessagesRef.current({ silent: true }) + }) + + client.subscribe('/user/queue/notification', (msg: IMessage) => { + let notification: NotificationMessage + try { + notification = JSON.parse(msg.body) as NotificationMessage + } catch { + return + } + + if (notification.chatRoomNo !== chatRoomNo) return + + if (notification.type === 'KICKED_FROM_ROOM') { + removeChatRoom(notification.chatRoomNo) + toast.error('방에서 강퇴되었습니다.') + navigate('/rooms/search') + return + } + + if (notification.type === 'ROOM_DELETED') { + removeChatRoom(notification.chatRoomNo) + toast.error('방이 삭제되었습니다.') + navigate('/rooms/search') + } }) client.subscribe('/user/queue/errors', (msg: IMessage) => { @@ -221,7 +318,7 @@ const ChatRoomPage = () => { client.deactivate() stompClientRef.current = null } - }, [chatRoomNo]) + }, [chatRoomNo, appendMessage, updateRoomOnNewMessage, removeChatRoom, navigate]) // showInfoPanel을 ref로 STOMP 콜백에서 최신 값 참조 const showInfoPanelRef = useRef(showInfoPanel) @@ -293,6 +390,7 @@ const ChatRoomPage = () => { if (!chatRoomNo) return try { await leaveChatRoom(chatRoomNo) + removeChatRoom(chatRoomNo) toast.success('채팅방을 나갔습니다.') navigate('/chats') } catch (e: unknown) { @@ -308,6 +406,29 @@ const ChatRoomPage = () => { } } + const handleKickFromChatPanel = async () => { + if (!currentRoom?.roomNo || !roommateToKick) return + + try { + await kickRoommate(String(currentRoom.roomNo), roommateToKick.userNo) + toast.success(`${roommateToKick.nickname}님을 내보냈습니다.`) + setRoommateToKick(null) + setOpenMemberMenuUserNo(null) + void fetchMembers() + } catch (e: unknown) { + const code = (e as { response?: { data?: { code?: string } } })?.response?.data?.code + if (code === 'ROOM005') { + toast.error('방장만 룸메이트를 내보낼 수 있습니다.') + } else if (code === 'ROOM009') { + toast.error('룸메이트를 찾을 수 없습니다.') + } else if (code === 'ROOM012') { + toast.error('자기 자신은 내보낼 수 없습니다.') + } else { + toast.error('내보내기에 실패했습니다.') + } + } + } + return (
{

{members.map(member => ( -
-
- -
-
- {member.nickname} - {member.isHost && ( - - )} - {member.userNo === myUserNo && ( - (나) - )} +
+
+
+ +
+
+ {member.nickname} + {member.isHost && ( + + )} + {member.userNo === myUserNo && ( + (나) + )} +
+ + {canKickMember(member) && ( +
+ + + {openMemberMenuUserNo === member.userNo && ( +
+ +
+ )} +
+ )}
))}
@@ -502,6 +653,32 @@ const ChatRoomPage = () => {
)} + + {roommateToKick && ( +
+
+

룸메이트 내보내기

+

+ {roommateToKick.nickname}님을 내보내시겠습니까?
+ 방이 최종 확정된 뒤에는 내보낼 수 없습니다. +

+
+ + +
+
+
+ )}
) } diff --git a/src/pages/MyRoomPage.tsx b/src/pages/MyRoomPage.tsx index 7db01a7..213d3a3 100644 --- a/src/pages/MyRoomPage.tsx +++ b/src/pages/MyRoomPage.tsx @@ -1,15 +1,23 @@ import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Bell, Share2, Pencil, Settings, DoorOpen, CheckCircle, Star } from 'lucide-react' +import { Bell, Share2, Pencil, Settings, DoorOpen, CheckCircle, Star, Trash2 } from 'lucide-react' import BottomNavigationBar from '../components/ui/BottomNavigationBar' import SectionLoading from '../components/ui/SectionLoading' import GuestOnlyMessage from '../components/ui/GuestOnlyMessage' import { getApiUrl } from '../utils/api' -import { getOrCreateDirectChatRoom } from '@/services/chatApi' +import { getOrCreateDirectChatRoom, findMyGroupChatRoomByRoomNo, leaveChatRoom } from '@/services/chatApi' import toast from 'react-hot-toast' +import { kickRoommate, deleteRoom } from '@/services/roomApi' +import { useChatStore } from '@/store/chatStore' +import { + buildRoomRuleFetchPath, + normalizeRoomRuleTimeValue, + selectSingleOption, +} from './myRoomRuleUtils' const MyRoomPage = () => { const navigate = useNavigate() + const { removeGroupChatRoomsByRoomNo } = useChatStore() type ApiRoom = { roomNo: number | string // 백엔드에서 ToStringSerializer로 문자열로 직렬화될 수 있음 roomType: string @@ -92,6 +100,13 @@ const MyRoomPage = () => { const [activeTab, setActiveTab] = useState<'규칙' | '지원자' | '룸메이트'>('규칙') const [roommates, setRoommates] = useState([]) const isHost = useMemo(() => roommates.some((m) => m.isMe && m.roomRole === 'HOST'), [roommates]) + + useEffect(() => { + if (!isHost && activeTab === '지원자') { + setActiveTab('규칙') + } + }, [isHost, activeTab]) + const [roommatesLoading, setRoommatesLoading] = useState(true) const [isEditingChecklist, setIsEditingChecklist] = useState(false) const [isEditingTitle, setIsEditingTitle] = useState(false) @@ -122,6 +137,7 @@ const MyRoomPage = () => { const [applicantChecklists, setApplicantChecklists] = useState>({}) // userNo를 키로 사용 const [applicantOtherNotes, setApplicantOtherNotes] = useState>({}) // userNo를 키로 사용 const [showConfirmAssignment, setShowConfirmAssignment] = useState(false) // 방 배정 확정 확인 + const [showDeleteRoomConfirm, setShowDeleteRoomConfirm] = useState(false) const [otherNotes, setOtherNotes] = useState('') const [checklistSections, setChecklistSections] = useState([]) @@ -186,14 +202,6 @@ const MyRoomPage = () => { useEffect(() => { const fetchMyRoom = async () => { try { - const isLoggedIn = !!localStorage.getItem('isLoggedIn') - if (!isLoggedIn) { - setIsGuest(true) - setLoading(false) - return - } - setIsGuest(false) - // 1) CheckMyRoom 먼저 호출 - 방 없으면 LoadMyRoom 호출하지 않음 const existsRes = await fetch(getApiUrl('/api/rooms/me/exists'), { credentials: 'include', @@ -205,6 +213,8 @@ const MyRoomPage = () => { return } + setIsGuest(false) + const existsRawBody = await existsRes.text() let existsData: any try { @@ -270,7 +280,7 @@ const MyRoomPage = () => { const fetchRoomRule = async () => { try { - const res = await fetch(getApiUrl(`/api/rooms/${effectiveRoomNo}/rule`), { + const res = await fetch(getApiUrl(buildRoomRuleFetchPath(effectiveRoomNo)), { credentials: 'include', }) @@ -1011,22 +1021,15 @@ const MyRoomPage = () => { prev.map((section, sIdx) => { if (sIdx !== sectionIndex) return section - // 추가 규칙 섹션인지 확인 - const isAdditionalRules = section.category === 'ADDITIONAL_RULES' - return { ...section, items: section.items.map((item, iIdx) => { if (iIdx !== itemIndex || !item.options) return item - // 추가 규칙이면 토글 방식, 아니면 단일 선택 방식 - if (isAdditionalRules) { + if (section.category === 'ADDITIONAL_RULES') { return { ...item, - options: item.options.map((option, oIdx) => ({ - ...option, - selected: oIdx === optionIndex ? !option.selected : option.selected, - })), + options: selectSingleOption(item.options, optionIndex), } } else { return { @@ -1048,17 +1051,145 @@ const MyRoomPage = () => { [room] ) const displayCapacity = room ? `${room.capacity}인실` : '' - const displayMembers = room ? `${roommates.length}/${room.capacity}명` : '' + const displayMembers = room ? `${room.currentMateCount}/${room.capacity}명` : '' const displayStatus = room ? mapApiStatusToDisplay(room.roomStatus) : '' // const displayCreatedAt = room ? formatRelativeTime(room.createdAt) : '' // 사용되지 않음 const displayResidencePeriod = room ? mapResidencePeriodToDisplay(room.residencePeriod) : '' + const handleKickRoommate = async () => { + if (!roommateToRemove || !room?.roomNo) return + + const roomNo = String(room.roomNo) + const target = roommates.find((mate) => mate.roommateNo === roommateToRemove.roommateNo) + + if (!target) { + toast.error('내보낼 룸메이트 정보를 찾을 수 없습니다.') + setRoommateToRemove(null) + return + } + + if (room.roomStatus === 'COMPLETED') { + toast.error('방이 최종 확정된 후에는 내보낼 수 없습니다.') + setRoommateToRemove(null) + setShowRoommateSettings(false) + return + } + + try { + await kickRoommate(roomNo, String(target.userNo)) + toast.success(`${roommateToRemove.name}님을 내보냈습니다.`) + setRoommates((prev) => prev.filter((mate) => mate.roommateNo !== roommateToRemove.roommateNo)) + setRoom((prev) => + prev + ? { + ...prev, + currentMateCount: Math.max(0, prev.currentMateCount - 1), + } + : prev + ) + setRoommateToRemove(null) + setShowRoommateSettings(false) + } catch (e: unknown) { + const code = (e as { response?: { data?: { code?: string } } })?.response?.data?.code + if (code === 'ROOM005') { + toast.error('방장만 룸메이트를 내보낼 수 있습니다.') + } else if (code === 'ROOM009') { + toast.error('룸메이트를 찾을 수 없습니다.') + } else if (code === 'ROOM012') { + toast.error('자기 자신은 내보낼 수 없습니다.') + } else { + toast.error('내보내기에 실패했습니다.') + } + } + } + + const handleLeaveRoom = async () => { + if (!room?.roomNo) return + + const roomNo = String(room.roomNo) + + try { + const groupChatRoom = await findMyGroupChatRoomByRoomNo(roomNo) + + if (!groupChatRoom?.chatRoomNo) { + if (isHost && roommates.length === 1) { + await deleteRoom(roomNo) + removeGroupChatRoomsByRoomNo(roomNo) + toast.success('방이 삭제되었습니다.') + setShowLeaveConfirm(false) + navigate('/rooms/search', { state: { refreshRooms: true } }) + return + } + + toast.error('연결된 채팅방을 찾을 수 없습니다.') + setShowLeaveConfirm(false) + return + } + + await leaveChatRoom(groupChatRoom.chatRoomNo) + removeGroupChatRoomsByRoomNo(roomNo) + toast.success('방에서 나갔습니다.') + setShowLeaveConfirm(false) + navigate('/rooms/search', { state: { deletedRoomNo: roomNo, refreshRooms: true } }) + } catch (e: unknown) { + const code = (e as { response?: { data?: { code?: string } } })?.response?.data?.code + + if (code === 'CHAT005' || code === 'HOST_CANNOT_LEAVE') { + toast.error('다른 멤버가 있는 경우 방장은 방을 나갈 수 없습니다.') + } else { + toast.error('방 나가기에 실패했습니다.') + } + + setShowLeaveConfirm(false) + } + } + + const handleDeleteRoom = async () => { + if (!room?.roomNo) return + + const roomNo = String(room.roomNo) + + try { + await deleteRoom(roomNo) + removeGroupChatRoomsByRoomNo(roomNo) + toast.success('방이 삭제되었습니다.') + setShowDeleteRoomConfirm(false) + navigate('/rooms/search', { state: { refreshRooms: true } }) + } catch (e: unknown) { + const code = (e as { response?: { data?: { code?: string } } })?.response?.data?.code + + if (code === 'ROOM005') { + toast.error('방장만 방을 삭제할 수 있습니다.') + } else if (code === 'ROOM017') { + toast.error('다른 룸메이트가 있으면 방을 삭제할 수 없습니다.') + } else if (code === 'ROOM018') { + toast.error('확정된 방은 삭제할 수 없습니다.') + } else { + toast.error('방 삭제에 실패했습니다.') + } + + setShowDeleteRoomConfirm(false) + } + } + // 자신의 confirmStatus 찾기 const myConfirmStatus = useMemo(() => { const myRoommate = roommates.find(mate => mate.isMe) return myRoommate?.confirmStatus || null }, [roommates]) + const otherRoommates = roommates.filter((mate) => !mate.isMe) + + const allOtherRoommatesConfirmed = otherRoommates.every( + (mate) => mate.confirmStatus === 'ACCEPTED' || mate.confirmStatus === 'COMPLETED' + ) + + const canOpenConfirmModal = (() => { + if (!room || myConfirmStatus === 'COMPLETED' || myConfirmStatus === 'ACCEPTED') return false + if (!isHost) return true + return allOtherRoommatesConfirmed + })() + return (
{/* 메인 콘텐츠 - 스크롤 가능 영역 */} @@ -1175,13 +1306,24 @@ const MyRoomPage = () => { )}
- - {displayStatus} - +
+ + {displayStatus} + + {isHost && room?.roomStatus !== 'COMPLETED' && ( + + )} +
@@ -1214,28 +1356,35 @@ const MyRoomPage = () => { 초대 링크
+ {isHost && room?.roomStatus !== 'COMPLETED' && !allOtherRoommatesConfirmed && ( +

+ 다른 룸메이트가 모두 확정한 뒤 방장이 마지막으로 최종 확정할 수 있습니다. +

+ )}
{/* 탭 */}
- {(['규칙', '지원자', '룸메이트'] as const).map((tab) => ( + {(['규칙', ...(isHost ? ['지원자'] : []), '룸메이트'] as const).map((tab) => (
-
- - + - -
+ }} + disabled={directChatLoadingId === applicant.userNo} + className="px-3 py-1 text-xs font-semibold text-gray-700 bg-gray-100 border border-gray-200 rounded hover:bg-gray-200 transition-colors disabled:opacity-50" + > + {directChatLoadingId === applicant.userNo ? '...' : '채팅'} + + +
+ )} - {myConfirmStatus === 'PENDING' && ( - )} ) : ( myConfirmStatus === 'PENDING' && ( - + ) )} @@ -2190,7 +2326,7 @@ const MyRoomPage = () => { )} - {showRoommateSettings && mate.confirmStatus === 'PENDING' && !mate.isMe ? ( + {showRoommateSettings && room?.roomStatus !== 'COMPLETED' && !mate.isMe ? ( + + + + + )} + {/* 지원자 수락 확인 모달 */} {applicantToAccept && (
@@ -2561,11 +2713,23 @@ const MyRoomPage = () => { {showConfirmAssignment && (
-

방 배정 확정

+

+ {isHost ? '방 최종 확정' : '방 배정 확정'} +

- 방 배정을 확정하시겠습니까?
- 확정 후에는 방에서 나갈 수 없으며
- 되돌릴 수 없습니다. + {isHost ? ( + <> + 다른 룸메이트가 모두 확정된 상태입니다.
+ 방장이 최종 확정하면 방 전체가 확정되며
+ 이후에는 강퇴할 수 없습니다. + + ) : ( + <> + 방 배정을 확정하시겠습니까?
+ 확정 후에는 되돌릴 수 없으며
+ 방장이 마지막으로 최종 확정하면 방 전체가 확정됩니다. + + )}

- {/* 방 만들기 버튼 - 로그인한 사용자만 표시 */} - {/* 로그인 여부는 서버의 인증 쿠키 기준으로 판단 */} - {true && ( + {/* 방 만들기 버튼 - 로그인했고 아직 속한 방이 없는 사용자에게만 표시 */} + {!isGuest && hasMyRoom === false && ( - {/* 로그인한 사용자만 다른 탭 표시 - 인증은 서버에서 처리 */} - {true && ( + {/* 로그인한 사용자만 관심/지원 탭 표시 */} + {!isGuest && ( <>