diff --git a/src/components/common/system/header/PageHeader.tsx b/src/components/common/system/header/PageHeader.tsx index f6004b4f..283683f6 100644 --- a/src/components/common/system/header/PageHeader.tsx +++ b/src/components/common/system/header/PageHeader.tsx @@ -1,12 +1,16 @@ import { useNavigate } from "react-router-dom"; import ArrowLeft from "@/assets/icons/arrow_left.svg"; import MeetBall from "@/assets/icons/meetball.svg"; +import Refresh from "@/assets/icons/refresh.svg"; import clsx from "clsx"; interface PageHeaderProps { title: string; onBackClick?: () => void; onMoreClick?: () => void; + onRefreshClick?: () => void; + /** true면 새로고침 아이콘이 회전한다. */ + refreshing?: boolean; className?: string; } @@ -14,6 +18,8 @@ export const PageHeader = ({ title, onBackClick, onMoreClick, + onRefreshClick, + refreshing = false, className, }: PageHeaderProps) => { const navigate = useNavigate(); @@ -31,7 +37,7 @@ export const PageHeader = ({ className, )} > - + )} + {onMoreClick && ( - )} diff --git a/src/components/home/WorkoutDayEntry.tsx b/src/components/home/WorkoutDayEntry.tsx index dcc69ae6..85313bd2 100644 --- a/src/components/home/WorkoutDayEntry.tsx +++ b/src/components/home/WorkoutDayEntry.tsx @@ -44,6 +44,13 @@ export const WorkoutDayEntry = ({ return (
+
+ + + 클릭해서 운동 상세·게임판 보기 + + +
{exerciseData.map(item => ( { partyDetail?.memberRole === "PARTY_MANAGER" || partyDetail?.memberRole === "PARTY_SUBMANAGER"; + // 모임 채팅 탭 안읽음 표시: 가입 회원에게만, 새 메시지가 있으면 빨간 점. + // 채팅 탭 진입 시 GroupChatDetailTemplate이 clearUnread로 0을 만들어 자동으로 사라진다. + const { data: groupChatRoomId } = useRoomIdByPartyId( + Number(groupId), + !!groupId && isJoined, + ); + const hasGroupChatUnread = useChatWsStore(s => + groupChatRoomId + ? (s.meta[groupChatRoomId]?.unreadCount ?? 0) > 0 + : false, + ); + return (
{ selected={select} onChange={handleChange} type="group" + dots={{ chat: hasGroupChatUnread }} />
diff --git a/src/pages/group/GroupCalendarPage.tsx b/src/pages/group/GroupCalendarPage.tsx index b8ba8ae7..2f242e2b 100644 --- a/src/pages/group/GroupCalendarPage.tsx +++ b/src/pages/group/GroupCalendarPage.tsx @@ -65,9 +65,13 @@ export const GroupCalendarPage = () => {
{selectedDayExercises.length > 0 ? ( - selectedDayExercises.map(exercise => ( + selectedDayExercises.map((exercise, index) => (
{ )}
- {calendarData?.isMember && ( + {calendarData?.isMember && selectedDayExercises.length > 0 && (
diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index b64d8ef5..b46fd8fe 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import clsx from "clsx"; import { DndContext, @@ -67,9 +67,18 @@ const MAX_SELECTED_MEMBERS = 4; interface GameBoardTabProps { gameBoardId: number; isManager: boolean; + /** 헤더 새로고침 버튼을 누를 때마다 증가. 값이 바뀌면 보드/명단을 다시 불러온다. */ + refreshSignal?: number; + /** 새로고침 요청이 진행 중인지 상위(헤더)로 알린다. */ + onRefreshingChange?: (refreshing: boolean) => void; } -export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { +export const GameBoardTab = ({ + gameBoardId, + isManager, + refreshSignal = 0, + onRefreshingChange, +}: GameBoardTabProps) => { const [isLoading, setIsLoading] = useState(true); const [selectedIds, setSelectedIds] = useState([]); const [members, setMembers] = useState([]); @@ -162,6 +171,50 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { return () => clearInterval(id); }, []); + // 소켓이 (재)연결되면 그동안 놓친 변경을 REST로 백필한다. + // 최초 연결분은 위 마운트 effect가 이미 처리하므로 건너뛴다. + const hasConnectedRef = useRef(false); + useEffect(() => { + if (!gameWs.isOpen) return; + if (!hasConnectedRef.current) { + hasConnectedRef.current = true; + return; + } + getGameBoard(gameBoardId) + .then(applyBoard) + .catch(() => {}); + refreshMembers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameWs.isOpen, gameBoardId]); + + // 헤더 새로고침 버튼: 보드/명단을 REST로 다시 불러온다. (초기값 0은 무시) + useEffect(() => { + if (!refreshSignal) return; + let cancelled = false; + onRefreshingChange?.(true); + Promise.all([ + getGameBoard(gameBoardId).then(board => { + if (!cancelled) applyBoard(board); + }), + getGameBoardMembers(gameBoardId, toGameBoardMembersParams(filters)).then( + res => { + if (!cancelled) applyMembersResponse(res); + }, + ), + // 응답이 즉시 와도 회전이 눈에 보이도록 최소 시간 확보 + new Promise(resolve => setTimeout(resolve, 600)), + ]) + .catch(() => {}) + .finally(() => { + if (!cancelled) onRefreshingChange?.(false); + }); + return () => { + cancelled = true; + onRefreshingChange?.(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshSignal]); + // 다른 클라이언트의 변경사항 브로드캐스트 반영 useEffect(() => { const msg = gameWs.lastMessage; @@ -272,10 +325,12 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const handleAddToWaitingQueue = async () => { if (selectedMembers.length === 0) return; try { - await gameWs.createGame({ + const res = await gameWs.createGame({ gameBoardId, gameBoardMemberIds: selectedMembers.map(m => m.id), }); + if (res.data?.board) applyBoard(res.data.board); + refreshMembers(); setSelectedIds([]); } catch (err) { console.error("[GAME] CREATE_GAME 실패", err); @@ -292,6 +347,8 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { }); const restoredIds = res.data?.players.map(p => p.gameBoardMemberId); setSelectedIds(restoredIds?.length ? restoredIds : group.memberIds); + if (res.data?.board) applyBoard(res.data.board); + refreshMembers(); } catch (err) { console.error("[GAME] DELETE_GAME(restore) 실패", err); alert("대기열 변경에 실패했어요."); @@ -302,11 +359,13 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const group = waitingGroups.find(g => g.id === id); if (!group) return; try { - await gameWs.deleteGame({ + const res = await gameWs.deleteGame({ gameBoardId, gameId: group.gameId, restore: false, }); + if (res.data?.board) applyBoard(res.data.board); + refreshMembers(); } catch (err) { console.error("[GAME] DELETE_GAME 실패", err); alert("대기열 삭제에 실패했어요."); @@ -317,7 +376,13 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const group = waitingGroups.find(g => g.id === waitingGroupId); if (!group) return; try { - await gameWs.startGame({ gameBoardId, gameId: group.gameId, courtId }); + const res = await gameWs.startGame({ + gameBoardId, + gameId: group.gameId, + courtId, + }); + if (res.data) applyBoard(res.data); + refreshMembers(); } catch (err) { console.error("[GAME] START_GAME 실패", err); alert("게임 시작에 실패했어요."); @@ -334,7 +399,13 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { if (!group || !court?.gameId) return; try { await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId }); - await gameWs.startGame({ gameBoardId, gameId: group.gameId, courtId }); + const res = await gameWs.startGame({ + gameBoardId, + gameId: group.gameId, + courtId, + }); + if (res.data) applyBoard(res.data); + refreshMembers(); } catch (err) { console.error("[GAME] 코트 교체 실패", err); alert("코트 교체에 실패했어요."); @@ -368,7 +439,12 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const court = courts.find(c => c.id === courtId); if (!court?.gameId) return; try { - await gameWs.completeGame({ gameBoardId, gameId: court.gameId }); + const res = await gameWs.completeGame({ + gameBoardId, + gameId: court.gameId, + }); + if (res.data) applyBoard(res.data); + refreshMembers(); } catch (err) { console.error("[GAME] COMPLETE_GAME 실패", err); alert("게임 완료에 실패했어요."); @@ -381,7 +457,12 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const court = courts.find(c => c.id === courtId); if (!court?.gameId) return; try { - await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId }); + const res = await gameWs.moveToWaiting({ + gameBoardId, + gameId: court.gameId, + }); + if (res.data) applyBoard(res.data); + refreshMembers(); } catch (err) { console.error("[GAME] MOVE_TO_WAITING 실패", err); alert("대기로 되돌리기에 실패했어요."); @@ -399,6 +480,8 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { }); const restoredIds = res.data?.players.map(p => p.gameBoardMemberId); setSelectedIds(restoredIds?.length ? restoredIds : []); + if (res.data?.board) applyBoard(res.data.board); + refreshMembers(); } catch (err) { console.error("[GAME] 경기 취소 실패", err); alert("경기 취소에 실패했어요."); diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index 7648384e..1e4a9af0 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -81,6 +81,10 @@ export const MyExerciseDetail = () => { const [isWithdrawnModal, setIsWithdrawnModal] = useState(false); + // 게임판 헤더 새로고침: 값이 바뀔 때마다 GameBoardTab이 보드/명단을 다시 불러온다. + const [gameRefreshSignal, setGameRefreshSignal] = useState(0); + const [isGameRefreshing, setIsGameRefreshing] = useState(false); + const location = window.location; type TabType = "detail" | "game" | "finished"; const [activeTab, setActiveTab] = useState(() => { @@ -206,8 +210,16 @@ export const MyExerciseDetail = () => { setIsSortOpen(true) : undefined + activeTab === "detail" && isCurrentUserLeader + ? () => setIsSortOpen(true) + : undefined + } + onRefreshClick={ + activeTab === "game" + ? () => setGameRefreshSignal(n => n + 1) + : undefined } + refreshing={isGameRefreshing} onBackClick={() => { if (returnPath === -1) navigate(-1); else navigate(returnPath); @@ -389,6 +401,8 @@ export const MyExerciseDetail = () => { )}