From 873abc19b6d240194e4f22136a3045485620d0f1 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 1 Sep 2026 23:51:32 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=ED=99=88=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=EC=9A=B4=EB=8F=99=20=EC=9E=88=EB=8A=94=20=EB=82=A0?= =?UTF-8?q?=EC=97=90=20=EA=B2=8C=EC=9E=84=ED=8C=90=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/home/WorkoutDayEntry.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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 => ( Date: Tue, 1 Sep 2026 23:55:28 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=9A=B4=EB=8F=99=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=EC=97=90=EC=84=9C=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=EC=8B=9C=20=EC=9A=B4=EB=8F=99=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EB=B2=84=ED=8A=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/GroupCalendarPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/group/GroupCalendarPage.tsx b/src/pages/group/GroupCalendarPage.tsx index b8ba8ae7..24d88cbb 100644 --- a/src/pages/group/GroupCalendarPage.tsx +++ b/src/pages/group/GroupCalendarPage.tsx @@ -94,7 +94,7 @@ export const GroupCalendarPage = () => { )}
- {calendarData?.isMember && ( + {calendarData?.isMember && selectedDayExercises.length > 0 && (
From 52977cb3fbb403d22ede13624e5e992c3ab840c4 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 1 Sep 2026 23:57:08 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=AA=A8=EC=9E=84=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=9A=B4=EB=8F=99=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EB=82=A0=20=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8A=BC=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20=EB=B0=8F=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=95=98=EB=8B=A8=20=EA=B5=AC=EB=B6=84=EC=84=A0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/GroupCalendarPage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/group/GroupCalendarPage.tsx b/src/pages/group/GroupCalendarPage.tsx index 24d88cbb..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) => (
Date: Wed, 2 Sep 2026 00:09:39 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=BD=94=ED=8A=B8/=EB=8C=80=EA=B8=B0=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=EC=9D=B4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=A0=84?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EB=B0=98=EC=98=81=20=EC=95=88=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index b64d8ef5..e749fabe 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, @@ -162,6 +162,22 @@ 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]); + // 다른 클라이언트의 변경사항 브로드캐스트 반영 useEffect(() => { const msg = gameWs.lastMessage; @@ -272,10 +288,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 +310,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 +322,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 +339,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 +362,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 +402,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 +420,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 +443,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("경기 취소에 실패했어요."); From 95026678dfad41a819ca48053d0efe008fcecf49 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 2 Sep 2026 14:22:25 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=ED=83=AD=20=ED=97=A4=EB=8D=94=EC=97=90=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/system/header/PageHeader.tsx | 30 +++++++++++++- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 39 ++++++++++++++++++- .../group/MyGroupDetail/MyExerciseDetail.tsx | 16 +++++++- 3 files changed, 81 insertions(+), 4 deletions(-) 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, )} > -
+ {onRefreshClick && ( + + )} + {onMoreClick && ( - )} diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index e749fabe..b46fd8fe 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -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([]); @@ -178,6 +187,34 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { // 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; 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 = () => { )} From c26b762d4d9267936eec395f30508405d47e56da Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 2 Sep 2026 15:02:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=ED=99=88=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=ED=83=AD=EC=97=90=20=EC=95=88=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EB=B9=A8=EA=B0=84=20=EC=A0=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layout/GroupLayout.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/layout/GroupLayout.tsx b/src/layout/GroupLayout.tsx index f7ce4785..f1307447 100644 --- a/src/layout/GroupLayout.tsx +++ b/src/layout/GroupLayout.tsx @@ -14,6 +14,8 @@ import axios, { AxiosError } from "axios"; import type { CommonResponse } from "../types/common"; import type { PersonalChatRoom } from "../types/chat"; import { Modal_Del } from "../components/group/Modal_Del"; +import { useRoomIdByPartyId } from "../api/chat/getRoomIdByPartyId"; +import { useChatWsStore } from "../store/useChatWsStore"; const options = [ { label: "홈", value: "" }, @@ -156,6 +158,18 @@ export const GroupLayout = () => { 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 }} />