From c1fc9840bf6447412b4a20f5e6c9ebfc30f6ea14 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 15 Aug 2026 02:12:10 +0900 Subject: [PATCH 01/52] =?UTF-8?q?feat:=20=EC=9A=B4=EB=8F=99=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EA=B2=8C=EC=9E=84=ED=8C=90/=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=EC=99=84=EB=A3=8C=20=ED=83=AD=20=EB=B0=8F=20=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=ED=8C=90=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20UI=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/launch.json | 11 ++ src/assets/icons/sparkle_filled.svg | 4 + .../MyGroupDetail/GameBoard/CourtCard.tsx | 110 ++++++++++++ .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 137 +++++++++++++++ .../GameBoard/GameCompleteTab.tsx | 14 ++ .../GameBoard/GameMemberCard.tsx | 103 +++++++++++ .../GameBoard/mockGameBoardData.ts | 163 ++++++++++++++++++ .../group/MyGroupDetail/MyExerciseDetail.tsx | 30 +++- 8 files changed, 569 insertions(+), 3 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/assets/icons/sparkle_filled.svg create mode 100644 src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameCompleteTab.tsx create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx create mode 100644 src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..7d64b738 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "cockple-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5174", "--strictPort"], + "port": 5174 + } + ] +} diff --git a/src/assets/icons/sparkle_filled.svg b/src/assets/icons/sparkle_filled.svg new file mode 100644 index 00000000..57318ec8 --- /dev/null +++ b/src/assets/icons/sparkle_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx new file mode 100644 index 00000000..ca9e99b3 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -0,0 +1,110 @@ +import clsx from "clsx"; +import Pen from "@/assets/icons/pen.svg"; +import Reject from "@/assets/icons/reject.svg"; +import type { GamePlayer } from "./mockGameBoardData"; + +const PlayerBadge = ({ name, group, color }: GamePlayer) => ( +
+ {name} + {group} +
+); + +interface CourtCardProps { + label: string; + timer?: string; + players: GamePlayer[] | null; + onComplete?: () => void; +} + +export const CourtCard = ({ + label, + timer, + players, + onComplete, +}: CourtCardProps) => { + if (!players) { + return ( +
+
+ {label} +
+
+ 빈 코트 +
+
+ ); + } + + return ( +
+
+
+ {label} + {timer && {timer}} +
+ +
+
+ {players.map(p => ( + + ))} +
+
+ ); +}; + +interface WaitingCardProps { + label: string; + players: GamePlayer[]; + onEdit?: () => void; + onReject?: () => void; +} + +export const WaitingCard = ({ + label, + players, + onEdit, + onReject, +}: WaitingCardProps) => ( +
+
+ {label} +
+ + +
+
+
+ {players.map(p => ( + + ))} +
+
+); diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx new file mode 100644 index 00000000..6a359ac4 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import AddWhite from "@/assets/icons/add_white.svg"; +import Filter from "@/assets/icons/filter.svg"; +import Sparkle from "@/assets/icons/sparkle_filled.svg"; +import { CourtCard, WaitingCard } from "./CourtCard"; +import { GameMemberCard } from "./GameMemberCard"; +import { + mockCourts, + mockGameMembers, + mockWaitingGroups, +} from "./mockGameBoardData"; + +const notReady = () => alert("준비 중인 기능이에요."); + +export const GameBoardTab = () => { + const [selectedIds, setSelectedIds] = useState([]); + + const toggleSelect = (id: number) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(v => v !== id) : [...prev, id], + ); + }; + + return ( +
+ {/* 게임 코트 */} +
+
+ 게임 코트 + +
+
+
+
+ {mockCourts.map(court => ( + + ))} +
+
+
+
+ + {/* 대기 */} +
+
+ 대기 +
+
+
+
+ {mockWaitingGroups.map(group => ( + + ))} +
+
+
+
+ + {/* 명단 */} +
+
+ 명단 + +
+
+ + 전체 {mockGameMembers.length} + + +
+
+ {mockGameMembers.map(member => ( + toggleSelect(member.id)} + /> + ))} +
+
+ + {/* 하단 선택 바 */} +
+
+ {selectedIds.length} + 선택됨 +
+ + +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameCompleteTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameCompleteTab.tsx new file mode 100644 index 00000000..187d3eec --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameCompleteTab.tsx @@ -0,0 +1,14 @@ +import ImgNoneError from "@/assets/images/None_Error.webp?url"; + +export const GameCompleteTab = () => ( +
+ 준비 중 +
+ 게임 완료 기능은 준비 중이에요! +
+
+); diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx new file mode 100644 index 00000000..3cccd217 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -0,0 +1,103 @@ +import clsx from "clsx"; +import DefaultProfile from "@/assets/images/base_profile_img.png"; +import Female from "@/assets/icons/female.svg"; +import Male from "@/assets/icons/male.svg"; +import CircleS from "@/assets/icons/circle_s.svg"; +import Exercise from "@/assets/icons/Exercise.svg"; +import ExerciseFilled from "@/assets/icons/Exercise_filled.svg"; +import type { GameMember, MemberTag } from "./mockGameBoardData"; + +const TAG_STYLE: Record = { + 운동: "bg-gr-100 text-gr-700", + 대기: "bg-[#fff4d2] text-[#d96303]", + 미참여: "bg-rd-500 text-white", +}; + +interface GameMemberCardProps { + member: GameMember; + selected: boolean; + onToggleSelect: () => void; +} + +export const GameMemberCard = ({ + member, + selected, + onToggleSelect, +}: GameMemberCardProps) => { + const { name, gender, ageGroup, group, playCount, tags, imgUrl, selectable } = + member; + const isWithdrawn = tags.includes("미참여"); + + return ( +
+
+
+ {tags.map(tag => ( + + {tag} + + ))} +
+ + {playCount}회 + +
+ +
+ {`${name} + {name} +
+ +
+
+ + {group} + + + {ageGroup} + +
+ +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts new file mode 100644 index 00000000..075485b4 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -0,0 +1,163 @@ +export interface GamePlayer { + id: number; + name: string; + group: string; + color: "pink" | "blue"; +} + +export interface CourtGroup { + id: number; + label: string; + timer?: string; + players: GamePlayer[] | null; // null → 빈 코트 +} + +export interface WaitingGroup { + id: number; + label: string; + players: GamePlayer[]; +} + +export type MemberTag = "운동" | "대기" | "미참여"; + +export interface GameMember { + id: number; + name: string; + gender: "MALE" | "FEMALE"; + ageGroup: string; + group: string; + playCount: number; + tags: MemberTag[]; + imgUrl?: string | null; + selectable: boolean; +} + +const player = ( + id: number, + name: string, + group: string, + color: "pink" | "blue", +): GamePlayer => ({ id, name, group, color }); + +export const mockCourts: CourtGroup[] = [ + { + id: 1, + label: "01 코트", + timer: "00:05", + players: [ + player(1, "김셰익", "A", "pink"), + player(2, "김셰익", "B", "pink"), + player(3, "김셰익", "C", "blue"), + player(4, "김셰익", "준자강", "blue"), + ], + }, + { id: 2, label: "02 코트", players: null }, + { + id: 3, + label: "03 코트", + timer: "00:05", + players: [ + player(5, "김셰익", "A", "pink"), + player(6, "김셰익", "B", "pink"), + player(7, "김셰익", "C", "blue"), + player(8, "김셰익", "준자강", "blue"), + ], + }, + { + id: 4, + label: "04 코트", + timer: "00:05", + players: [ + player(9, "김셰익", "A", "pink"), + player(10, "김셰익", "B", "pink"), + player(11, "김셰익", "C", "blue"), + player(12, "김셰익", "준자강", "blue"), + ], + }, +]; + +export const mockWaitingGroups: WaitingGroup[] = [ + { + id: 1, + label: "대기 1번", + players: [ + player(13, "김셰익", "A", "pink"), + player(14, "김셰익", "B", "pink"), + player(15, "김셰익", "C", "blue"), + player(16, "김셰익", "준자강", "blue"), + ], + }, + { + id: 2, + label: "대기 2번", + players: [ + player(17, "김셰익", "A", "pink"), + player(18, "김셰익", "B", "pink"), + player(19, "김셰익", "C", "blue"), + player(20, "김셰익", "준자강", "blue"), + ], + }, +]; + +export const mockGameMembers: GameMember[] = [ + { + id: 1, + name: "김셰익", + gender: "FEMALE", + ageGroup: "30대", + group: "D조", + playCount: 0, + tags: ["운동"], + selectable: true, + }, + { + id: 2, + name: "김셰익", + gender: "FEMALE", + ageGroup: "30대", + group: "D조", + playCount: 0, + tags: ["대기"], + selectable: true, + }, + { + id: 3, + name: "김셰익", + gender: "FEMALE", + ageGroup: "30대", + group: "D조", + playCount: 0, + tags: ["운동", "대기"], + selectable: true, + }, + { + id: 4, + name: "김셰익", + gender: "FEMALE", + ageGroup: "30대", + group: "D조", + playCount: 0, + tags: ["운동"], + selectable: true, + }, + { + id: 5, + name: "김셰익", + gender: "FEMALE", + ageGroup: "30대", + group: "D조", + playCount: 0, + tags: ["운동", "대기"], + selectable: true, + }, + { + id: 6, + name: "김셰익", + gender: "FEMALE", + ageGroup: "30대", + group: "D조", + playCount: 0, + tags: ["미참여"], + selectable: false, + }, +]; diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index b76b7fc2..85bca4bc 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -10,6 +10,9 @@ import { Modal_ExDel } from "../../../components/group/Modal_ExDel copy"; import { useState, useEffect } from "react"; import { getModalConfig } from "../../../components/group/modalConfig"; import { SortBottomSheet } from "../../../components/common/SortBottomSheet"; +import TabSelector from "../../../components/common/TabSelector"; +import { GameBoardTab } from "./GameBoard/GameBoardTab"; +import { GameCompleteTab } from "./GameBoard/GameCompleteTab"; import { getExerciseDetail, cancelSelf, @@ -51,6 +54,15 @@ export const MyExerciseDetail = () => { const [isWithdrawnModal, setIsWithdrawnModal] = useState(false); + const [activeTab, setActiveTab] = useState< + "detail" | "game" | "gameComplete" + >("detail"); + const tabOptions = [ + { label: "운동 상세", value: "detail" }, + { label: "게임판", value: "game" }, + { label: "게임 완료", value: "gameComplete" }, + ]; + // 운동 상세 조회 useEffect(() => { if (exerciseIdNumber) { @@ -162,9 +174,19 @@ export const MyExerciseDetail = () => { }} /> -
- {/* 장소 정보 */} -
+ setActiveTab(val as typeof activeTab)} + /> + +
+ {activeTab === "game" && } + {activeTab === "gameComplete" && } + {activeTab === "detail" && ( +
+ {/* 장소 정보 */} +
{detail.notice && ( <>
@@ -323,6 +345,8 @@ export const MyExerciseDetail = () => {
)}
+ )} +
Date: Tue, 18 Aug 2026 22:50:22 +0900 Subject: [PATCH 02/52] =?UTF-8?q?fix:=20=EC=9A=B4=EB=8F=99=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20floating=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/CreateExercise.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/group/CreateExercise.tsx b/src/pages/group/CreateExercise.tsx index ccf4b626..81b85dc6 100644 --- a/src/pages/group/CreateExercise.tsx +++ b/src/pages/group/CreateExercise.tsx @@ -8,7 +8,7 @@ import RedCircle from "@/assets/icons/cicle_s_red.svg?react"; import { DropBox } from "../../components/common/DropBox"; import { TitleBtn } from "../../components/group/main/create_exercise/TitleBtn"; import { TextField } from "../../components/group/main/create_exercise/TextField"; -import GR400_L from "../../components/common/Btn_Static/Text/GR400_L"; +import Grad_GR400_L from "../../components/common/Btn_Static/Text/Grad_GR400_L"; import { LocationField } from "../../components/common/LocationField"; import { useLocation, @@ -272,13 +272,15 @@ export const CreateExercise = () => {
-
+
+ +
{errorMsg && (

{errorMsg}

)} - Date: Tue, 18 Aug 2026 23:02:25 +0900 Subject: [PATCH 03/52] =?UTF-8?q?feat:=20=EB=AA=85=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20ui=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameBoard/GameAddPlayerModal.tsx | 112 ++++++++++++++++++ .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 34 +++++- 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx new file mode 100644 index 00000000..84c5d65c --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { PageHeader } from "@/components/common/system/header/PageHeader"; +import InputField from "@/components/common/Search_Filed/InputField"; +import TextBox from "@/components/common/Text_Box/TextBox"; +import DropCheckBox from "@/components/common/Drop_Box/DropCheckBox"; +import { DropBox } from "@/components/common/DropBox"; +import Grad_GR400_L from "@/components/common/Btn_Static/Text/Grad_GR400_L"; +import Circle_RedIcon from "@/assets/icons/cicle_s_red.svg?url"; +import { LEVEL_KEY } from "@/constants/options"; + +const AGE_OPTIONS = ["10대", "20대", "30대", "40대", "50대", "60대 이상"]; + +export interface NewGamePlayer { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup: string; +} + +interface GameAddPlayerModalProps { + onClose: () => void; + onSubmit: (player: NewGamePlayer) => void; +} + +export const GameAddPlayerModal = ({ + onClose, + onSubmit, +}: GameAddPlayerModalProps) => { + const [name, setName] = useState(""); + const [gender, setGender] = useState<"MALE" | "FEMALE" | null>(null); + const [level, setLevel] = useState(""); + const [ageGroup, setAgeGroup] = useState(""); + + const levelOptions = LEVEL_KEY.slice(1); + const isLevelValid = level === "disabled" || levelOptions.includes(level); + const isFormValid = name.trim().length > 0 && gender !== null && isLevelValid; + + const handleSubmit = () => { + if (!isFormValid || gender === null) return; + onSubmit({ + name: name.trim(), + gender, + level: level === "disabled" ? "급수없음" : level, + ageGroup, + }); + }; + + return ( +
+ +
+ setName(e.target.value.slice(0, 17))} + /> + +
+
+ 성별 + +
+
+ setGender(prev => (prev === "MALE" ? null : "MALE"))} + className="w-19" + > + 남성 + + + setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) + } + className="w-19" + > + 여성 + +
+
+ + setLevel(val ?? "")} + /> + +
+ 나이대 + ({ value }))} + value={ageGroup} + onChange={setAgeGroup} + /> +
+
+ +
+ +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 6a359ac4..96006b66 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -8,12 +8,16 @@ import { mockCourts, mockGameMembers, mockWaitingGroups, + type GameMember, } from "./mockGameBoardData"; +import { GameAddPlayerModal } from "./GameAddPlayerModal"; const notReady = () => alert("준비 중인 기능이에요."); export const GameBoardTab = () => { const [selectedIds, setSelectedIds] = useState([]); + const [members, setMembers] = useState(mockGameMembers); + const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); const toggleSelect = (id: number) => { setSelectedIds(prev => @@ -81,15 +85,13 @@ export const GameBoardTab = () => {
- - 전체 {mockGameMembers.length} - + 전체 {members.length}
- {mockGameMembers.map(member => ( + {members.map(member => ( { 선수를 선택해주세요
+ + {isAddPlayerOpen && ( + setIsAddPlayerOpen(false)} + onSubmit={player => { + setMembers(prev => [ + ...prev, + { + id: Math.max(0, ...prev.map(m => m.id)) + 1, + name: player.name, + gender: player.gender, + ageGroup: player.ageGroup, + group: player.level, + playCount: 0, + tags: ["미참여"], + selectable: true, + }, + ]); + setIsAddPlayerOpen(false); + }} + /> + )}
); }; From 65afba32127c6c26fbbaa8e48dc9b8eacffc5ec5 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 18 Aug 2026 23:11:13 +0900 Subject: [PATCH 04/52] =?UTF-8?q?feat:=20=EC=BD=94=ED=8A=B8,=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EB=B9=84=EC=96=B4=EC=9E=88=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 96006b66..cc1fc45f 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -61,21 +61,27 @@ export const GameBoardTab = () => {
대기
-
-
-
- {mockWaitingGroups.map(group => ( - - ))} + {mockWaitingGroups.length === 0 ? ( +
+ 대기중인 팀이 없어요 +
+ ) : ( +
+
+
+ {mockWaitingGroups.map(group => ( + + ))} +
-
+ )}
{/* 명단 */} From 41b92d00745c20409a2cf993bc647e3210c8b289 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 18 Aug 2026 23:40:15 +0900 Subject: [PATCH 05/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=9B=B9=20=ED=99=94=EB=A9=B4=20=EB=B3=B4=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 88 +++++--- .../GameBoard/GameBoardWebView.tsx | 188 ++++++++++++++++++ .../GameBoard/gameBoardShared.tsx | 31 +++ 3 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx create mode 100644 src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index cc1fc45f..69c0c742 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -11,13 +11,14 @@ import { type GameMember, } from "./mockGameBoardData"; import { GameAddPlayerModal } from "./GameAddPlayerModal"; - -const notReady = () => alert("준비 중인 기능이에요."); +import { GameBoardWebView } from "./GameBoardWebView"; +import { notReady } from "./gameBoardShared"; export const GameBoardTab = () => { const [selectedIds, setSelectedIds] = useState([]); const [members, setMembers] = useState(mockGameMembers); const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); + const [isWebViewOpen, setIsWebViewOpen] = useState(false); const toggleSelect = (id: number) => { setSelectedIds(prev => @@ -25,19 +26,61 @@ export const GameBoardTab = () => { ); }; + const handleAddPlayer = (player: { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup: string; + }) => { + setMembers(prev => [ + ...prev, + { + id: Math.max(0, ...prev.map(m => m.id)) + 1, + name: player.name, + gender: player.gender, + ageGroup: player.ageGroup, + group: player.level, + playCount: 0, + tags: ["미참여"], + selectable: true, + }, + ]); + setIsAddPlayerOpen(false); + }; + return (
{/* 게임 코트 */}
게임 코트 - +
+ + +
@@ -141,25 +184,20 @@ export const GameBoardTab = () => {
+ {isWebViewOpen && ( + setIsAddPlayerOpen(true)} + onClose={() => setIsWebViewOpen(false)} + /> + )} + {isAddPlayerOpen && ( setIsAddPlayerOpen(false)} - onSubmit={player => { - setMembers(prev => [ - ...prev, - { - id: Math.max(0, ...prev.map(m => m.id)) + 1, - name: player.name, - gender: player.gender, - ageGroup: player.ageGroup, - group: player.level, - playCount: 0, - tags: ["미참여"], - selectable: true, - }, - ]); - setIsAddPlayerOpen(false); - }} + onSubmit={handleAddPlayer} /> )}
diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx new file mode 100644 index 00000000..71494379 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -0,0 +1,188 @@ +import clsx from "clsx"; +import AddWhite from "@/assets/icons/add_white.svg"; +import Sparkle from "@/assets/icons/sparkle_filled.svg"; +import Dismiss from "@/assets/icons/dismiss.svg"; +import ArrowLeft from "@/assets/icons/arrow_left.svg"; +import { CourtCard, WaitingCard } from "./CourtCard"; +import { GameMemberCard } from "./GameMemberCard"; +import { + mockCourts, + mockWaitingGroups, + type GameMember, +} from "./mockGameBoardData"; +import { + notReady, + FILTER_LEVEL_OPTIONS, + FILTER_GENDER_OPTIONS, + FILTER_SHUTTLE_OPTIONS, + FilterChipRow, +} from "./gameBoardShared"; + +interface GameBoardWebViewProps { + members: GameMember[]; + selectedIds: number[]; + toggleSelect: (id: number) => void; + onAddPlayer: () => void; + onClose: () => void; +} + +export const GameBoardWebView = ({ + members, + selectedIds, + toggleSelect, + onAddPlayer, + onClose, +}: GameBoardWebViewProps) => { + const selectedMembers = members.filter(m => selectedIds.includes(m.id)); + + return ( +
+
+ + 게임판 +
+ +
+ {/* 게임 코트 */} +
+
+ 게임 코트 + +
+
+
+
+ {mockCourts.map(court => ( + + ))} +
+
+
+
+ + {/* 대기 */} +
+
+ 대기 +
+ {mockWaitingGroups.length === 0 ? ( +
+ + 대기중인 팀이 없어요 + +
+ ) : ( +
+
+
+ {mockWaitingGroups.map(group => ( + + ))} +
+
+
+ )} +
+ + {/* 명단 */} +
+
+
+ 명단 + +
+ +
+ {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((m, i) => ( + + ))} +
+ )} +
+ + {selectedIds.length} + + 선택됨 +
+ + +
+
+ +
+ + + +
+ +
+ {members.map(member => ( + toggleSelect(member.id)} + /> + ))} +
+
+
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx b/src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx new file mode 100644 index 00000000..150712a6 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx @@ -0,0 +1,31 @@ +import { LEVEL_KEY } from "@/constants/options"; + +export const notReady = () => alert("준비 중인 기능이에요."); + +export const FILTER_LEVEL_OPTIONS = [...LEVEL_KEY.slice(1), "급수없음"]; +export const FILTER_GENDER_OPTIONS = ["남성", "여성"]; +export const FILTER_SHUTTLE_OPTIONS = ["제출", "미제출"]; + +export const FilterChipRow = ({ + label, + options, +}: { + label: string; + options: string[]; +}) => ( +
+ {label} +
+ {options.map(option => ( + + ))} +
+
+); From 51c4a177e04106d184ef5559f77d17958f3182b1 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 19 Aug 2026 00:09:51 +0900 Subject: [PATCH 06/52] =?UTF-8?q?feat:=20=EC=BD=94=ED=8A=B8=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A8=EB=8B=AC=20ui=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameBoard/CourtManageBottomSheet.tsx | 132 ++++++++++++++++++ .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 33 ++++- .../GameBoard/GameBoardWebView.tsx | 10 +- 3 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx new file mode 100644 index 00000000..d02d8cbd --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import clsx from "clsx"; +import Dismiss from "@/assets/icons/dismiss.svg"; +import Add from "@/assets/icons/add.svg"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; +import GR400_L from "@/components/common/Btn_Static/Text/GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; + +interface CourtManageBottomSheetProps { + variant?: "sheet" | "overlay"; + courtLabels: string[]; + onClose: () => void; + onSave: (labels: string[]) => void; +} + +export const CourtManageBottomSheet = ({ + variant = "sheet", + courtLabels, + onClose, + onSave, +}: CourtManageBottomSheetProps) => { + useLockBodyScroll(true); + const [labels, setLabels] = useState(courtLabels); + const [newLabel, setNewLabel] = useState(""); + const [isNewLabelFocused, setIsNewLabelFocused] = useState(false); + + const handleAdd = () => { + const trimmed = newLabel.trim(); + if (!trimmed) return; + setLabels(prev => [...prev, trimmed]); + setNewLabel(""); + }; + + const handleRemove = (index: number) => { + setLabels(prev => prev.filter((_, i) => i !== index)); + }; + + const handleSave = () => { + const trimmed = newLabel.trim(); + onSave(trimmed ? [...labels, trimmed] : labels); + onClose(); + }; + + const isOverlay = variant === "overlay"; + + const content = ( + <> + 코트 관리 + +
+ {labels.map((label, index) => ( +
+ + {label} + + +
+ ))} + +
+ setNewLabel(e.target.value)} + onFocus={() => setIsNewLabelFocused(true)} + onBlur={() => setIsNewLabelFocused(false)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }} + className={clsx( + "w-full rounded-xl border px-3 py-2.5 body-md-500 text-black outline-none", + isNewLabelFocused ? "border-[#87c95e]" : "border-gy-200", + )} + /> + +
+
+ +
+ {isOverlay ? ( + + ) : ( + + )} +
+ + ); + + if (isOverlay) { + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); + } + + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 69c0c742..834f818c 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -8,17 +8,23 @@ import { mockCourts, mockGameMembers, mockWaitingGroups, + type CourtGroup, type GameMember, } from "./mockGameBoardData"; import { GameAddPlayerModal } from "./GameAddPlayerModal"; import { GameBoardWebView } from "./GameBoardWebView"; +import { CourtManageBottomSheet } from "./CourtManageBottomSheet"; import { notReady } from "./gameBoardShared"; export const GameBoardTab = () => { const [selectedIds, setSelectedIds] = useState([]); const [members, setMembers] = useState(mockGameMembers); + const [courts, setCourts] = useState(mockCourts); const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); const [isWebViewOpen, setIsWebViewOpen] = useState(false); + const [courtManageVariant, setCourtManageVariant] = useState< + "sheet" | "overlay" | null + >(null); const toggleSelect = (id: number) => { setSelectedIds(prev => @@ -48,6 +54,18 @@ export const GameBoardTab = () => { setIsAddPlayerOpen(false); }; + const handleSaveCourts = (labels: string[]) => { + setCourts(prev => + labels.map((label, index) => { + const existing = prev[index]; + return existing + ? { ...existing, label } + : { id: Date.now() + index, label, players: null }; + }), + ); + setCourtManageVariant(null); + }; + return (
{/* 게임 코트 */} @@ -76,7 +94,7 @@ export const GameBoardTab = () => { @@ -85,7 +103,7 @@ export const GameBoardTab = () => {
- {mockCourts.map(court => ( + {courts.map(court => ( { {isWebViewOpen && ( setIsAddPlayerOpen(true)} + onManageCourts={() => setCourtManageVariant("overlay")} onClose={() => setIsWebViewOpen(false)} /> )} @@ -200,6 +220,15 @@ export const GameBoardTab = () => { onSubmit={handleAddPlayer} /> )} + + {courtManageVariant && ( + c.label)} + onClose={() => setCourtManageVariant(null)} + onSave={handleSaveCourts} + /> + )}
); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx index 71494379..5310d917 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -6,8 +6,8 @@ import ArrowLeft from "@/assets/icons/arrow_left.svg"; import { CourtCard, WaitingCard } from "./CourtCard"; import { GameMemberCard } from "./GameMemberCard"; import { - mockCourts, mockWaitingGroups, + type CourtGroup, type GameMember, } from "./mockGameBoardData"; import { @@ -19,18 +19,22 @@ import { } from "./gameBoardShared"; interface GameBoardWebViewProps { + courts: CourtGroup[]; members: GameMember[]; selectedIds: number[]; toggleSelect: (id: number) => void; onAddPlayer: () => void; + onManageCourts: () => void; onClose: () => void; } export const GameBoardWebView = ({ + courts, members, selectedIds, toggleSelect, onAddPlayer, + onManageCourts, onClose, }: GameBoardWebViewProps) => { const selectedMembers = members.filter(m => selectedIds.includes(m.id)); @@ -56,7 +60,7 @@ export const GameBoardWebView = ({ @@ -64,7 +68,7 @@ export const GameBoardWebView = ({
- {mockCourts.map(court => ( + {courts.map(court => ( Date: Wed, 19 Aug 2026 00:25:16 +0900 Subject: [PATCH 07/52] =?UTF-8?q?fix:=20=EB=AA=85=EB=8B=A8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=84=A0=ED=83=9D=20=EB=8F=99=EC=9E=91=20=EB=B0=8F?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=ED=95=98=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=94=20UI=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 70 ++++++++++++++----- .../GameBoard/GameMemberCard.tsx | 27 ++++--- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 834f818c..0c85aef8 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; +import clsx from "clsx"; import AddWhite from "@/assets/icons/add_white.svg"; import Filter from "@/assets/icons/filter.svg"; import Sparkle from "@/assets/icons/sparkle_filled.svg"; +import Dismiss from "@/assets/icons/dismiss.svg"; import { CourtCard, WaitingCard } from "./CourtCard"; import { GameMemberCard } from "./GameMemberCard"; import { @@ -32,6 +34,8 @@ export const GameBoardTab = () => { ); }; + const selectedMembers = members.filter(m => selectedIds.includes(m.id)); + const handleAddPlayer = (player: { name: string; gender: "MALE" | "FEMALE"; @@ -181,25 +185,55 @@ export const GameBoardTab = () => {
{/* 하단 선택 바 */} -
-
- {selectedIds.length} - 선택됨 +
+ {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((m, i) => ( + + ))} +
+ )} +
+
+ {selectedIds.length} + 선택됨 +
+ + {selectedIds.length > 0 ? ( + + ) : ( + + )}
- -
{isWebViewOpen && ( diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx index 3cccd217..bfe6017e 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -29,10 +29,18 @@ export const GameMemberCard = ({ const isWithdrawn = tags.includes("미참여"); return ( -
@@ -79,16 +87,17 @@ export const GameMemberCard = ({ {ageGroup}
- +
-
+ ); }; From f56c248f64ffd7a829df630c7f793589f2192f29 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 19 Aug 2026 00:32:08 +0900 Subject: [PATCH 08/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=ED=8E=98=EC=9D=B4=EC=A7=80=20ui=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 8 +- .../GameBoard/GameFilterPage.tsx | 124 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameFilterPage.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 0c85aef8..b7fad9a9 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -16,6 +16,7 @@ import { import { GameAddPlayerModal } from "./GameAddPlayerModal"; import { GameBoardWebView } from "./GameBoardWebView"; import { CourtManageBottomSheet } from "./CourtManageBottomSheet"; +import { GameFilterPage } from "./GameFilterPage"; import { notReady } from "./gameBoardShared"; export const GameBoardTab = () => { @@ -24,6 +25,7 @@ export const GameBoardTab = () => { const [courts, setCourts] = useState(mockCourts); const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); const [isWebViewOpen, setIsWebViewOpen] = useState(false); + const [isFilterOpen, setIsFilterOpen] = useState(false); const [courtManageVariant, setCourtManageVariant] = useState< "sheet" | "overlay" | null >(null); @@ -166,7 +168,7 @@ export const GameBoardTab = () => {
); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameFilterPage.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameFilterPage.tsx new file mode 100644 index 00000000..3384fcd3 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameFilterPage.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import clsx from "clsx"; +import { PageHeader } from "@/components/common/system/header/PageHeader"; +import TextBox from "@/components/common/Text_Box/TextBox"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; +import Refresh from "@/assets/icons/refresh.svg"; +import ArrowUp from "@/assets/icons/arrow_up.svg"; +import { LEVEL_KEY } from "@/constants/options"; + +const LEVEL_OPTIONS = [...LEVEL_KEY, "급수없음"]; +const GENDER_OPTIONS = ["전체", "남성", "여성"]; +const SHUTTLE_OPTIONS = ["제출함", "미제출"]; + +interface FilterToggleSectionProps { + title: string; + options: string[]; + selected: string | null; + onSelect: (value: string) => void; +} + +const FilterToggleSection = ({ + title, + options, + selected, + onSelect, +}: FilterToggleSectionProps) => { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+
+ {title} + +
+ {isOpen && ( +
+ {options.map(option => ( + onSelect(option)} + className="w-19" + > + {option} + + ))} +
+ )} +
+ ); +}; + +interface GameFilterPageProps { + onClose: () => void; +} + +export const GameFilterPage = ({ onClose }: GameFilterPageProps) => { + const [level, setLevel] = useState("전체"); + const [gender, setGender] = useState("전체"); + const [shuttle, setShuttle] = useState(null); + + const handleReset = () => { + setLevel("전체"); + setGender("전체"); + setShuttle(null); + }; + + return ( +
+ + +
+ +
+ + +
+ + + setShuttle(prev => (prev === value ? null : value)) + } + /> +
+
+ +
+ +
+ +
+
+
+ ); +}; From efe7b32005a2781ebe2e50bd62f0e79150ee58b1 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 19 Aug 2026 00:48:57 +0900 Subject: [PATCH 09/52] =?UTF-8?q?feat:=20=EB=8C=80=EA=B8=B0=EC=97=B4=20?= =?UTF-8?q?=EC=BD=94=ED=8A=B8=20=EC=9D=B4=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/CourtCard.tsx | 117 +++++++++++++----- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 30 ++++- .../GameBoard/GameBoardWebView.tsx | 17 ++- 3 files changed, 127 insertions(+), 37 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx index ca9e99b3..91f71a63 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import clsx from "clsx"; import Pen from "@/assets/icons/pen.svg"; import Reject from "@/assets/icons/reject.svg"; @@ -71,40 +73,99 @@ export const CourtCard = ({ interface WaitingCardProps { label: string; players: GamePlayer[]; - onEdit?: () => void; + courts: { id: number; label: string }[]; + onMoveToCourt?: (courtId: number) => void; onReject?: () => void; } export const WaitingCard = ({ label, players, - onEdit, + courts, + onMoveToCourt, onReject, -}: WaitingCardProps) => ( -
-
- {label} -
- - +}: WaitingCardProps) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const editButtonRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + !editButtonRef.current?.contains(e.target as Node) + ) { + setIsMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMenuOpen]); + + const handleToggleMenu = () => { + const rect = editButtonRef.current?.getBoundingClientRect(); + if (rect) { + setMenuPosition({ top: rect.bottom + 4, left: rect.right - 149 }); + } + setIsMenuOpen(prev => !prev); + }; + + return ( +
+
+ {label} +
+ + +
+
+ {players.map(p => ( + + ))} +
+ + {isMenuOpen && + createPortal( +
+ {courts.map((court, index) => ( +
+ {index > 0 &&
} + +
+ ))} +
, + document.body, + )}
-
- {players.map(p => ( - - ))} -
-
-); + ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index b7fad9a9..56d3d89a 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -12,6 +12,7 @@ import { mockWaitingGroups, type CourtGroup, type GameMember, + type WaitingGroup, } from "./mockGameBoardData"; import { GameAddPlayerModal } from "./GameAddPlayerModal"; import { GameBoardWebView } from "./GameBoardWebView"; @@ -23,6 +24,8 @@ export const GameBoardTab = () => { const [selectedIds, setSelectedIds] = useState([]); const [members, setMembers] = useState(mockGameMembers); const [courts, setCourts] = useState(mockCourts); + const [waitingGroups, setWaitingGroups] = + useState(mockWaitingGroups); const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); const [isWebViewOpen, setIsWebViewOpen] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false); @@ -60,6 +63,19 @@ export const GameBoardTab = () => { setIsAddPlayerOpen(false); }; + const handleRemoveWaitingGroup = (id: number) => { + setWaitingGroups(prev => prev.filter(group => group.id !== id)); + }; + + const handleMoveToCourt = (waitingGroupId: number, courtId: number) => { + const group = waitingGroups.find(g => g.id === waitingGroupId); + if (!group) return; + setCourts(prev => + prev.map(c => (c.id === courtId ? { ...c, players: group.players } : c)), + ); + handleRemoveWaitingGroup(waitingGroupId); + }; + const handleSaveCourts = (labels: string[]) => { setCourts(prev => labels.map((label, index) => { @@ -128,7 +144,7 @@ export const GameBoardTab = () => {
대기
- {mockWaitingGroups.length === 0 ? ( + {waitingGroups.length === 0 ? (
대기중인 팀이 없어요
@@ -136,13 +152,16 @@ export const GameBoardTab = () => {
- {mockWaitingGroups.map(group => ( + {waitingGroups.map(group => ( + handleMoveToCourt(group.id, courtId) + } + onReject={() => handleRemoveWaitingGroup(group.id)} /> ))}
@@ -241,6 +260,9 @@ export const GameBoardTab = () => { {isWebViewOpen && ( void; + onMoveToCourt: (waitingGroupId: number, courtId: number) => void; members: GameMember[]; selectedIds: number[]; toggleSelect: (id: number) => void; @@ -30,6 +33,9 @@ interface GameBoardWebViewProps { export const GameBoardWebView = ({ courts, + waitingGroups, + onRemoveWaitingGroup, + onMoveToCourt, members, selectedIds, toggleSelect, @@ -87,7 +93,7 @@ export const GameBoardWebView = ({
대기
- {mockWaitingGroups.length === 0 ? ( + {waitingGroups.length === 0 ? (
대기중인 팀이 없어요 @@ -97,13 +103,14 @@ export const GameBoardWebView = ({
- {mockWaitingGroups.map(group => ( + {waitingGroups.map(group => ( onMoveToCourt(group.id, courtId)} + onReject={() => onRemoveWaitingGroup(group.id)} /> ))}
From 2255391c56ca53895cf5b8828c8eb53a676ab171 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 19 Aug 2026 00:52:57 +0900 Subject: [PATCH 10/52] =?UTF-8?q?feat:=20=EC=BD=94=ED=8A=B8=200=EA=B0=9C?= =?UTF-8?q?=EC=9D=BC=20=EC=8B=9C=20=EC=BD=94=ED=8A=B8=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EB=9C=A8=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 56d3d89a..f651c2c4 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -31,7 +31,7 @@ export const GameBoardTab = () => { const [isFilterOpen, setIsFilterOpen] = useState(false); const [courtManageVariant, setCourtManageVariant] = useState< "sheet" | "overlay" | null - >(null); + >(() => (mockCourts.length === 0 ? "sheet" : null)); const toggleSelect = (id: number) => { setSelectedIds(prev => From e6cb9d766b2912916fdcb4f8e6317a2a2e0f4eb9 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 19 Aug 2026 02:00:10 +0900 Subject: [PATCH 11/52] =?UTF-8?q?feat:=20=EB=8C=80=EA=B8=B0=EC=97=B4=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/CourtCard.tsx | 62 +++++++++++++++---- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 31 +++++++++- .../GameBoard/GameBoardWebView.tsx | 7 ++- .../GameBoard/mockGameBoardData.ts | 3 + 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx index 91f71a63..45324b62 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -1,4 +1,10 @@ -import { useEffect, useRef, useState } from "react"; +import { + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type TouchEvent as ReactTouchEvent, +} from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; import Pen from "@/assets/icons/pen.svg"; @@ -75,20 +81,25 @@ interface WaitingCardProps { players: GamePlayer[]; courts: { id: number; label: string }[]; onMoveToCourt?: (courtId: number) => void; + onChange?: () => void; onReject?: () => void; } +const LONG_PRESS_MS = 600; + export const WaitingCard = ({ label, players, courts, onMoveToCourt, + onChange, onReject, }: WaitingCardProps) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); - const editButtonRef = useRef(null); + const cardRef = useRef(null); const menuRef = useRef(null); + const longPressTimer = useRef | null>(null); useEffect(() => { if (!isMenuOpen) return; @@ -96,7 +107,7 @@ export const WaitingCard = ({ if ( menuRef.current && !menuRef.current.contains(e.target as Node) && - !editButtonRef.current?.contains(e.target as Node) + !cardRef.current?.contains(e.target as Node) ) { setIsMenuOpen(false); } @@ -105,31 +116,58 @@ export const WaitingCard = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [isMenuOpen]); - const handleToggleMenu = () => { - const rect = editButtonRef.current?.getBoundingClientRect(); - if (rect) { - setMenuPosition({ top: rect.bottom + 4, left: rect.right - 149 }); + const openMenuAt = (x: number, y: number) => { + setMenuPosition({ top: y, left: x - 149 }); + setIsMenuOpen(true); + }; + + const clearLongPressTimer = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; } - setIsMenuOpen(prev => !prev); + }; + + const handleContextMenu = (e: ReactMouseEvent) => { + e.preventDefault(); + openMenuAt(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: ReactTouchEvent) => { + const touch = e.touches[0]; + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => { + openMenuAt(touch.clientX, touch.clientY); + }, LONG_PRESS_MS); }; return ( -
+
{label}
diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index f651c2c4..cad542a2 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -67,6 +67,28 @@ export const GameBoardTab = () => { setWaitingGroups(prev => prev.filter(group => group.id !== id)); }; + const handleAddToWaitingQueue = () => { + if (selectedMembers.length === 0) return; + const newGroup: WaitingGroup = { + id: Date.now(), + label: `대기 ${waitingGroups.length + 1}번`, + memberIds: selectedMembers.map(m => m.id), + players: selectedMembers.map((m, i) => ({ + id: m.id, + name: m.name, + group: m.group, + color: i % 2 === 0 ? "pink" : "blue", + })), + }; + setWaitingGroups(prev => [...prev, newGroup]); + setSelectedIds([]); + }; + + const handleChangeWaitingGroup = (group: WaitingGroup) => { + handleRemoveWaitingGroup(group.id); + setSelectedIds(group.memberIds); + }; + const handleMoveToCourt = (waitingGroupId: number, courtId: number) => { const group = waitingGroups.find(g => g.id === waitingGroupId); if (!group) return; @@ -161,6 +183,7 @@ export const GameBoardTab = () => { onMoveToCourt={courtId => handleMoveToCourt(group.id, courtId) } + onChange={() => handleChangeWaitingGroup(group)} onReject={() => handleRemoveWaitingGroup(group.id)} /> ))} @@ -208,13 +231,13 @@ export const GameBoardTab = () => { {/* 하단 선택 바 */}
{selectedMembers.length > 0 && ( -
+
{selectedMembers.map((m, i) => ( @@ -263,6 +286,8 @@ export const GameBoardTab = () => { waitingGroups={waitingGroups} onRemoveWaitingGroup={handleRemoveWaitingGroup} onMoveToCourt={handleMoveToCourt} + onChangeWaitingGroup={handleChangeWaitingGroup} + onAddToWaitingQueue={handleAddToWaitingQueue} members={members} selectedIds={selectedIds} toggleSelect={toggleSelect} diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx index 16439087..4ef6ee89 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -23,6 +23,8 @@ interface GameBoardWebViewProps { waitingGroups: WaitingGroup[]; onRemoveWaitingGroup: (id: number) => void; onMoveToCourt: (waitingGroupId: number, courtId: number) => void; + onChangeWaitingGroup: (group: WaitingGroup) => void; + onAddToWaitingQueue: () => void; members: GameMember[]; selectedIds: number[]; toggleSelect: (id: number) => void; @@ -36,6 +38,8 @@ export const GameBoardWebView = ({ waitingGroups, onRemoveWaitingGroup, onMoveToCourt, + onChangeWaitingGroup, + onAddToWaitingQueue, members, selectedIds, toggleSelect, @@ -110,6 +114,7 @@ export const GameBoardWebView = ({ players={group.players} courts={courts} onMoveToCourt={courtId => onMoveToCourt(group.id, courtId)} + onChange={() => onChangeWaitingGroup(group)} onReject={() => onRemoveWaitingGroup(group.id)} /> ))} @@ -169,7 +174,7 @@ export const GameBoardWebView = ({ type="button" disabled={selectedIds.length === 0} className="flex w-[10.3125rem] shrink-0 items-center justify-center rounded-lg bg-gr-600 px-4 py-2 body-sm-500 text-white disabled:bg-gy-400" - onClick={notReady} + onClick={onAddToWaitingQueue} > 대기열 추가 diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts index 075485b4..f468f485 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -16,6 +16,7 @@ export interface WaitingGroup { id: number; label: string; players: GamePlayer[]; + memberIds: number[]; } export type MemberTag = "운동" | "대기" | "미참여"; @@ -80,6 +81,7 @@ export const mockWaitingGroups: WaitingGroup[] = [ { id: 1, label: "대기 1번", + memberIds: [1, 2, 3, 4], players: [ player(13, "김셰익", "A", "pink"), player(14, "김셰익", "B", "pink"), @@ -90,6 +92,7 @@ export const mockWaitingGroups: WaitingGroup[] = [ { id: 2, label: "대기 2번", + memberIds: [2, 3, 4, 5], players: [ player(17, "김셰익", "A", "pink"), player(18, "김셰익", "B", "pink"), From 257c8439a3afdab77205427c8b39152b83897878 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 19 Aug 2026 02:08:04 +0900 Subject: [PATCH 12/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=BD=94?= =?UTF-8?q?=ED=8A=B8=20=EC=99=84=EB=A3=8C=20=ED=99=95=EC=9D=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 23 +++++++++- .../GameBoard/GameBoardWebView.tsx | 19 +++++++- .../MyGroupDetail/GameBoard/GameEndModal.tsx | 46 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameEndModal.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index cad542a2..6c427ca3 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -18,6 +18,7 @@ import { GameAddPlayerModal } from "./GameAddPlayerModal"; import { GameBoardWebView } from "./GameBoardWebView"; import { CourtManageBottomSheet } from "./CourtManageBottomSheet"; import { GameFilterPage } from "./GameFilterPage"; +import { GameEndModal } from "./GameEndModal"; import { notReady } from "./gameBoardShared"; export const GameBoardTab = () => { @@ -29,6 +30,9 @@ export const GameBoardTab = () => { const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); const [isWebViewOpen, setIsWebViewOpen] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false); + const [completingCourtId, setCompletingCourtId] = useState( + null, + ); const [courtManageVariant, setCourtManageVariant] = useState< "sheet" | "overlay" | null >(() => (mockCourts.length === 0 ? "sheet" : null)); @@ -98,6 +102,15 @@ export const GameBoardTab = () => { handleRemoveWaitingGroup(waitingGroupId); }; + const handleCompleteCourt = (courtId: number) => { + setCourts(prev => + prev.map(c => + c.id === courtId ? { ...c, players: null, timer: undefined } : c, + ), + ); + setCompletingCourtId(null); + }; + const handleSaveCourts = (labels: string[]) => { setCourts(prev => labels.map((label, index) => { @@ -153,7 +166,7 @@ export const GameBoardTab = () => { label={court.label} timer={court.timer} players={court.players} - onComplete={notReady} + onComplete={() => setCompletingCourtId(court.id)} /> ))}
@@ -283,6 +296,7 @@ export const GameBoardTab = () => { {isWebViewOpen && ( { {isFilterOpen && ( setIsFilterOpen(false)} /> )} + + {completingCourtId !== null && ( + setCompletingCourtId(null)} + onConfirm={() => handleCompleteCourt(completingCourtId)} + /> + )}
); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx index 4ef6ee89..069e8c8e 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import clsx from "clsx"; import AddWhite from "@/assets/icons/add_white.svg"; import Sparkle from "@/assets/icons/sparkle_filled.svg"; @@ -5,6 +6,7 @@ import Dismiss from "@/assets/icons/dismiss.svg"; import ArrowLeft from "@/assets/icons/arrow_left.svg"; import { CourtCard, WaitingCard } from "./CourtCard"; import { GameMemberCard } from "./GameMemberCard"; +import { GameEndModal } from "./GameEndModal"; import { type CourtGroup, type GameMember, @@ -20,6 +22,7 @@ import { interface GameBoardWebViewProps { courts: CourtGroup[]; + onCompleteCourt: (courtId: number) => void; waitingGroups: WaitingGroup[]; onRemoveWaitingGroup: (id: number) => void; onMoveToCourt: (waitingGroupId: number, courtId: number) => void; @@ -35,6 +38,7 @@ interface GameBoardWebViewProps { export const GameBoardWebView = ({ courts, + onCompleteCourt, waitingGroups, onRemoveWaitingGroup, onMoveToCourt, @@ -48,6 +52,9 @@ export const GameBoardWebView = ({ onClose, }: GameBoardWebViewProps) => { const selectedMembers = members.filter(m => selectedIds.includes(m.id)); + const [completingCourtId, setCompletingCourtId] = useState( + null, + ); return (
@@ -84,7 +91,7 @@ export const GameBoardWebView = ({ label={court.label} timer={court.timer} players={court.players} - onComplete={notReady} + onComplete={() => setCompletingCourtId(court.id)} /> ))}
@@ -199,6 +206,16 @@ export const GameBoardWebView = ({
+ + {completingCourtId !== null && ( + setCompletingCourtId(null)} + onConfirm={() => { + onCompleteCourt(completingCourtId); + setCompletingCourtId(null); + }} + /> + )}
); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameEndModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameEndModal.tsx new file mode 100644 index 00000000..bc720825 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameEndModal.tsx @@ -0,0 +1,46 @@ +import Dismiss from "@/assets/icons/dismiss.svg"; +import ExerciseFilled from "@/assets/icons/Exercise_filled.svg"; + +interface GameEndModalProps { + onClose: () => void; + onConfirm: () => void; +} + +export const GameEndModal = ({ onClose, onConfirm }: GameEndModalProps) => ( +
+
e.stopPropagation()} + > +
+
+ +
+ +
+ + 게임을 완료하시겠어요? + + 완료한 게임은 '게임 완료' 탭에서 확인할 수 있어요. + +
+ + +
+
+); From ba871ff270c9f7cffcb9b98e8a55f04959d0ab88 Mon Sep 17 00:00:00 2001 From: kimtaeyeon04 Date: Wed, 19 Aug 2026 22:52:12 +0900 Subject: [PATCH 13/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=83=AD=20=EB=B0=8F=20=EC=9A=B4=EC=98=81=EC=9E=90?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95=20UI=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 5 + src/api/exercise/exercises.ts | 11 + src/components/common/contentcard/Member.tsx | 6 + .../group/MyGroupDetail/GameFinishedTab.tsx | 130 +++++++++ .../group/MyGroupDetail/GameManagerPage.tsx | 270 +++++++++++++++++ .../group/MyGroupDetail/MyExerciseDetail.tsx | 271 ++++++++++-------- src/pages/group/index.ts | 1 + 7 files changed, 575 insertions(+), 119 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameFinishedTab.tsx create mode 100644 src/pages/group/MyGroupDetail/GameManagerPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 89403817..02a9f6cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { MemberDefault, ExerciseDetailApply, MyExerciseDetail, + GameManagerPage, ViceLeaderDefault, EditGroupInfoDefault, } from "./pages/group"; @@ -222,6 +223,10 @@ const router = createBrowserRouter([ path: "/group/Mygroup/MyExerciseDetail/:exerciseId", element: , }, + { + path: "/group/Mygroup/GameManager/:exerciseId", + element: , + }, { path: "/group/Mygroup/ExerciseDetailApplye", element: , diff --git a/src/api/exercise/exercises.ts b/src/api/exercise/exercises.ts index 37cb5231..1b6ec67b 100644 --- a/src/api/exercise/exercises.ts +++ b/src/api/exercise/exercises.ts @@ -220,3 +220,14 @@ export const deleteExercise = async (exerciseId: number) => { throw error.response?.data || error; } }; + +// 게임 진행자 지정하기 (API가 아직 없을 수 있으므로 일단 추가) +export const setGameManager = async (exerciseId: number, participantId: number) => { + try { + const response = await api.patch(`/api/exercises/${exerciseId}/participants/${participantId}/manager`); + return response.data; + } catch (error: any) { + console.error("게임 진행자 지정 API 호출 실패:", error); + throw error.response?.data || error; + } +}; diff --git a/src/components/common/contentcard/Member.tsx b/src/components/common/contentcard/Member.tsx index 4400e6a5..25065e61 100644 --- a/src/components/common/contentcard/Member.tsx +++ b/src/components/common/contentcard/Member.tsx @@ -106,6 +106,7 @@ const MemberInfo = ({ isGuest, guestName, isLeader, + isManager, position, }: Partial) => { const formattedDate = lastExerciseDate?.replace(/-/g, "."); @@ -118,6 +119,11 @@ const MemberInfo = ({ {!isLeader && position === "sub_leader" && ( )} + {isManager && ( + + ★ 게임 진행자 + + )}
{gender === "FEMALE" ? ( diff --git a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx new file mode 100644 index 00000000..d56042d7 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx @@ -0,0 +1,130 @@ +export interface PlayerInfo { + name: string; + tag: string; +} + +export interface MatchInfo { + id: number; + duration: string; + endTime: string; + teamA: PlayerInfo[]; + teamB: PlayerInfo[]; +} + +interface GameFinishedTabProps { + exerciseId: number; + // 추후 API 데이터 통신 시 props로 넘겨주거나, 내부에서 useQuery 등으로 호출할 수 있습니다. + // matches?: MatchInfo[]; +} + +export const GameFinishedTab = ({ exerciseId }: GameFinishedTabProps) => { + // TODO: 실제 API 연동 시 아래 더미 배열 대신 서버 상태(useQuery)를 사용하세요. + // 예: const { data: mockMatches } = useGameFinishedQuery(exerciseId); + const mockMatches: MatchInfo[] = [ + { + id: 1, + duration: "15분", + endTime: "22:30 완료", + teamA: [ + { name: "김셰익", tag: "A" }, + { name: "김셰익", tag: "B" } + ], + teamB: [ + { name: "김셰익", tag: "C" }, + { name: "김셰익", tag: "준자강" } + ] + }, + { + id: 2, + duration: "15분", + endTime: "22:30 완료", + teamA: [ + { name: "김셰익", tag: "A" }, + { name: "김셰익", tag: "B" } + ], + teamB: [ + { name: "김셰익", tag: "C" }, + { name: "김셰익", tag: "준자강" } + ] + }, + { + id: 3, + duration: "15분", + endTime: "22:30 완료", + teamA: [ + { name: "김셰익", tag: "A" }, + { name: "김셰익", tag: "B" } + ], + teamB: [ + { name: "김셰익", tag: "C" }, + { name: "김셰익", tag: "준자강" } + ] + }, + { + id: 4, + duration: "15분", + endTime: "22:30 완료", + teamA: [ + { name: "김셰익", tag: "A" }, + { name: "김셰익", tag: "B" } + ], + teamB: [ + { name: "김셰익", tag: "C" }, + { name: "김셰익", tag: "준자강" } + ] + } + ]; + + return ( +
+ {/* 01 코트 / 좌우 화살표 */} +
+ + +
+ 01 코트 +
+ + +
+ + {/* 매치 리스트 */} +
+ {mockMatches.map(match => ( +
+
+ {match.duration} + {match.endTime} +
+
+
+ {match.teamA.map((p, idx) => ( +
+ {p.name} + {p.tag} +
+ ))} +
+
+ {match.teamB.map((p, idx) => ( +
+ {p.name} + {p.tag} +
+ ))} +
+
+
+ ))} +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameManagerPage.tsx b/src/pages/group/MyGroupDetail/GameManagerPage.tsx new file mode 100644 index 00000000..e879fc53 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameManagerPage.tsx @@ -0,0 +1,270 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { PageHeader } from "../../../components/common/system/header/PageHeader"; +import { Member, type MemberProps } from "../../../components/common/contentcard/Member"; +import { getExerciseDetail, setGameManager } from "../../../api/exercise/exercises"; +import useUserStore from "../../../store/useUserStore"; +import { useQueryClient } from "@tanstack/react-query"; +import DismissIcon from "../../../assets/icons/dismiss.svg?react"; +import Search from "../../../assets/icons/search.svg?react"; +import Grad_GR400_L from "../../../components/common/Btn_Static/Text/Grad_GR400_L"; +import GY800_S from "../../../components/common/Btn_Static/Text/GY800_S"; + +export const GameManagerPage = () => { + const navigate = useNavigate(); + const { exerciseId } = useParams<{ exerciseId: string }>(); + const exerciseIdNumber = Number(exerciseId); + const { user } = useUserStore(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + + const [members, setMembers] = useState([ + { + participantId: 1, + memberId: 101, + status: "Participating", + name: "탕수육", + gender: "FEMALE", + level: "전국 D조", + lastExerciseDate: "2000-00-00", + isLeader: true, + isManager: true, + }, + { + participantId: 2, + memberId: 102, + status: "Participating", + name: "마라탕", + gender: "FEMALE", + level: "전국 D조", + lastExerciseDate: "2000-00-00", + isLeader: false, + isManager: false, + }, + { + participantId: 3, + memberId: 103, + status: "Participating", + name: "고구마", + gender: "MALE", + level: "전국 D조", + lastExerciseDate: "2000-00-00", + isLeader: false, + isManager: false, + }, + { + participantId: 4, + memberId: 104, + status: "Participating", + name: "옥수수", + gender: "MALE", + level: "전국 D조", + lastExerciseDate: "2000-00-00", + isLeader: false, + isManager: false, + } + ]); + const [totalCount, setTotalCount] = useState(4); + + + const [selectedMember, setSelectedMember] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectMode, setSelectMode] = useState(false); + + useEffect(() => { + if (exerciseIdNumber) { + fetchMembers(); + } + }, [exerciseIdNumber, user?.memberId]); + + const fetchMembers = async () => { + // API 호출 대신 더미 데이터를 그대로 사용합니다. + /* + try { + const res = await getExerciseDetail(exerciseIdNumber, user?.memberId); + setMembers(res.participantMembers); + setTotalCount(res.participantsCount); + } catch (error) { + console.error("운동 상세 조회 실패", error); + } + */ + }; + + + + const handleAppointClick = (member: MemberProps) => { + if (member.isManager) { + // 이미 지정된 경우 취소하거나 무시 + return; + } + setSelectedMember(member); + setIsModalOpen(true); + }; + + const handleConfirmAppoint = async () => { + if (!selectedMember || selectedMember.participantId === undefined) return; + + try { + // 실제 API 호출 + // 만약 API가 없다면 아래 로직은 에러를 내뿜을 수 있음. + // API 호출 대신 프론트엔드 상태만 즉시 업데이트합니다. + const updatedMembers = members.map(m => { + if (m.participantId === selectedMember.participantId) { + return { ...m, isManager: true }; + } else if (m.isManager) { + // 기존 게임 진행자 권한 즉시 해제 + return { ...m, isManager: false }; + } + return m; + }); + setMembers(updatedMembers); + + /* + try { + await setGameManager(exerciseIdNumber, selectedMember.participantId); + } catch (err) { + console.warn("API 호출 실패, 하지만 프론트엔드 상태는 업데이트됨:", err); + } + queryClient.invalidateQueries({ + queryKey: ["exerciseDetail"], + }); + */ + + setIsModalOpen(false); + setSelectMode(false); + alert("게임 진행자가 변경되었습니다."); + + // 모달 닫기 후 뒤로가기? 사용자 요구사항엔 없지만 보통 권한 변경 후 유지 또는 뒤로가기. + // 유지하는 것으로 결정. + } catch (error: any) { + alert(error?.message || "게임 진행자 변경에 실패했습니다."); + } + }; + + // 검색 기능 + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + const filteredMembers = members.filter((member) => { + const term = searchTerm.toLowerCase(); + if (!term) return true; + return ( + member.name?.toLowerCase().includes(term) || + member.level?.toLowerCase().includes(term) + ); + }); + + + return ( +
setSelectMode(false)} + > + navigate(-1)} /> + +
e.stopPropagation()}> + {/* 검색창 */} +
+
+ + + + +
+
+ + {/* 인원 표시 */} +
+
+
+ +

{totalCount}

+
+
+
+ + {/* 멤버 리스트 */} +
+ {filteredMembers.map((member, idx) => ( +
+ { + if (!selectMode && member.memberId) { + navigate(`/mypage/profile/${member.memberId}`); + } + }} + onAppointClick={() => handleAppointClick(member)} + /> +
+
+ ))} +
+
+ + {/* 하단버튼 */} + {!selectMode && ( +
e.stopPropagation()} + > + setSelectMode(true)} + /> +
+ )} + + {/* 게임 진행자 지정 모달 */} + {isModalOpen && ( +
setIsModalOpen(false)} + > +
e.stopPropagation()} + > + {/* 닫기 버튼: absolute로 띄워서 내용물을 아래로 밀어내지 않게 함 */} + setIsModalOpen(false)} + /> + +
+ + + + +

게임 진행자로 지정하시겠어요?

+ +
+

'지정하기'를 누르시면,

+

게임 진행 권한이 부여됩니다.

+
+
+ +
+ +
+
+
+ )} +
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index b76b7fc2..a0a1fac2 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -10,6 +10,8 @@ import { Modal_ExDel } from "../../../components/group/Modal_ExDel copy"; import { useState, useEffect } from "react"; import { getModalConfig } from "../../../components/group/modalConfig"; import { SortBottomSheet } from "../../../components/common/SortBottomSheet"; +import TabSelector from "../../../components/common/TabSelector"; +import { GameFinishedTab } from "./GameFinishedTab"; import { getExerciseDetail, cancelSelf, @@ -51,6 +53,14 @@ export const MyExerciseDetail = () => { const [isWithdrawnModal, setIsWithdrawnModal] = useState(false); + type TabType = "detail" | "game" | "finished"; + const [activeTab, setActiveTab] = useState("detail"); + const tabOptions = [ + { label: "운동 상세", value: "detail" }, + { label: "게임판", value: "game" }, + { label: "게임 완료", value: "finished" }, + ]; + // 운동 상세 조회 useEffect(() => { if (exerciseIdNumber) { @@ -73,8 +83,8 @@ export const MyExerciseDetail = () => { p.position === "PARTY_MANAGER", position: p.position === "OWNER" || - p.position === "MANAGER" || - p.position === "PARTY_MANAGER" + p.position === "MANAGER" || + p.position === "PARTY_MANAGER" ? "leader" : p.position === "SUBOWNER" || p.position === "PARTY_SUBMANAGER" ? "sub_leader" @@ -162,138 +172,77 @@ export const MyExerciseDetail = () => { }} /> -
- {/* 장소 정보 */} -
- {detail.notice && ( - <> -
- -

{detail.notice}

-
- - )} -
- -
-

- {detail.placeName} -

-

{detail.placeAddress}

-
-
-
- - {/* 참여 인원 */} -
-
-
- - - {detail.participantGenderCount.male + - detail.participantGenderCount.female}{" "} - / {detail.participantsCount} - -
-
- -

- {detail.participantGenderCount.female} -

- -

- {detail.participantGenderCount.male} -

-
-
-
+ setActiveTab(val)} + /> - {/* 참여 멤버 리스트 */} - {members.map((member, idx) => { - const modalConfig = getModalConfig( - member.status, - isCurrentUserLeader, - member.isMe ?? false, - member.name, - ); - const handleIsUSer = () => { - if (member.isWithdrawn) { - setIsWithdrawnModal(true); - } else { - navigate(`/mypage/profile/${member.memberId}`); - } - }; - return ( -
- { - if (member.participantId !== undefined) { - handleDeleteMember(member.participantId, { - isLeaderAction: isCurrentUserLeader && !member.isMe, - isGuest: member.isGuest, - }); - } - }} - showDeleteButton={ - isCurrentUserLeader || (member.isMe && !isCurrentUserLeader) - } - modalConfig={modalConfig ?? undefined} - /> -
- {isWithdrawnModal && ( - setIsWithdrawnModal(false)} - /> +
+ {activeTab === "detail" && ( +
+ {/* 장소 정보 */} +
+ {detail.notice && ( + <> +
+ +

{detail.notice}

+
+ )} -
- ); - })} - - {/* 대기 인원 */} - {waitingMembers.length > 0 && ( -
-
-
- -

{waitingCount}

+
+ +
+

+ {detail.placeName} +

+

{detail.placeAddress}

+
-
- -

- {detail.waitingGenderCount?.female ?? 0} -

- -

- {detail.waitingGenderCount?.male ?? 0} -

+
+ + {/* 참여 인원 */} +
+
+
+ + + {detail.participantGenderCount.male + + detail.participantGenderCount.female}{" "} + / {detail.participantsCount} + +
+
+ +

+ {detail.participantGenderCount.female} +

+ +

+ {detail.participantGenderCount.male} +

+
- {waitingMembers.map((member, idx) => { + {/* 참여 멤버 리스트 */} + {members.map((member, idx) => { const modalConfig = getModalConfig( member.status, isCurrentUserLeader, member.isMe ?? false, member.name, ); - const handleIsUSer = () => { if (member.isWithdrawn) { setIsWithdrawnModal(true); - // } else { - // navigate(`/mypage/profile/${member.memberId}`); + } else { + navigate(`/mypage/profile/${member.memberId}`); } }; - return ( -
+
{ } }} showDeleteButton={ - isCurrentUserLeader || - (member.isMe && !isCurrentUserLeader) + isCurrentUserLeader || (member.isMe && !isCurrentUserLeader) } modalConfig={modalConfig ?? undefined} />
+ {isWithdrawnModal && ( + setIsWithdrawnModal(false)} + /> + )}
); })} + + {/* 대기 인원 */} + {waitingMembers.length > 0 && ( +
+
+
+ +

{waitingCount}

+
+
+ +

+ {detail.waitingGenderCount?.female ?? 0} +

+ +

+ {detail.waitingGenderCount?.male ?? 0} +

+
+
+ + {waitingMembers.map((member, idx) => { + const modalConfig = getModalConfig( + member.status, + isCurrentUserLeader, + member.isMe ?? false, + member.name, + ); + + const handleIsUSer = () => { + if (member.isWithdrawn) { + setIsWithdrawnModal(true); + // } else { + // navigate(`/mypage/profile/${member.memberId}`); + } + }; + + return ( +
+ { + if (member.participantId !== undefined) { + handleDeleteMember(member.participantId, { + isLeaderAction: isCurrentUserLeader && !member.isMe, + isGuest: member.isGuest, + }); + } + }} + showDeleteButton={ + isCurrentUserLeader || + (member.isMe && !isCurrentUserLeader) + } + modalConfig={modalConfig ?? undefined} + /> +
+
+ ); + })} +
+ )} +
+ )} + + {activeTab === "game" && ( +
+ 게임판 화면입니다.
)} + + {activeTab === "finished" && }
setIsSortOpen(false)} - options={["운동 수정하기", "운동 삭제하기"]} + options={["게임 진행자 관리", "운동 수정하기", "운동 삭제하기"]} onSelect={option => { + if (option === "게임 진행자 관리") { + navigate( + `/group/Mygroup/GameManager/${exerciseId}?returnPath=${returnPath}`, + ); + } if (option === "운동 수정하기") { navigate( `/group/exercise/edit/${exerciseId}?returnPath=${returnPath}`, diff --git a/src/pages/group/index.ts b/src/pages/group/index.ts index 9339c731..93093eea 100644 --- a/src/pages/group/index.ts +++ b/src/pages/group/index.ts @@ -5,3 +5,4 @@ export { ViceLeaderDefault } from "./AdminMenu/ViceLeaderDefault"; export { EditGroupInfoDefault } from "./AdminMenu/EditGroupInfoDefault"; export { ExerciseDetail } from "./MyGroupDetail/ExerciseDetail"; // 대기 신청하는 화면 export { MyExerciseDetail } from "./MyGroupDetail/MyExerciseDetail"; //MyExerciseDetail 내 운동 상세 +export { GameManagerPage } from "./MyGroupDetail/GameManagerPage"; From 30bce1412a6b9e89f679f5c5c3c63eaec40a9fca Mon Sep 17 00:00:00 2001 From: kimtaeyeon04 Date: Wed, 19 Aug 2026 23:02:59 +0900 Subject: [PATCH 14/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=83=AD=20=EB=B0=8F=20=EC=9A=B4=EC=98=81=EC=9E=90?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95=20UI=20=EC=A0=9C=EC=9E=912?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/MyGroupDetail/GameFinishedTab.tsx | 76 ++++++++++--------- .../group/MyGroupDetail/MyExerciseDetail.tsx | 31 +++++++- 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx index d56042d7..d3f7d423 100644 --- a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx +++ b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx @@ -76,55 +76,57 @@ export const GameFinishedTab = ({ exerciseId }: GameFinishedTabProps) => { ]; return ( -
- {/* 01 코트 / 좌우 화살표 */} -
- -
- 01 코트 + {/* 매치 리스트를 담는 회색 배경 컴포넌트 */} +
+ {mockMatches.map(match => ( +
+
+ {match.duration} + {match.endTime} +
+
+
+ {match.teamA.map((p, idx) => ( +
+ {p.name} + {p.tag} +
+ ))} +
+
+ {match.teamB.map((p, idx) => ( +
+ {p.name} + {p.tag} +
+ ))} +
+
+
+ ))}
-
- - {/* 매치 리스트 */} -
- {mockMatches.map(match => ( -
-
- {match.duration} - {match.endTime} -
-
-
- {match.teamA.map((p, idx) => ( -
- {p.name} - {p.tag} -
- ))} -
-
- {match.teamB.map((p, idx) => ( -
- {p.name} - {p.tag} -
- ))} -
-
-
- ))} -
); }; diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index a0a1fac2..f7611ea8 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -37,8 +37,35 @@ export const MyExerciseDetail = () => { const { exerciseId } = useParams<{ exerciseId: string }>(); const exerciseIdNumber = Number(exerciseId); - const [detail, setDetail] = useState(null); - const [members, setMembers] = useState([]); + const [detail, setDetail] = useState({ + notice: "API 에러(CORS/Network)로 인해 출력된 임시 화면입니다.", + placeName: "테스트 체육관 코트", + placeAddress: "서울특별시 강남구 테헤란로 123", + participantGenderCount: { male: 2, female: 2 }, + participantsCount: 4, + isManager: true, + participantMembers: [], + waitingMembers: [], + waitingGenderCount: { male: 0, female: 0 }, + } as any); + const [members, setMembers] = useState([ + { + participantId: 1, + memberId: 1, + status: "Participating", + name: "김셰익스피어", + gender: "MALE", + level: "A조", + isMe: true, + isLeader: true, + position: "leader", + imgUrl: null, + canCancel: true, + isGuest: false, + inviterName: "", + isWithdrawn: false, + } + ]); // const [participantsCount, setParticipantsCount] = useState(0); const [waitingMembers, setWaitingMembers] = useState([]); From c06842de30222283dea508db3d778eae03fd5478 Mon Sep 17 00:00:00 2001 From: kimtaeyeon04 Date: Wed, 19 Aug 2026 23:05:53 +0900 Subject: [PATCH 15/52] =?UTF-8?q?fix:=20=EC=BB=A4=EC=84=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20=EC=88=98=EB=9D=BD/=EA=B1=B0=EC=A0=88=20AP?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/alarm/AlertPage.tsx | 60 ++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/src/pages/alarm/AlertPage.tsx b/src/pages/alarm/AlertPage.tsx index c0cafd4f..235a3eed 100644 --- a/src/pages/alarm/AlertPage.tsx +++ b/src/pages/alarm/AlertPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import AlertInvite from "../../components/common/contentcard/alertTest/AlertInvite"; @@ -13,14 +13,17 @@ import api from "../../api/api"; import { PageHeader } from "../../components/common/system/header/PageHeader"; import { EmptyState } from "../../components/alert/EmptyState"; import AlertTest1 from "../../components/common/contentcard/alertTest/AlertTest1"; -import type { AlertListResponse, ResponseAlertDto } from "../../types/alert"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AlertListResponse, ResponseAlertDto, AlertListPage } from "../../types/alert"; +import { useMutation, useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { AlertSkeleton } from "../../components/alert/AlertSkeleton"; import DefaultGroupImg from "@/assets/icons/defaultGroupImg.svg?url"; -const fetchNotifications = async (): Promise => { - const response = await api.get("/api/notifications"); - return response.data.data.notifications; +const fetchNotifications = async ({ pageParam = null }: { pageParam?: number | null }): Promise => { + const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; + const response = await api.get( + `/api/v2/notifications?destination=APP${cursorParam}` + ); + return response.data.data; }; export const AlertPage = () => { @@ -31,20 +34,43 @@ export const AlertPage = () => { const [modalType, setModalType] = useState<"approve" | "reject" | null>(null); const { - data: notifications = [], + data, + fetchNextPage, + hasNextPage, isLoading, isError, - } = useQuery({ + } = useInfiniteQuery({ queryKey: ["notifications"], queryFn: fetchNotifications, + initialPageParam: null as number | null, + getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), staleTime: 1000 * 60, }); + const notifications = data?.pages.flatMap(page => page.notifications) || []; + // INVITE/CHANGE/SIMPLE 만 노출 const visibleNotifications = notifications.filter(alert => ["INVITE", "CHANGE", "SIMPLE"].includes(alert.type), ); + const observerRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasNextPage && !isLoading) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + if (observerRef.current) { + observer.observe(observerRef.current); + } + return () => observer.disconnect(); + }, [hasNextPage, fetchNextPage, isLoading]); + const handleAccept = (id: number) => { setTargetId(id); setModalType("approve"); @@ -92,10 +118,8 @@ export const AlertPage = () => { // 🌟CHANGE/SIMPLE 읽음 처리 공통 뮤테이션 const markReadMutation = useMutation({ mutationFn: async (notification: ResponseAlertDto) => { - const { notificationId, type } = notification; - - // 현재 구현처럼 쿼리스트링로 전송 - await api.patch(`/api/notifications/${notificationId}?type=${type}`); + const { notificationId } = notification; + await api.patch(`/api/v2/notifications/${notificationId}/read`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notifications"] }); @@ -110,10 +134,8 @@ export const AlertPage = () => { const { notificationId, partyId } = notification; const invitationId = extractInvitationId(notification.data); - // 알림 상태 수정 (INVITE → INVITE_ACCEPT) - await api.patch( - `/api/notifications/${notificationId}?type=INVITE_ACCEPT`, - ); + // V2: 알림 읽음 처리만 하고, 수락은 초대 API로 별도 호출 + await api.patch(`/api/v2/notifications/${notificationId}/read`); if (partyId && invitationId) { await api.patch(`/api/parties/invitations/${invitationId}`, { @@ -138,9 +160,8 @@ export const AlertPage = () => { mutationFn: async (notification: ResponseAlertDto) => { const { notificationId, data } = notification; - await api.patch( - `/api/notifications/${notificationId}?type=INVITE_REJECT`, - ); + // V2: 알림 읽음 처리만 하고, 거절은 초대 API로 별도 호출 + await api.patch(`/api/v2/notifications/${notificationId}/read`); if (data?.invitationId) { await api.patch(`/api/parties/invitations/${data.invitationId}`, { @@ -218,6 +239,7 @@ export const AlertPage = () => { ), ) )} + {hasNextPage &&
}
{modalType === "approve" && selectedAlert && ( From 3230e699d7a9b1f9cb79b8349e73a4453171162a Mon Sep 17 00:00:00 2001 From: kimtaeyeon04 Date: Wed, 19 Aug 2026 23:16:49 +0900 Subject: [PATCH 16/52] =?UTF-8?q?fix=20:=20=EC=84=9C=EB=B2=84=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite.config.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index 668105db..a9bc1fa3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -87,6 +87,17 @@ export default defineConfig(({ mode }) => { server: { host: true, + proxy: { + "/api": { + target: "https://api.cockple.site", + changeOrigin: true, + }, + "/ws": { + target: "https://api.cockple.site", + changeOrigin: true, + ws: true, + }, + }, }, define: { From 8934e47ae8b0c6228486ab16724dad079492f456 Mon Sep 17 00:00:00 2001 From: qowldud Date: Wed, 19 Aug 2026 23:56:14 +0900 Subject: [PATCH 17/52] =?UTF-8?q?feat:=20=EC=A0=95=EB=B3=B4=EC=88=98?= =?UTF-8?q?=EC=A0=95=20ui=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 50 ++++++++ .../GameBoard/GameBoardWebView.tsx | 6 + .../GameBoard/GameEditPlayerModal.tsx | 119 ++++++++++++++++++ .../GameBoard/GameMemberCard.tsx | 118 ++++++++++++++++- 4 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 6c427ca3..cd785114 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -15,6 +15,10 @@ import { type WaitingGroup, } from "./mockGameBoardData"; import { GameAddPlayerModal } from "./GameAddPlayerModal"; +import { + GameEditPlayerModal, + type EditedGamePlayer, +} from "./GameEditPlayerModal"; import { GameBoardWebView } from "./GameBoardWebView"; import { CourtManageBottomSheet } from "./CourtManageBottomSheet"; import { GameFilterPage } from "./GameFilterPage"; @@ -28,6 +32,7 @@ export const GameBoardTab = () => { const [waitingGroups, setWaitingGroups] = useState(mockWaitingGroups); const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); + const [editingMemberId, setEditingMemberId] = useState(null); const [isWebViewOpen, setIsWebViewOpen] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false); const [completingCourtId, setCompletingCourtId] = useState( @@ -44,6 +49,26 @@ export const GameBoardTab = () => { }; const selectedMembers = members.filter(m => selectedIds.includes(m.id)); + const editingMember = + members.find(m => m.id === editingMemberId) ?? null; + + const handleSaveMemberEdit = (updated: EditedGamePlayer) => { + if (editingMemberId === null) return; + setMembers(prev => + prev.map(m => + m.id === editingMemberId + ? { + ...m, + name: updated.name, + gender: updated.gender, + group: updated.level, + ageGroup: updated.ageGroup, + } + : m, + ), + ); + setEditingMemberId(null); + }; const handleAddPlayer = (player: { name: string; @@ -67,6 +92,19 @@ export const GameBoardTab = () => { setIsAddPlayerOpen(false); }; + const handleToggleParticipation = (id: number) => { + setMembers(prev => + prev.map(m => { + if (m.id !== id) return m; + const isWithdrawn = m.tags.includes("미참여"); + return isWithdrawn + ? { ...m, tags: m.tags.filter(t => t !== "미참여"), selectable: true } + : { ...m, tags: ["미참여"], selectable: false }; + }), + ); + setSelectedIds(prev => prev.filter(v => v !== id)); + }; + const handleRemoveWaitingGroup = (id: number) => { setWaitingGroups(prev => prev.filter(group => group.id !== id)); }; @@ -236,6 +274,8 @@ export const GameBoardTab = () => { member={member} selected={selectedIds.includes(member.id)} onToggleSelect={() => toggleSelect(member.id)} + onEditInfo={() => setEditingMemberId(member.id)} + onToggleParticipation={() => handleToggleParticipation(member.id)} /> ))}
@@ -305,6 +345,8 @@ export const GameBoardTab = () => { members={members} selectedIds={selectedIds} toggleSelect={toggleSelect} + onToggleParticipation={handleToggleParticipation} + onEditMember={setEditingMemberId} onAddPlayer={() => setIsAddPlayerOpen(true)} onManageCourts={() => setCourtManageVariant("overlay")} onClose={() => setIsWebViewOpen(false)} @@ -318,6 +360,14 @@ export const GameBoardTab = () => { /> )} + {editingMember && ( + setEditingMemberId(null)} + onSubmit={handleSaveMemberEdit} + /> + )} + {courtManageVariant && ( void; + onToggleParticipation: (id: number) => void; + onEditMember: (id: number) => void; onAddPlayer: () => void; onManageCourts: () => void; onClose: () => void; @@ -47,6 +49,8 @@ export const GameBoardWebView = ({ members, selectedIds, toggleSelect, + onToggleParticipation, + onEditMember, onAddPlayer, onManageCourts, onClose, @@ -201,6 +205,8 @@ export const GameBoardWebView = ({ member={member} selected={selectedIds.includes(member.id)} onToggleSelect={() => toggleSelect(member.id)} + onEditInfo={() => onEditMember(member.id)} + onToggleParticipation={() => onToggleParticipation(member.id)} /> ))}
diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx new file mode 100644 index 00000000..6cd92ff8 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { PageHeader } from "@/components/common/system/header/PageHeader"; +import InputField from "@/components/common/Search_Filed/InputField"; +import TextBox from "@/components/common/Text_Box/TextBox"; +import DropCheckBox from "@/components/common/Drop_Box/DropCheckBox"; +import { DropBox } from "@/components/common/DropBox"; +import Grad_GR400_L from "@/components/common/Btn_Static/Text/Grad_GR400_L"; +import Circle_RedIcon from "@/assets/icons/cicle_s_red.svg?url"; +import { LEVEL_KEY } from "@/constants/options"; +import type { GameMember } from "./mockGameBoardData"; + +const AGE_OPTIONS = ["10대", "20대", "30대", "40대", "50대", "60대 이상"]; + +export interface EditedGamePlayer { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup: string; +} + +interface GameEditPlayerModalProps { + member: GameMember; + onClose: () => void; + onSubmit: (player: EditedGamePlayer) => void; +} + +export const GameEditPlayerModal = ({ + member, + onClose, + onSubmit, +}: GameEditPlayerModalProps) => { + const [name, setName] = useState(member.name); + const [gender, setGender] = useState<"MALE" | "FEMALE" | null>( + member.gender, + ); + const [level, setLevel] = useState( + member.group === "급수없음" ? "disabled" : member.group, + ); + const [ageGroup, setAgeGroup] = useState(member.ageGroup); + + const levelOptions = LEVEL_KEY.slice(1); + const isLevelValid = level === "disabled" || levelOptions.includes(level); + const isFormValid = name.trim().length > 0 && gender !== null && isLevelValid; + + const handleSubmit = () => { + if (!isFormValid || gender === null) return; + onSubmit({ + name: name.trim(), + gender, + level: level === "disabled" ? "급수없음" : level, + ageGroup, + }); + }; + + return ( +
+ +
+ setName(e.target.value.slice(0, 17))} + /> + +
+
+ 성별 + +
+
+ setGender(prev => (prev === "MALE" ? null : "MALE"))} + className="w-19" + > + 남성 + + + setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) + } + className="w-19" + > + 여성 + +
+
+ + setLevel(val ?? "")} + /> + +
+ 나이대 + ({ value }))} + value={ageGroup} + onChange={setAgeGroup} + /> +
+
+ +
+ +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx index bfe6017e..6d6e45ea 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -1,3 +1,11 @@ +import { + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type TouchEvent as ReactTouchEvent, +} from "react"; +import { createPortal } from "react-dom"; import clsx from "clsx"; import DefaultProfile from "@/assets/images/base_profile_img.png"; import Female from "@/assets/icons/female.svg"; @@ -13,26 +21,89 @@ const TAG_STYLE: Record = { 미참여: "bg-rd-500 text-white", }; +const LONG_PRESS_MS = 600; + interface GameMemberCardProps { member: GameMember; selected: boolean; onToggleSelect: () => void; + onEditInfo?: () => void; + onToggleParticipation?: () => void; } export const GameMemberCard = ({ member, selected, onToggleSelect, + onEditInfo, + onToggleParticipation, }: GameMemberCardProps) => { const { name, gender, ageGroup, group, playCount, tags, imgUrl, selectable } = member; const isWithdrawn = tags.includes("미참여"); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const cardRef = useRef(null); + const menuRef = useRef(null); + const longPressTimer = useRef | null>(null); + const suppressNextClick = useRef(false); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + if (cardRef.current?.contains(e.target as Node)) { + suppressNextClick.current = true; + } + setIsMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMenuOpen]); + + const openMenuAt = (x: number, y: number) => { + setMenuPosition({ top: y, left: x - 149 }); + setIsMenuOpen(true); + }; + + const clearLongPressTimer = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + + const handleContextMenu = (e: ReactMouseEvent) => { + e.preventDefault(); + openMenuAt(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: ReactTouchEvent) => { + const touch = e.touches[0]; + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => { + openMenuAt(touch.clientX, touch.clientY); + }, LONG_PRESS_MS); + }; + return ( -
- + + {isMenuOpen && + createPortal( +
e.stopPropagation()} + > + +
+ +
, + document.body, + )} +
); }; From 6c1e5d07ccc815290f8d5e0a38d33d8c93182c76 Mon Sep 17 00:00:00 2001 From: qowldud Date: Thu, 20 Aug 2026 00:18:42 +0900 Subject: [PATCH 18/52] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EA=B0=80=20=EB=B7=B0=ED=8F=AC=ED=8A=B8=20=EB=B0=96=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=98=EB=A6=AC=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/CourtCard.tsx | 44 ++++++++++++++++++- .../GameBoard/GameMemberCard.tsx | 13 +++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx index 45324b62..d626fa1d 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -1,5 +1,6 @@ import { useEffect, + useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent, @@ -86,6 +87,24 @@ interface WaitingCardProps { } const LONG_PRESS_MS = 600; +const MENU_WIDTH = 149; +const MENU_EDGE_MARGIN = 8; + +const clampMenuPosition = ( + x: number, + y: number, + width: number, + height: number, +) => ({ + left: Math.min( + Math.max(x - width, MENU_EDGE_MARGIN), + window.innerWidth - width - MENU_EDGE_MARGIN, + ), + top: Math.min( + Math.max(y, MENU_EDGE_MARGIN), + window.innerHeight - height - MENU_EDGE_MARGIN, + ), +}); export const WaitingCard = ({ label, @@ -97,6 +116,9 @@ export const WaitingCard = ({ }: WaitingCardProps) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const [clickPoint, setClickPoint] = useState<{ x: number; y: number } | null>( + null, + ); const cardRef = useRef(null); const menuRef = useRef(null); const longPressTimer = useRef | null>(null); @@ -116,8 +138,28 @@ export const WaitingCard = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [isMenuOpen]); + // 코트 개수에 따라 메뉴 높이가 달라지므로 렌더된 실제 크기를 측정해 위치를 보정한다. + // clickPoint는 openMenuAt마다 새 객체로 갱신되어, 메뉴가 이미 열려 있는 상태에서 + // 같은 카드를 다시 우클릭/롱프레스해도 이 effect가 다시 실행된다. + useLayoutEffect(() => { + if (!isMenuOpen || !clickPoint || !menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + const corrected = clampMenuPosition( + clickPoint.x, + clickPoint.y, + rect.width, + rect.height, + ); + setMenuPosition(prev => + prev.top === corrected.top && prev.left === corrected.left + ? prev + : corrected, + ); + }, [isMenuOpen, clickPoint, courts.length]); + const openMenuAt = (x: number, y: number) => { - setMenuPosition({ top: y, left: x - 149 }); + setClickPoint({ x, y }); + setMenuPosition(clampMenuPosition(x, y, MENU_WIDTH, 0)); setIsMenuOpen(true); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx index 6d6e45ea..3973885c 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -22,6 +22,9 @@ const TAG_STYLE: Record = { }; const LONG_PRESS_MS = 600; +const MENU_WIDTH = 149; +const MENU_HEIGHT = 84; +const MENU_EDGE_MARGIN = 8; interface GameMemberCardProps { member: GameMember; @@ -64,7 +67,15 @@ export const GameMemberCard = ({ }, [isMenuOpen]); const openMenuAt = (x: number, y: number) => { - setMenuPosition({ top: y, left: x - 149 }); + const left = Math.min( + Math.max(x - MENU_WIDTH, MENU_EDGE_MARGIN), + window.innerWidth - MENU_WIDTH - MENU_EDGE_MARGIN, + ); + const top = Math.min( + Math.max(y, MENU_EDGE_MARGIN), + window.innerHeight - MENU_HEIGHT - MENU_EDGE_MARGIN, + ); + setMenuPosition({ top, left }); setIsMenuOpen(true); }; From 4ade9f79d002078473745c4547bed7c8a31e6c74 Mon Sep 17 00:00:00 2001 From: qowldud Date: Thu, 20 Aug 2026 00:44:41 +0900 Subject: [PATCH 19/52] =?UTF-8?q?feat:=20=EB=8C=80=EA=B8=B0=EC=97=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=AA=A8=EB=8B=AC=20ui=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 18 +- .../GameBoard/GameDuplicateCheckModal.tsx | 241 ++++++++++++++++++ .../GameBoard/mockGameBoardData.ts | 48 ++++ 3 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index cd785114..9e0c3c30 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -23,6 +23,7 @@ import { GameBoardWebView } from "./GameBoardWebView"; import { CourtManageBottomSheet } from "./CourtManageBottomSheet"; import { GameFilterPage } from "./GameFilterPage"; import { GameEndModal } from "./GameEndModal"; +import { GameDuplicateCheckModal } from "./GameDuplicateCheckModal"; import { notReady } from "./gameBoardShared"; export const GameBoardTab = () => { @@ -35,6 +36,7 @@ export const GameBoardTab = () => { const [editingMemberId, setEditingMemberId] = useState(null); const [isWebViewOpen, setIsWebViewOpen] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false); + const [isDuplicateCheckOpen, setIsDuplicateCheckOpen] = useState(false); const [completingCourtId, setCompletingCourtId] = useState( null, ); @@ -317,7 +319,7 @@ export const GameBoardTab = () => { @@ -341,7 +343,7 @@ export const GameBoardTab = () => { onRemoveWaitingGroup={handleRemoveWaitingGroup} onMoveToCourt={handleMoveToCourt} onChangeWaitingGroup={handleChangeWaitingGroup} - onAddToWaitingQueue={handleAddToWaitingQueue} + onAddToWaitingQueue={() => setIsDuplicateCheckOpen(true)} members={members} selectedIds={selectedIds} toggleSelect={toggleSelect} @@ -387,6 +389,18 @@ export const GameBoardTab = () => { onConfirm={() => handleCompleteCourt(completingCourtId)} /> )} + + {isDuplicateCheckOpen && ( + setIsDuplicateCheckOpen(false)} + onConfirm={() => { + handleAddToWaitingQueue(); + setIsDuplicateCheckOpen(false); + }} + /> + )}
); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx new file mode 100644 index 00000000..dfbbd1be --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx @@ -0,0 +1,241 @@ +import DefaultProfile from "@/assets/images/base_profile_img.png"; +import GR400_L from "@/components/common/Btn_Static/Text/GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; +import { + getMatchupInfo, + getMatchupKey, + type GameMember, + type MatchupType, +} from "./mockGameBoardData"; + +const LEGEND_ITEMS: { type: MatchupType; label: string; dotClass: string; textClass: string }[] = [ + { type: "recent", label: "직전 경기", dotClass: "bg-rd-500", textClass: "text-rd-500" }, + { type: "first", label: "첫 경기", dotClass: "bg-gy-500", textClass: "text-gy-700" }, + { type: "previous", label: "이전 경기", dotClass: "bg-gr-700", textClass: "text-gr-700" }, +]; + +const MATCHUP_BADGE_STYLE: Record = { + recent: "border border-rd-500 shadow-[0_0_4px_rgba(246,45,45,0.16)]", + previous: "border border-gr-500 shadow-[0_0_4px_rgba(26,187,101,0.16)]", + first: "shadow-[0_0_8px_rgba(18,18,18,0.16)]", +}; + +interface MatchBadgeProps { + className?: string; + info: { count: number; type: MatchupType }; +} + +const MatchBadge = ({ className, info }: MatchBadgeProps) => ( +
+ {info.count} +
+); + +const Avatar = ({ member }: { member: GameMember }) => ( + {`${member.name} +); + +// 정확히 4명 선택 시, 4명이 이루는 다이아몬드 대진 관계(변 4개 + 대각선 2개 = 총 6쌍)를 그대로 시각화한다. +const DiamondMatchup = ({ members }: { members: GameMember[] }) => { + const [topLeft, bottomLeft, topRight, bottomRight] = members; + const matchups = getMatchupInfo(members.map(m => m.id)); + const infoFor = (a: GameMember, b: GameMember) => + matchups.get(getMatchupKey(a.id, b.id)) ?? { count: 0, type: "first" as const }; + + return ( +
+ + + + + + + + + +
+ {topLeft.name} + +
+
+ + {bottomLeft.name} +
+
+ {topRight.name} + +
+
+ + {bottomRight.name} +
+ + + + + + + +
+ ); +}; + +// 4명이 아닌 인원수(1~3명, 5명 이상)에서는 다이아몬드 도형이 성립하지 않으므로, +// 동일한 배지 색 규칙을 유지하는 쌍별 리스트로 대체한다. +const PairListMatchup = ({ members }: { members: GameMember[] }) => { + const matchups = getMatchupInfo(members.map(m => m.id)); + const pairs: [GameMember, GameMember][] = []; + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) { + pairs.push([members[i], members[j]]); + } + } + + if (pairs.length === 0) { + return ( +
+ 비교할 상대가 없어요. +
+ ); + } + + return ( +
+ {pairs.map(([a, b]) => { + const info = matchups.get(getMatchupKey(a.id, b.id)) ?? { + count: 0, + type: "first" as const, + }; + return ( +
+
+ + {a.name} +
+ +
+ {b.name} + +
+
+ ); + })} +
+ ); +}; + +interface GameDuplicateCheckModalProps { + variant?: "sheet" | "overlay"; + members: GameMember[]; + onClose: () => void; + onConfirm: () => void; +} + +export const GameDuplicateCheckModal = ({ + variant = "sheet", + members, + onClose, + onConfirm, +}: GameDuplicateCheckModalProps) => { + useLockBodyScroll(true); + const isOverlay = variant === "overlay"; + + const content = ( + <> + 게임 중복 체크 + +
+
+ 이 조합, 얼마나 겹쳤을까요? + + 선수들이 이전에 서로 경기한 횟수에요. + +
+
+ {LEGEND_ITEMS.map(item => ( +
+ + + {item.label} + +
+ ))} +
+
+ + {members.length === 4 ? ( + + ) : ( + + )} + + {isOverlay ? ( + + ) : ( + + )} + + ); + + if (isOverlay) { + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); + } + + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts index f468f485..2fccdd25 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -40,6 +40,54 @@ const player = ( color: "pink" | "blue", ): GamePlayer => ({ id, name, group, color }); +// 직전 경기: 가장 최근에 함께 뛴 조합 / 첫 경기: 한 번도 함께 뛴 적 없는 조합 / 이전 경기: 그 외 함께 뛴 적 있는 조합 +export type MatchupType = "recent" | "first" | "previous"; + +export interface MatchupInfo { + count: number; + type: MatchupType; +} + +export const getMatchupKey = (memberIdA: number, memberIdB: number) => + memberIdA < memberIdB + ? `${memberIdA}-${memberIdB}` + : `${memberIdB}-${memberIdA}`; + +// 실제 매치 이력 API가 아직 없어 두 회원 id로 결정적인 가짜 대결 횟수(0~3)를 만든다. +const mockMatchCount = (memberIdA: number, memberIdB: number) => { + const [lo, hi] = + memberIdA < memberIdB ? [memberIdA, memberIdB] : [memberIdB, memberIdA]; + return (lo * 31 + hi * 17) % 4; +}; + +export const getMatchupInfo = ( + memberIds: number[], +): Map => { + const counts = new Map(); + let mostRecentKey: string | null = null; + let maxCount = 0; + + for (let i = 0; i < memberIds.length; i++) { + for (let j = i + 1; j < memberIds.length; j++) { + const key = getMatchupKey(memberIds[i], memberIds[j]); + const count = mockMatchCount(memberIds[i], memberIds[j]); + counts.set(key, count); + if (count > maxCount) { + maxCount = count; + mostRecentKey = key; + } + } + } + + const result = new Map(); + counts.forEach((count, key) => { + const type: MatchupType = + count === 0 ? "first" : key === mostRecentKey ? "recent" : "previous"; + result.set(key, { count, type }); + }); + return result; +}; + export const mockCourts: CourtGroup[] = [ { id: 1, From 785c34d3da835269d709ddf22a511b1dc817a1f4 Mon Sep 17 00:00:00 2001 From: qowldud Date: Thu, 20 Aug 2026 17:08:45 +0900 Subject: [PATCH 20/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A7=A4=EC=B9=AD(=EC=B6=94=EC=B2=9C)=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 14 +- .../GameBoard/GameBoardWebView.tsx | 5 +- .../MyGroupDetail/GameBoard/gameAutoMatch.ts | 162 ++++++++++++++++++ 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 9e0c3c30..6e57bc64 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -24,7 +24,7 @@ import { CourtManageBottomSheet } from "./CourtManageBottomSheet"; import { GameFilterPage } from "./GameFilterPage"; import { GameEndModal } from "./GameEndModal"; import { GameDuplicateCheckModal } from "./GameDuplicateCheckModal"; -import { notReady } from "./gameBoardShared"; +import { autoMatchMembers } from "./gameAutoMatch"; export const GameBoardTab = () => { const [selectedIds, setSelectedIds] = useState([]); @@ -151,6 +151,15 @@ export const GameBoardTab = () => { setCompletingCourtId(null); }; + const handleAutoMatch = () => { + const matchedIds = autoMatchMembers(members, courts, waitingGroups); + if (!matchedIds) { + alert("자동 매칭할 인원이 부족해요. (대기 가능 인원 최소 4명 필요)"); + return; + } + setSelectedIds(matchedIds); + }; + const handleSaveCourts = (labels: string[]) => { setCourts(prev => labels.map((label, index) => { @@ -311,7 +320,7 @@ export const GameBoardTab = () => { @@ -344,6 +353,7 @@ export const GameBoardTab = () => { onMoveToCourt={handleMoveToCourt} onChangeWaitingGroup={handleChangeWaitingGroup} onAddToWaitingQueue={() => setIsDuplicateCheckOpen(true)} + onAutoMatch={handleAutoMatch} members={members} selectedIds={selectedIds} toggleSelect={toggleSelect} diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx index 1d288e7a..ffd336c6 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -13,7 +13,6 @@ import { type WaitingGroup, } from "./mockGameBoardData"; import { - notReady, FILTER_LEVEL_OPTIONS, FILTER_GENDER_OPTIONS, FILTER_SHUTTLE_OPTIONS, @@ -28,6 +27,7 @@ interface GameBoardWebViewProps { onMoveToCourt: (waitingGroupId: number, courtId: number) => void; onChangeWaitingGroup: (group: WaitingGroup) => void; onAddToWaitingQueue: () => void; + onAutoMatch: () => void; members: GameMember[]; selectedIds: number[]; toggleSelect: (id: number) => void; @@ -46,6 +46,7 @@ export const GameBoardWebView = ({ onMoveToCourt, onChangeWaitingGroup, onAddToWaitingQueue, + onAutoMatch, members, selectedIds, toggleSelect, @@ -177,7 +178,7 @@ export const GameBoardWebView = ({ diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts new file mode 100644 index 00000000..d8eafcb0 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts @@ -0,0 +1,162 @@ +import { + getMatchupInfo, + getMatchupKey, + type CourtGroup, + type GameMember, + type WaitingGroup, +} from "./mockGameBoardData"; + +// 급수별 기본 능력치 (남자 기준). 여자는 동일 급수에서 -1 적용. +const GRADE_SCORE: Record = { + 자강: 10, + 준자강: 9, + A조: 8, + B조: 7, + C조: 6, + D조: 5, + 초심: 4, + 왕초심: 3, +}; + +// 남자에 한해서만 적용되는 나이대 보정치 (여자는 나이 반영 안 함) +const AGE_BONUS_MALE: Record = { + "20대": 0.6, + "30대": 0.4, + "40대": 0.2, + "50대": 0, +}; + +const getSkillScore = (member: GameMember) => { + const base = GRADE_SCORE[member.group] ?? 5; + if (member.gender === "FEMALE") return base - 1; + return base + (AGE_BONUS_MALE[member.ageGroup] ?? 0.2); +}; + +const combinations = (arr: T[], k: number): T[][] => { + const results: T[][] = []; + const helper = (start: number, combo: T[]) => { + if (combo.length === k) { + results.push([...combo]); + return; + } + for (let i = start; i < arr.length; i++) { + combo.push(arr[i]); + helper(i + 1, combo); + combo.pop(); + } + }; + helper(0, []); + return results; +}; + +// 4명을 2:2로 나눌 수 있는 3가지 조합 중, 팀간 능력치 합 차이가 가장 적은 값을 반환 +const bestSplitSkillDiff = (four: GameMember[]) => { + const [a, b, c, d] = four; + const splits: [GameMember, GameMember][][] = [ + [ + [a, b], + [c, d], + ], + [ + [a, c], + [b, d], + ], + [ + [a, d], + [b, c], + ], + ]; + let minDiff = Infinity; + splits.forEach(([t1, t2]) => { + const s1 = getSkillScore(t1[0]) + getSkillScore(t1[1]); + const s2 = getSkillScore(t2[0]) + getSkillScore(t2[1]); + minDiff = Math.min(minDiff, Math.abs(s1 - s2)); + }); + return minDiff; +}; + +// 4명 사이 6가지 조합 전체에 대해 과거 함께 경기한 누적 횟수 합산 (중복 페널티) +const repeatPenaltyForQuartet = (four: GameMember[]) => { + const matchupInfo = getMatchupInfo(four.map(m => m.id)); + let penalty = 0; + for (let i = 0; i < 4; i++) { + for (let j = i + 1; j < 4; j++) { + const key = getMatchupKey(four[i].id, four[j].id); + penalty += matchupInfo.get(key)?.count ?? 0; + } + } + return penalty; +}; + +// 코트 타이머("MM:SS" 경과 표시)를 분 단위로 환산 +const parseElapsedMinutes = (timer?: string) => { + if (!timer) return 0; + const [min, sec] = timer.split(":").map(Number); + if (Number.isNaN(min) || Number.isNaN(sec)) return 0; + return min + sec / 60; +}; + +// 대기/명단 인원 중 4명을 자동으로 뽑아 회원 id 배열을 반환. 매칭 가능한 인원이 없으면 null. +export const autoMatchMembers = ( + members: GameMember[], + courts: CourtGroup[], + waitingGroups: WaitingGroup[], +): number[] | null => { + const queuedIds = new Set(waitingGroups.flatMap(g => g.memberIds)); + const playingCourtOf = new Map(); + courts.forEach(court => { + court.players?.forEach(p => playingCourtOf.set(p.id, court)); + }); + + const availableMembers = members.filter(m => { + if (!m.selectable) return false; + if (queuedIds.has(m.id)) return false; + const court = playingCourtOf.get(m.id); + if (court) return parseElapsedMinutes(court.timer) >= 10; // 운동중이어도 10분 넘었으면 다음 매치 후보로 포함 + return true; // 코트/대기열 어디에도 없으면 포함 + }); + + if (availableMembers.length < 4) return null; + + // 0단계: 혼복/남복/여복 중 어떤 타입으로 매치를 만들지 랜덤 결정 (인원이 부족한 타입은 후보에서 제외) + const menPool = availableMembers.filter(m => m.gender === "MALE"); + const womenPool = availableMembers.filter(m => m.gender === "FEMALE"); + const typeOptions: ("혼복" | "남복" | "여복")[] = ["혼복"]; + if (menPool.length >= 4) typeOptions.push("남복"); + if (womenPool.length >= 4) typeOptions.push("여복"); + const matchType = typeOptions[Math.floor(Math.random() * typeOptions.length)]; + const genderPool = + matchType === "남복" + ? menPool + : matchType === "여복" + ? womenPool + : availableMembers; + + const sortedPool = [...genderPool].sort((a, b) => a.playCount - b.playCount); + const minGames = sortedPool[0].playCount; + + // 1단계: 운동 횟수가 적은 사람 위주로 후보군 구성 (4명이 안 모이면 다음 순번까지 단계적으로 확장) + let tier = 0; + let candidatePool: GameMember[] = []; + while (candidatePool.length < 4 && tier <= 5) { + candidatePool = sortedPool.filter(m => m.playCount <= minGames + tier); + tier++; + } + candidatePool = candidatePool.slice(0, 12); // 성능을 위해 최대 12명까지만 후보로 사용 + + // 2단계: 후보군 내 모든 4인 조합에 대해 급수 밸런스 + 중복 이력 + 게임수 공정성 점수 계산 + let best: GameMember[] | null = null; + let bestScore = Infinity; + combinations(candidatePool, 4).forEach(four => { + const skillDiff = bestSplitSkillDiff(four); + const repeatPenalty = repeatPenaltyForQuartet(four); + const gamesSum = four.reduce((sum, m) => sum + m.playCount, 0); + const score = skillDiff * 100 + repeatPenalty * 15 + gamesSum; + if (score < bestScore) { + bestScore = score; + best = four; + } + }); + + return best ? (best as GameMember[]).map(m => m.id) : null; +}; From ae4111dca0c6d99a8f8b54cc8c013eafb3104e9b Mon Sep 17 00:00:00 2001 From: qowldud Date: Fri, 21 Aug 2026 01:31:44 +0900 Subject: [PATCH 21/52] =?UTF-8?q?feat:=20=EB=AA=85=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20pc=20=EB=B2=84=EC=A0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameBoard/GameAddPlayerModal.tsx | 123 +++++++++++------- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 2 + .../GameBoard/GameEditPlayerModal.tsx | 123 +++++++++++------- 3 files changed, 156 insertions(+), 92 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx index 84c5d65c..a6cbea92 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx @@ -5,8 +5,10 @@ import TextBox from "@/components/common/Text_Box/TextBox"; import DropCheckBox from "@/components/common/Drop_Box/DropCheckBox"; import { DropBox } from "@/components/common/DropBox"; import Grad_GR400_L from "@/components/common/Btn_Static/Text/Grad_GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; import Circle_RedIcon from "@/assets/icons/cicle_s_red.svg?url"; import { LEVEL_KEY } from "@/constants/options"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; const AGE_OPTIONS = ["10대", "20대", "30대", "40대", "50대", "60대 이상"]; @@ -18,14 +20,17 @@ export interface NewGamePlayer { } interface GameAddPlayerModalProps { + variant?: "sheet" | "overlay"; onClose: () => void; onSubmit: (player: NewGamePlayer) => void; } export const GameAddPlayerModal = ({ + variant = "sheet", onClose, onSubmit, }: GameAddPlayerModalProps) => { + useLockBodyScroll(true); const [name, setName] = useState(""); const [gender, setGender] = useState<"MALE" | "FEMALE" | null>(null); const [level, setLevel] = useState(""); @@ -45,60 +50,86 @@ export const GameAddPlayerModal = ({ }); }; - return ( -
- -
- setName(e.target.value.slice(0, 17))} - /> + const fields = ( + <> + setName(e.target.value.slice(0, 17))} + /> -
-
- 성별 - -
-
- setGender(prev => (prev === "MALE" ? null : "MALE"))} - className="w-19" - > - 남성 - - - setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) - } - className="w-19" - > - 여성 - -
+
+
+ 성별 +
+
+ setGender(prev => (prev === "MALE" ? null : "MALE"))} + className="w-19" + > + 남성 + + + setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) + } + className="w-19" + > + 여성 + +
+
+ + setLevel(val ?? "")} + /> - setLevel(val ?? "")} +
+ 나이대 + ({ value }))} + value={ageGroup} + onChange={setAgeGroup} /> +
+ + ); -
- 나이대 - ({ value }))} - value={ageGroup} - onChange={setAgeGroup} + if (variant === "overlay") { + return ( +
+
e.stopPropagation()} + > + 명단 추가 +
{fields}
+
+ ); + } + + return ( +
+ +
{fields}
{ {isAddPlayerOpen && ( setIsAddPlayerOpen(false)} onSubmit={handleAddPlayer} /> @@ -374,6 +375,7 @@ export const GameBoardTab = () => { {editingMember && ( setEditingMemberId(null)} onSubmit={handleSaveMemberEdit} diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx index 6cd92ff8..55c9edf7 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx @@ -5,8 +5,10 @@ import TextBox from "@/components/common/Text_Box/TextBox"; import DropCheckBox from "@/components/common/Drop_Box/DropCheckBox"; import { DropBox } from "@/components/common/DropBox"; import Grad_GR400_L from "@/components/common/Btn_Static/Text/Grad_GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; import Circle_RedIcon from "@/assets/icons/cicle_s_red.svg?url"; import { LEVEL_KEY } from "@/constants/options"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; import type { GameMember } from "./mockGameBoardData"; const AGE_OPTIONS = ["10대", "20대", "30대", "40대", "50대", "60대 이상"]; @@ -19,16 +21,19 @@ export interface EditedGamePlayer { } interface GameEditPlayerModalProps { + variant?: "sheet" | "overlay"; member: GameMember; onClose: () => void; onSubmit: (player: EditedGamePlayer) => void; } export const GameEditPlayerModal = ({ + variant = "sheet", member, onClose, onSubmit, }: GameEditPlayerModalProps) => { + useLockBodyScroll(true); const [name, setName] = useState(member.name); const [gender, setGender] = useState<"MALE" | "FEMALE" | null>( member.gender, @@ -52,60 +57,86 @@ export const GameEditPlayerModal = ({ }); }; - return ( -
- -
- setName(e.target.value.slice(0, 17))} - /> + const fields = ( + <> + setName(e.target.value.slice(0, 17))} + /> -
-
- 성별 - -
-
- setGender(prev => (prev === "MALE" ? null : "MALE"))} - className="w-19" - > - 남성 - - - setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) - } - className="w-19" - > - 여성 - -
+
+
+ 성별 +
+
+ setGender(prev => (prev === "MALE" ? null : "MALE"))} + className="w-19" + > + 남성 + + + setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) + } + className="w-19" + > + 여성 + +
+
+ + setLevel(val ?? "")} + /> - setLevel(val ?? "")} +
+ 나이대 + ({ value }))} + value={ageGroup} + onChange={setAgeGroup} /> +
+ + ); -
- 나이대 - ({ value }))} - value={ageGroup} - onChange={setAgeGroup} + if (variant === "overlay") { + return ( +
+
e.stopPropagation()} + > + 정보 수정 +
{fields}
+
+ ); + } + + return ( +
+ +
{fields}
Date: Sat, 22 Aug 2026 02:19:59 +0900 Subject: [PATCH 22/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=9B=B9=EC=86=8C=EC=BC=93(GAME=20=EB=8F=84=EB=A9=94=EC=9D=B8)?= =?UTF-8?q?=20envelope=20=EC=97=B0=EB=8F=99=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/rawWs.ts | 331 +++++++++++++++++++++++++++++++++++++++++ src/hooks/useGameWs.ts | 91 +++++++++++ 2 files changed, 422 insertions(+) create mode 100644 src/api/game/rawWs.ts create mode 100644 src/hooks/useGameWs.ts diff --git a/src/api/game/rawWs.ts b/src/api/game/rawWs.ts new file mode 100644 index 00000000..8bd016ac --- /dev/null +++ b/src/api/game/rawWs.ts @@ -0,0 +1,331 @@ +// api/game/rawWs.ts +// GAME 도메인 실시간 소켓 (Raw WebSocket, SockJS/STOMP 미사용). +// 엔드포인트: GET /ws/realtime?token={accessToken} +// 요청/응답이 requestId로 매칭되는 envelope 구조라, 액션 전송은 Promise 기반 request()로 처리한다. + +import useUserStore from "../../store/useUserStore"; + +let ws: WebSocket | null = null; + +export type WsStatus = "idle" | "connecting" | "open" | "closed" | "error"; + +export const GAME_PROTOCOL_VERSION = 1; +export const GAME_DOMAIN = "GAME" as const; + +// ---------- 요청 envelope ---------- +export type GameAction = + | "SUBSCRIBE" + | "UNSUBSCRIBE" + | "CREATE_GAME" + | "START_GAME" + | "COMPLETE_GAME" + | "DELETE_GAME" + | "MOVE_COURT" + | "MOVE_TO_WAITING"; + +export type GameRequestEnvelope> = { + version: number; + domain: typeof GAME_DOMAIN; + action: GameAction; + requestId: string; + payload: TPayload; +}; + +// ---------- 응답 envelope ---------- +export type GameResponseType = + | "SUBSCRIBED" + | "UNSUBSCRIBED" + | "BOARD_UPDATED" + | "MEMBERS_UPDATED" + | "GAME_CREATED" + | "GAME_DELETED" + | "ERROR"; + +export type GameErrorPayload = { code: string; message: string }; + +// GameBoardDTO.Response (게임 코트 보드 조회 Response와 동일한 스냅샷). +// 아직 해당 명세를 받지 못해 임시로 unknown 처리 — 필드 확정되면 교체. +export type GameBoardSnapshot = unknown; + +export type GameResponseEnvelope = { + version: number; + domain: typeof GAME_DOMAIN; + type: GameResponseType; + requestId?: string; + data?: TData; + // 명세상 ERROR의 데이터는 "error: { code, message }"로 표기되어 있어 + // 서버가 data 대신 error 필드로 내려줄 가능성을 함께 대비한다. + error?: GameErrorPayload; + timestamp: string; +}; + +//전역 리스너(Event Bus) — SUBSCRIBED 이후의 브로드캐스트(BOARD_UPDATED/MEMBERS_UPDATED 등) 수신용 +type MsgListener = (msg: GameResponseEnvelope) => void; +const listeners = new Set(); + +export const addGameWsListener = (fn: MsgListener) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +}; + +type OpenListener = () => void; +type CloseListener = (ev?: CloseEvent) => void; +const openListeners = new Set(); +const closeListeners = new Set(); + +export const addGameWsOpenListener = (fn: OpenListener) => { + openListeners.add(fn); + return () => { + openListeners.delete(fn); + }; +}; + +export const addGameWsCloseListener = (fn: CloseListener) => { + closeListeners.add(fn); + return () => { + closeListeners.delete(fn); + }; +}; + +// requestId → pending Promise 매칭 +type PendingEntry = { + resolve: (msg: GameResponseEnvelope) => void; + reject: (err: Error) => void; + timer: number; +}; +const pending = new Map(); +const REQUEST_TIMEOUT_MS = 10000; + +// 재연결 백오프 +let reconnectTimer: number | null = null; +let reconnectAttempt = 0; +let isManualClose = false; + +let connectPromise: Promise | null = null; + +const WS_ORIGIN = ( + import.meta.env.VITE_WS_ORIGIN ?? window.location.origin +).replace(/\/$/, ""); +const WS_REALTIME_PATH = ( + import.meta.env.VITE_WS_REALTIME_PATH ?? "/ws/realtime" +).replace(/\/$/, ""); + +const toWsUrl = (origin: string) => + origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:"); + +const buildWsUrl = (origin?: string) => toWsUrl(origin ?? WS_ORIGIN) + WS_REALTIME_PATH; + +const getToken = () => { + const { user } = useUserStore.getState(); + return user?.accessToken ?? localStorage.getItem("accessToken") ?? ""; +}; +const hasToken = () => !!getToken(); + +const genRequestId = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `req-${Date.now()}-${Math.random().toString(36).slice(2)}`; + +const rejectAllPending = (reason: string) => { + pending.forEach(({ reject, timer }) => { + window.clearTimeout(timer); + reject(new Error(reason)); + }); + pending.clear(); +}; + +// --------- 공개 API ---------- +export const connectGameWs = async ({ + origin, +}: { origin?: string } = {}) => { + if (!hasToken()) { + console.info("[GAME WS] skipped: no accessToken"); + return null; + } + + if ( + ws && + (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) + ) { + return ws; + } + + if (connectPromise) return connectPromise; + + connectPromise = (async () => { + const url = new URL(buildWsUrl(origin)); + url.searchParams.set("token", getToken()); + + const sock = new WebSocket(url.toString()); + ws = sock; + + sock.onopen = () => { + reconnectAttempt = 0; + isManualClose = false; + openListeners.forEach(fn => { + try { + fn(); + } catch (err) { + console.warn("game ws open listener err", err); + } + }); + }; + + sock.onmessage = (e: MessageEvent) => { + try { + const parsed: GameResponseEnvelope = JSON.parse(e.data); + console.log("[GAME WS←]", parsed.type, parsed); + + if (parsed.requestId && pending.has(parsed.requestId)) { + const entry = pending.get(parsed.requestId)!; + window.clearTimeout(entry.timer); + pending.delete(parsed.requestId); + if (parsed.type === "ERROR") { + const err = parsed.error ?? (parsed.data as GameErrorPayload | undefined); + entry.reject( + new Error(err?.message ?? "GAME_WS_ERROR") + ); + } else { + entry.resolve(parsed); + } + } + + // 요청자 응답 여부와 무관하게 구독자 브로드캐스트로도 전달 + listeners.forEach(fn => { + try { + fn(parsed); + } catch (err) { + console.warn("game ws listener err", err); + } + }); + } catch { + console.warn("[GAME WS] Non-JSON message:", e.data); + } + }; + + sock.onerror = (ev: Event) => { + console.warn("[GAME WS error]", ev); + }; + + sock.onclose = (ev: CloseEvent) => { + console.warn("[GAME WS close]", ev.code, ev.reason); + rejectAllPending("GAME_WS_CLOSED"); + closeListeners.forEach(fn => { + try { + fn(ev); + } catch (err) { + console.warn("game ws close listener err", err); + } + }); + ws = null; + connectPromise = null; + + if (isManualClose) { + console.log("[GAME WS] Manual disconnect. No reconnect."); + return; + } + + if (!hasToken()) return; + + if (!reconnectTimer) { + const delay = Math.min(500 * 2 ** reconnectAttempt, 8000); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + reconnectAttempt++; + connectGameWs({ origin }); + }, delay); + } + }; + + return ws!; + })(); + + const result = await connectPromise; + connectPromise = null; + return result; +}; + +export const disconnectGameWs = () => { + if ( + ws && + (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) + ) { + isManualClose = true; + ws.close(); + } + ws = null; + rejectAllPending("GAME_WS_DISCONNECTED"); +}; + +export const gameWsState = () => ws?.readyState; // 0/1/2/3 +export const isGameWsOpen = () => ws?.readyState === WebSocket.OPEN; + +// requestId 기반 요청 → 매칭되는 응답(or ERROR)을 Promise로 반환 +export const gameWsRequest = >( + action: GameAction, + payload: TPayload, +): Promise> => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("GAME_WS_NOT_OPEN")); + } + + const requestId = genRequestId(); + const envelope: GameRequestEnvelope = { + version: GAME_PROTOCOL_VERSION, + domain: GAME_DOMAIN, + action, + requestId, + payload, + }; + + return new Promise>((resolve, reject) => { + const timer = window.setTimeout(() => { + pending.delete(requestId); + reject(new Error("GAME_WS_TIMEOUT")); + }, REQUEST_TIMEOUT_MS); + + pending.set(requestId, { + resolve: resolve as (msg: GameResponseEnvelope) => void, + reject, + timer, + }); + + console.log("[GAME WS→]", action, envelope); + ws!.send(JSON.stringify(envelope)); + }); +}; + +// ---------- 액션별 헬퍼 ---------- +// 각 액션의 payload/data 필드는 액션별 API 명세가 확정되면 구체 타입으로 교체한다. +export const subscribeGameBoard = (gameBoardId: number) => + gameWsRequest<{ gameBoardId: number }>("SUBSCRIBE", { gameBoardId }); + +export const unsubscribeGameBoard = (gameBoardId: number) => + gameWsRequest<{ gameBoardId: number }>("UNSUBSCRIBE", { gameBoardId }); + +export const createGameWS = (payload: Record) => + gameWsRequest("CREATE_GAME", payload); + +export const startGameWS = (payload: Record) => + gameWsRequest("START_GAME", payload); + +export const completeGameWS = (payload: Record) => + gameWsRequest("COMPLETE_GAME", payload); + +export const deleteGameWS = (payload: Record) => + gameWsRequest("DELETE_GAME", payload); + +// 이미 코트에 배정된 경기를 다른 코트로 옮길 때 사용 (피그마상 불필요해 보이나 추후 대비) +export type MoveCourtPayload = { + gameBoardId: number; + courtId: number; + targetCourtNo: number; +}; + +export const moveCourtWS = (payload: MoveCourtPayload) => + gameWsRequest("MOVE_COURT", payload); + +export const moveToWaitingWS = (payload: Record) => + gameWsRequest("MOVE_TO_WAITING", payload); diff --git a/src/hooks/useGameWs.ts b/src/hooks/useGameWs.ts new file mode 100644 index 00000000..bccbf21b --- /dev/null +++ b/src/hooks/useGameWs.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { + connectGameWs, + disconnectGameWs, + subscribeGameBoard, + unsubscribeGameBoard, + createGameWS, + startGameWS, + completeGameWS, + deleteGameWS, + moveCourtWS, + moveToWaitingWS, + addGameWsListener, + addGameWsOpenListener, + addGameWsCloseListener, + isGameWsOpen, + type GameResponseEnvelope, +} from "../api/game/rawWs"; +import useUserStore from "../store/useUserStore"; + +// 게임판 화면 진입 시 연결하고, 이탈 시 해당 보드를 구독 해제한다. +// (소켓 연결 자체는 다른 게임판 화면과 공유될 수 있어 연결 종료는 하지 않음) +export const useGameWs = (opts: { gameBoardId?: number; origin?: string } = {}) => { + const [isOpen, setIsOpen] = useState(false); + const [lastMessage, setLastMessage] = useState( + null, + ); + const mounted = useRef(false); + + const token = + useUserStore(s => s.user?.accessToken) ?? + localStorage.getItem("accessToken") ?? + ""; + + useEffect(() => { + mounted.current = true; + + if (!token) { + setIsOpen(false); + disconnectGameWs(); + return () => { + mounted.current = false; + }; + } + + connectGameWs({ origin: opts.origin }); + + if (isGameWsOpen()) setIsOpen(true); + const offOpen = addGameWsOpenListener(() => mounted.current && setIsOpen(true)); + const offClose = addGameWsCloseListener( + () => mounted.current && setIsOpen(false), + ); + const offMsg = addGameWsListener(msg => { + if (!mounted.current) return; + setLastMessage(msg); + }); + + return () => { + mounted.current = false; + offOpen(); + offClose(); + offMsg(); + }; + }, [token, opts.origin]); + + // 보드 구독/해제: 소켓 open 이후에 시도, 언마운트 시 자동 해제 + useEffect(() => { + if (!isOpen || !opts.gameBoardId) return; + + subscribeGameBoard(opts.gameBoardId).catch(err => + console.warn("[GAME WS] subscribe failed", err), + ); + + return () => { + unsubscribeGameBoard(opts.gameBoardId!).catch(err => + console.warn("[GAME WS] unsubscribe failed", err), + ); + }; + }, [isOpen, opts.gameBoardId]); + + return { + isOpen, + lastMessage, + createGame: createGameWS, + startGame: startGameWS, + completeGame: completeGameWS, + deleteGame: deleteGameWS, + moveCourt: moveCourtWS, + moveToWaiting: moveToWaitingWS, + }; +}; From 25b0d79688c9ac58a1ccc165d78841de160e6790 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 22 Aug 2026 02:31:38 +0900 Subject: [PATCH 23/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=9B=B9=EC=86=8C=EC=BC=93=20=EC=95=A1=EC=85=98=EB=B3=84=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD/=EC=9D=91=EB=8B=B5=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/rawWs.ts | 66 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/api/game/rawWs.ts b/src/api/game/rawWs.ts index 8bd016ac..c1bbf2db 100644 --- a/src/api/game/rawWs.ts +++ b/src/api/game/rawWs.ts @@ -305,17 +305,57 @@ export const subscribeGameBoard = (gameBoardId: number) => export const unsubscribeGameBoard = (gameBoardId: number) => gameWsRequest<{ gameBoardId: number }>("UNSUBSCRIBE", { gameBoardId }); -export const createGameWS = (payload: Record) => - gameWsRequest("CREATE_GAME", payload); +// 대기열에 게임 생성 +export type CreateGamePayload = { + gameBoardId: number; + gameBoardMemberIds: number[]; // 최대 4명, 배열 순서 = playerOrder +}; + +export type CreateGameData = { + gameId: number; + board: GameBoardSnapshot; +}; -export const startGameWS = (payload: Record) => - gameWsRequest("START_GAME", payload); +export const createGameWS = (payload: CreateGamePayload) => + gameWsRequest("CREATE_GAME", payload); -export const completeGameWS = (payload: Record) => - gameWsRequest("COMPLETE_GAME", payload); +// 대기열에 있는 팀을 게임 코트로 옮길 때 사용 +export type StartGamePayload = { + gameBoardId: number; + gameId: number; + courtId: number; +}; + +export const startGameWS = (payload: StartGamePayload) => + gameWsRequest("START_GAME", payload); + +// 게임 완료 시 코트를 비우고 팀 멤버들의 게임횟수를 증가 +export type CompleteGamePayload = { + gameBoardId: number; + gameId: number; +}; -export const deleteGameWS = (payload: Record) => - gameWsRequest("DELETE_GAME", payload); +export const completeGameWS = (payload: CompleteGamePayload) => + gameWsRequest("COMPLETE_GAME", payload); + +// PlayerInfo 필드 명세 미확정 — placeholder +export type PlayerInfo = unknown; + +// 게임 취소 / 대기열 삭제 시 사용 +export type DeleteGamePayload = { + gameBoardId: number; + gameId: number; + restore?: boolean; // true면 삭제 게임 플레이어를 복원용으로 반환 (기본 false) +}; + +export type DeleteGameData = { + gameId: number; + players: PlayerInfo[]; // restore=false면 빈 배열 + board: GameBoardSnapshot; +}; + +export const deleteGameWS = (payload: DeleteGamePayload) => + gameWsRequest("DELETE_GAME", payload); // 이미 코트에 배정된 경기를 다른 코트로 옮길 때 사용 (피그마상 불필요해 보이나 추후 대비) export type MoveCourtPayload = { @@ -327,5 +367,11 @@ export type MoveCourtPayload = { export const moveCourtWS = (payload: MoveCourtPayload) => gameWsRequest("MOVE_COURT", payload); -export const moveToWaitingWS = (payload: Record) => - gameWsRequest("MOVE_TO_WAITING", payload); +// 진행중인 게임을 대기열 맨 앞으로 이동. 코트는 비워짐 +export type MoveToWaitingPayload = { + gameBoardId: number; + gameId: number; +}; + +export const moveToWaitingWS = (payload: MoveToWaitingPayload) => + gameWsRequest("MOVE_TO_WAITING", payload); From 7c846fa78bdc2887e66796689f3839ce0534dd06 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 22 Aug 2026 02:40:56 +0900 Subject: [PATCH 24/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=BD=94?= =?UTF-8?q?=ED=8A=B8=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/board.ts | 59 ++++++++++++++++++++++++++++++++++++++++++ src/api/game/courts.ts | 46 ++++++++++++++++++++++++++++++++ src/api/game/rawWs.ts | 10 +++---- 3 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 src/api/game/board.ts create mode 100644 src/api/game/courts.ts diff --git a/src/api/game/board.ts b/src/api/game/board.ts new file mode 100644 index 00000000..ea127341 --- /dev/null +++ b/src/api/game/board.ts @@ -0,0 +1,59 @@ +// 게임 코트 보드 조회 (REST, GET /api/game-boards/{gameBoardId}) +// 명단을 제외한 게임코트/대기열 스냅샷. 웹소켓 각 액션 응답의 board/data와 동일한 shape(GameBoardDTO.Response). +import api from "../api"; + +export type CourtStatus = "EMPTY" | "PLAYING"; + +export interface GameBoardPlayer { + gameBoardMemberId: number; + name: string; + level: string; + playerOrder: number; +} + +export interface GameBoardCourtGame { + gameId: number; + startedAt: string; // 경과 시간은 클라가 now - startedAt으로 계산 + players: GameBoardPlayer[]; +} + +export interface GameBoardCourt { + courtId: number; + courtNo: number; + courtName: string; + status: CourtStatus; + game: GameBoardCourtGame | null; // EMPTY면 null +} + +export interface GameBoardWaiting { + gameId: number; // status=WAITING + waitingOrder: number; // 화면 대기 N번. 정렬 인덱스(저장값 아님) + players: GameBoardPlayer[]; +} + +export interface GameBoardResponse { + courtCount: number; // 0이면 코트 생성 화면 분기 + courts: GameBoardCourt[]; + waitings: GameBoardWaiting[]; +} + +interface ApiEnvelope { + code: string; + message: string; + data: T; + errorReason?: { + code: string; + message: string; + httpStatus: string; + }; + success: boolean; +} + +export const getGameBoard = async ( + gameBoardId: number, +): Promise => { + const response = await api.get>( + `/api/game-boards/${gameBoardId}`, + ); + return response.data.data; +}; diff --git a/src/api/game/courts.ts b/src/api/game/courts.ts new file mode 100644 index 00000000..cd779f2f --- /dev/null +++ b/src/api/game/courts.ts @@ -0,0 +1,46 @@ +// 게임판 코트 관리 (REST, PUT /api/game-boards/{gameBoardId}/courts) +import api from "../api"; + +export interface UpdateCourtItem { + courtId?: number; // 있으면 유지(이름 변경 가능), 없으면 신규 생성. 기존 id가 요청에서 빠지면 삭제 + courtName?: string; // 생략 시 courtNo 기반 기본값(01 코트 등) +} + +export interface UpdateCourtsRequest { + courts: UpdateCourtItem[]; +} + +export interface CourtItem { + courtId: number; + courtNo: number; + courtName: string; +} + +export interface UpdateCourtsResponseData { + gameBoardId: number; + courts: CourtItem[]; +} + +export interface UpdateCourtsResponse { + code: string; + message: string; + data: UpdateCourtsResponseData; + errorReason?: { + code: string; + message: string; + httpStatus: string; + }; + success: boolean; +} + +// 400: 입력값 오류 / 404: 게임판 또는 코트를 찾을 수 없음 +export const updateCourts = async ( + gameBoardId: number, + payload: UpdateCourtsRequest, +): Promise => { + const response = await api.put( + `/api/game-boards/${gameBoardId}/courts`, + payload, + ); + return response.data.data; +}; diff --git a/src/api/game/rawWs.ts b/src/api/game/rawWs.ts index c1bbf2db..8a1dddb5 100644 --- a/src/api/game/rawWs.ts +++ b/src/api/game/rawWs.ts @@ -4,6 +4,7 @@ // 요청/응답이 requestId로 매칭되는 envelope 구조라, 액션 전송은 Promise 기반 request()로 처리한다. import useUserStore from "../../store/useUserStore"; +import type { GameBoardPlayer, GameBoardResponse } from "./board"; let ws: WebSocket | null = null; @@ -43,9 +44,8 @@ export type GameResponseType = export type GameErrorPayload = { code: string; message: string }; -// GameBoardDTO.Response (게임 코트 보드 조회 Response와 동일한 스냅샷). -// 아직 해당 명세를 받지 못해 임시로 unknown 처리 — 필드 확정되면 교체. -export type GameBoardSnapshot = unknown; +// GameBoardDTO.Response (게임 코트 보드 조회 Response와 동일한 스냅샷) +export type GameBoardSnapshot = GameBoardResponse; export type GameResponseEnvelope = { version: number; @@ -338,8 +338,8 @@ export type CompleteGamePayload = { export const completeGameWS = (payload: CompleteGamePayload) => gameWsRequest("COMPLETE_GAME", payload); -// PlayerInfo 필드 명세 미확정 — placeholder -export type PlayerInfo = unknown; +// 복원용 플레이어 정보 — 보드 조회의 PlayerDto(GameBoardPlayer)와 동일하다고 가정. 다르면 교체 필요 +export type PlayerInfo = GameBoardPlayer; // 게임 취소 / 대기열 삭제 시 사용 export type DeleteGamePayload = { From 6f36b9be20756dda6fa39ae2d300128386b54e71 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 22 Aug 2026 02:43:05 +0900 Subject: [PATCH 25/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B2=B4=ED=81=AC=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/board.ts | 15 ++------------- src/api/game/courts.ts | 15 ++------------- src/api/game/duplicateCheck.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 26 deletions(-) create mode 100644 src/api/game/duplicateCheck.ts diff --git a/src/api/game/board.ts b/src/api/game/board.ts index ea127341..e0c23427 100644 --- a/src/api/game/board.ts +++ b/src/api/game/board.ts @@ -1,6 +1,7 @@ // 게임 코트 보드 조회 (REST, GET /api/game-boards/{gameBoardId}) // 명단을 제외한 게임코트/대기열 스냅샷. 웹소켓 각 액션 응답의 board/data와 동일한 shape(GameBoardDTO.Response). import api from "../api"; +import type { CommonResponse } from "../../types/common"; export type CourtStatus = "EMPTY" | "PLAYING"; @@ -37,22 +38,10 @@ export interface GameBoardResponse { waitings: GameBoardWaiting[]; } -interface ApiEnvelope { - code: string; - message: string; - data: T; - errorReason?: { - code: string; - message: string; - httpStatus: string; - }; - success: boolean; -} - export const getGameBoard = async ( gameBoardId: number, ): Promise => { - const response = await api.get>( + const response = await api.get>( `/api/game-boards/${gameBoardId}`, ); return response.data.data; diff --git a/src/api/game/courts.ts b/src/api/game/courts.ts index cd779f2f..8eb08c3a 100644 --- a/src/api/game/courts.ts +++ b/src/api/game/courts.ts @@ -1,5 +1,6 @@ // 게임판 코트 관리 (REST, PUT /api/game-boards/{gameBoardId}/courts) import api from "../api"; +import type { CommonResponse } from "../../types/common"; export interface UpdateCourtItem { courtId?: number; // 있으면 유지(이름 변경 가능), 없으면 신규 생성. 기존 id가 요청에서 빠지면 삭제 @@ -21,24 +22,12 @@ export interface UpdateCourtsResponseData { courts: CourtItem[]; } -export interface UpdateCourtsResponse { - code: string; - message: string; - data: UpdateCourtsResponseData; - errorReason?: { - code: string; - message: string; - httpStatus: string; - }; - success: boolean; -} - // 400: 입력값 오류 / 404: 게임판 또는 코트를 찾을 수 없음 export const updateCourts = async ( gameBoardId: number, payload: UpdateCourtsRequest, ): Promise => { - const response = await api.put( + const response = await api.put>( `/api/game-boards/${gameBoardId}/courts`, payload, ); diff --git a/src/api/game/duplicateCheck.ts b/src/api/game/duplicateCheck.ts new file mode 100644 index 00000000..7c55f70f --- /dev/null +++ b/src/api/game/duplicateCheck.ts @@ -0,0 +1,32 @@ +// 게임 중복 체크 (REST, GET /api/game-boards/{gameBoardId}/games/duplicate-check) +// 선택 인원 쌍 별 대전 횟수 제공 +import qs from "qs"; +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export interface DuplicateCheckPair { + memberIdA: number; + memberIdB: number; + count: number; // 그날 누적 대전 횟수 + playedInLastGame: boolean; +} + +export interface DuplicateCheckResponse { + pairs: DuplicateCheckPair[]; // 4명이면 6쌍 +} + +export const getGameDuplicateCheck = async ( + gameBoardId: number, + gameBoardMemberIds: number[], +): Promise => { + const response = await api.get>( + `/api/game-boards/${gameBoardId}/games/duplicate-check`, + { + params: { gameBoardMemberId: gameBoardMemberIds }, + paramsSerializer: { + serialize: params => qs.stringify(params, { arrayFormat: "repeat" }), // gameBoardMemberId=101&gameBoardMemberId=102 형식 + }, + }, + ); + return response.data.data; +}; From 2b00013a3b2075362dcf0c0de4e5d45e8892c50a Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 22 Aug 2026 02:48:16 +0900 Subject: [PATCH 26/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=AA=85=EB=8B=A8=EC=A1=B0=ED=9A=8C=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/members.ts | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/api/game/members.ts diff --git a/src/api/game/members.ts b/src/api/game/members.ts new file mode 100644 index 00000000..4d65704f --- /dev/null +++ b/src/api/game/members.ts @@ -0,0 +1,44 @@ +// 게임판 명단 조회 (REST, GET /api/game-boards/{gameBoardId}/gameBoardMembers) +import qs from "qs"; +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export interface GameBoardMember { + gameBoardMemberId: number; + inGame: boolean; // 운동 참여 여부 + waiting: boolean; // 대기 참여 여부 + participating: boolean; // 참여 여부 + gameCount: number; // 게임 참여 횟수 + profileImageUrl: string | null; + name: string; + ageGroup: string; + level: string; + shuttlecockSubmitted: boolean; +} + +export interface GameBoardMembersResponse { + totalCount: number; // 전체 운동 인원 + gameBoardMembers: GameBoardMember[]; +} + +export interface GetGameBoardMembersParams { + level?: string[]; // 급수, 다중 선택 + gender?: "MALE" | "FEMALE"; + shuttlecockSubmitted?: boolean; +} + +export const getGameBoardMembers = async ( + gameBoardId: number, + params: GetGameBoardMembersParams = {}, +): Promise => { + const response = await api.get>( + `/api/game-boards/${gameBoardId}/gameBoardMembers`, + { + params, + paramsSerializer: { + serialize: p => qs.stringify(p, { arrayFormat: "repeat" }), // level=A조&level=B조 형식 + }, + }, + ); + return response.data.data; +}; From 223828eeedc6686ab9639512e188247a1c3f1e50 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 22 Aug 2026 12:57:58 +0900 Subject: [PATCH 27/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20moc?= =?UTF-8?q?k=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=20REST/WS=20API=20=EC=97=B0=EB=8F=99=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코트/대기열/명단을 getGameBoard, getGameBoardMembers로 조회하고 useGameWs로 구독 - 대기열 추가/게임 시작/완료/삭제를 CREATE_GAME/START_GAME/COMPLETE_GAME/DELETE_GAME 웹소켓 액션에 연결 - 코트 관리 저장을 updateCourts REST에, 중복 체크 모달을 getGameDuplicateCheck REST에 연결 - 명단 조회 API에 gender 필드가 없어 GameMember.gender를 optional로 변경 Co-Authored-By: Claude Sonnet 5 --- .../GameBoard/CourtManageBottomSheet.tsx | 25 +- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 236 +++++++++++++----- .../GameBoard/GameDuplicateCheckModal.tsx | 72 +++++- .../GameBoard/GameEditPlayerModal.tsx | 2 +- .../GameBoard/GameMemberCard.tsx | 12 +- .../MyGroupDetail/GameBoard/gameAutoMatch.ts | 1 + .../GameBoard/gameBoardAdapter.ts | 64 +++++ .../GameBoard/mockGameBoardData.ts | 141 +---------- .../group/MyGroupDetail/MyExerciseDetail.tsx | 4 +- 9 files changed, 329 insertions(+), 228 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx index d02d8cbd..d6c09d18 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx @@ -6,38 +6,43 @@ import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; import GR400_L from "@/components/common/Btn_Static/Text/GR400_L"; import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; +export interface CourtManageItem { + courtId?: number; // 있으면 기존 코트(이름 변경), 없으면 신규 생성 + courtName: string; +} + interface CourtManageBottomSheetProps { variant?: "sheet" | "overlay"; - courtLabels: string[]; + courts: CourtManageItem[]; onClose: () => void; - onSave: (labels: string[]) => void; + onSave: (courts: CourtManageItem[]) => void; } export const CourtManageBottomSheet = ({ variant = "sheet", - courtLabels, + courts, onClose, onSave, }: CourtManageBottomSheetProps) => { useLockBodyScroll(true); - const [labels, setLabels] = useState(courtLabels); + const [items, setItems] = useState(courts); const [newLabel, setNewLabel] = useState(""); const [isNewLabelFocused, setIsNewLabelFocused] = useState(false); const handleAdd = () => { const trimmed = newLabel.trim(); if (!trimmed) return; - setLabels(prev => [...prev, trimmed]); + setItems(prev => [...prev, { courtName: trimmed }]); setNewLabel(""); }; const handleRemove = (index: number) => { - setLabels(prev => prev.filter((_, i) => i !== index)); + setItems(prev => prev.filter((_, i) => i !== index)); }; const handleSave = () => { const trimmed = newLabel.trim(); - onSave(trimmed ? [...labels, trimmed] : labels); + onSave(trimmed ? [...items, { courtName: trimmed }] : items); onClose(); }; @@ -48,13 +53,13 @@ export const CourtManageBottomSheet = ({ 코트 관리
- {labels.map((label, index) => ( + {items.map((item, index) => (
- {label} + {item.courtName}
- {members.length === 4 ? ( - + {matchups === null ? ( +
+ 불러오는 중이에요... +
+ ) : members.length === 4 ? ( + ) : ( - + )} {isOverlay ? ( diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx index 55c9edf7..98eab525 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx @@ -36,7 +36,7 @@ export const GameEditPlayerModal = ({ useLockBodyScroll(true); const [name, setName] = useState(member.name); const [gender, setGender] = useState<"MALE" | "FEMALE" | null>( - member.gender, + member.gender ?? null, ); const [level, setLevel] = useState( member.group === "급수없음" ? "disabled" : member.group, diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx index 3973885c..1beabcea 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -158,11 +158,13 @@ export const GameMemberCard = ({
- + {gender && ( + + )} {group} diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts index d8eafcb0..342ac632 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts @@ -28,6 +28,7 @@ const AGE_BONUS_MALE: Record = { const getSkillScore = (member: GameMember) => { const base = GRADE_SCORE[member.group] ?? 5; + if (!member.gender) return base; // 성별 정보 없음 - 보정 없이 기본 점수만 사용 if (member.gender === "FEMALE") return base - 1; return base + (AGE_BONUS_MALE[member.ageGroup] ?? 0.2); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts new file mode 100644 index 00000000..e05ecef5 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts @@ -0,0 +1,64 @@ +// 게임판 REST/WS 응답(GameBoardDTO 등)을 화면에서 쓰는 view model(CourtGroup/WaitingGroup/GameMember)로 변환한다. +import type { + GameBoardCourt, + GameBoardPlayer, + GameBoardResponse, + GameBoardWaiting, +} from "@/api/game/board"; +import type { GameBoardMember } from "@/api/game/members"; +import type { CourtGroup, GameMember, GamePlayer, WaitingGroup } from "./mockGameBoardData"; + +export const formatElapsed = (startedAt: string) => { + const ms = Date.now() - new Date(startedAt).getTime(); + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const mm = String(Math.floor(totalSec / 60)).padStart(2, "0"); + const ss = String(totalSec % 60).padStart(2, "0"); + return `${mm}:${ss}`; +}; + +const toGamePlayer = (p: GameBoardPlayer): GamePlayer => ({ + id: p.gameBoardMemberId, + name: p.name, + group: p.level, + color: p.playerOrder % 2 === 0 ? "pink" : "blue", +}); + +export const toCourtGroup = (c: GameBoardCourt): CourtGroup => ({ + id: c.courtId, + label: c.courtName, + gameId: c.game?.gameId, + startedAt: c.game?.startedAt, + timer: c.game ? formatElapsed(c.game.startedAt) : undefined, + players: c.game ? c.game.players.map(toGamePlayer) : null, +}); + +export const toWaitingGroup = (w: GameBoardWaiting): WaitingGroup => ({ + id: w.gameId, + gameId: w.gameId, + label: `대기 ${w.waitingOrder}번`, + players: w.players.map(toGamePlayer), + memberIds: w.players.map(p => p.gameBoardMemberId), +}); + +export const toBoardViewModel = (board: GameBoardResponse) => ({ + courts: board.courts.map(toCourtGroup), + waitingGroups: board.waitings.map(toWaitingGroup), +}); + +export const toGameMember = (m: GameBoardMember): GameMember => { + const tags = !m.participating + ? (["미참여"] as const) + : ([...(m.inGame ? ["운동" as const] : []), ...(m.waiting ? ["대기" as const] : [])]); + + return { + id: m.gameBoardMemberId, + name: m.name, + // gender: 명단 조회 API에 필드가 없어 매핑 불가 — 확정되면 채운다. + ageGroup: m.ageGroup, + group: m.level, + playCount: m.gameCount, + tags: [...tags], + imgUrl: m.profileImageUrl, + selectable: m.participating, + }; +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts index 2fccdd25..8158f645 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -6,14 +6,17 @@ export interface GamePlayer { } export interface CourtGroup { - id: number; + id: number; // courtId label: string; - timer?: string; + gameId?: number; // 진행중인 게임 id (완료/대기이동 액션에 필요) + timer?: string; // startedAt으로부터 계산된 표시용 "MM:SS" + startedAt?: string; // 스톱워치 기준 시각 players: GamePlayer[] | null; // null → 빈 코트 } export interface WaitingGroup { id: number; + gameId: number; // 대기중인 게임 id (삭제/시작 액션에 필요) label: string; players: GamePlayer[]; memberIds: number[]; @@ -24,7 +27,7 @@ export type MemberTag = "운동" | "대기" | "미참여"; export interface GameMember { id: number; name: string; - gender: "MALE" | "FEMALE"; + gender?: "MALE" | "FEMALE"; // 명단 조회 API 미제공 필드 — 확정되면 필수로 변경 ageGroup: string; group: string; playCount: number; @@ -33,13 +36,6 @@ export interface GameMember { selectable: boolean; } -const player = ( - id: number, - name: string, - group: string, - color: "pink" | "blue", -): GamePlayer => ({ id, name, group, color }); - // 직전 경기: 가장 최근에 함께 뛴 조합 / 첫 경기: 한 번도 함께 뛴 적 없는 조합 / 이전 경기: 그 외 함께 뛴 적 있는 조합 export type MatchupType = "recent" | "first" | "previous"; @@ -87,128 +83,3 @@ export const getMatchupInfo = ( }); return result; }; - -export const mockCourts: CourtGroup[] = [ - { - id: 1, - label: "01 코트", - timer: "00:05", - players: [ - player(1, "김셰익", "A", "pink"), - player(2, "김셰익", "B", "pink"), - player(3, "김셰익", "C", "blue"), - player(4, "김셰익", "준자강", "blue"), - ], - }, - { id: 2, label: "02 코트", players: null }, - { - id: 3, - label: "03 코트", - timer: "00:05", - players: [ - player(5, "김셰익", "A", "pink"), - player(6, "김셰익", "B", "pink"), - player(7, "김셰익", "C", "blue"), - player(8, "김셰익", "준자강", "blue"), - ], - }, - { - id: 4, - label: "04 코트", - timer: "00:05", - players: [ - player(9, "김셰익", "A", "pink"), - player(10, "김셰익", "B", "pink"), - player(11, "김셰익", "C", "blue"), - player(12, "김셰익", "준자강", "blue"), - ], - }, -]; - -export const mockWaitingGroups: WaitingGroup[] = [ - { - id: 1, - label: "대기 1번", - memberIds: [1, 2, 3, 4], - players: [ - player(13, "김셰익", "A", "pink"), - player(14, "김셰익", "B", "pink"), - player(15, "김셰익", "C", "blue"), - player(16, "김셰익", "준자강", "blue"), - ], - }, - { - id: 2, - label: "대기 2번", - memberIds: [2, 3, 4, 5], - players: [ - player(17, "김셰익", "A", "pink"), - player(18, "김셰익", "B", "pink"), - player(19, "김셰익", "C", "blue"), - player(20, "김셰익", "준자강", "blue"), - ], - }, -]; - -export const mockGameMembers: GameMember[] = [ - { - id: 1, - name: "김셰익", - gender: "FEMALE", - ageGroup: "30대", - group: "D조", - playCount: 0, - tags: ["운동"], - selectable: true, - }, - { - id: 2, - name: "김셰익", - gender: "FEMALE", - ageGroup: "30대", - group: "D조", - playCount: 0, - tags: ["대기"], - selectable: true, - }, - { - id: 3, - name: "김셰익", - gender: "FEMALE", - ageGroup: "30대", - group: "D조", - playCount: 0, - tags: ["운동", "대기"], - selectable: true, - }, - { - id: 4, - name: "김셰익", - gender: "FEMALE", - ageGroup: "30대", - group: "D조", - playCount: 0, - tags: ["운동"], - selectable: true, - }, - { - id: 5, - name: "김셰익", - gender: "FEMALE", - ageGroup: "30대", - group: "D조", - playCount: 0, - tags: ["운동", "대기"], - selectable: true, - }, - { - id: 6, - name: "김셰익", - gender: "FEMALE", - ageGroup: "30대", - group: "D조", - playCount: 0, - tags: ["미참여"], - selectable: false, - }, -]; diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index 85bca4bc..496337f7 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -181,7 +181,9 @@ export const MyExerciseDetail = () => { />
- {activeTab === "game" && } + {activeTab === "game" && ( + + )} {activeTab === "gameComplete" && } {activeTab === "detail" && (
From 458674059aafe966a976cf19e5353907efff69f1 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 22 Aug 2026 12:58:12 +0900 Subject: [PATCH 28/52] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85/=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=ED=8C=90=20=EC=9B=B9=EC=86=8C=EC=BC=93=EC=9D=84=20/ws?= =?UTF-8?q?/realtime=20=EB=8B=A8=EC=9D=BC=20=EC=97=B0=EA=B2=B0=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/realtime/connection.ts에 공용 SockJS 연결·envelope 송수신 로직을 두고 chat/rawWs.ts, game/rawWs.ts는 도메인별 얇은 래퍼로 전환 - 기존 /ws/chats와 신규 /ws/realtime 동시 연결 시 중복 발송되는 문제 방지 - 채팅 쪽 외부 API(IncomingMessage, addWsListener 등)는 그대로 유지해 호출부 변경 없음 Co-Authored-By: Claude Sonnet 5 --- src/api/chat/rawWs.ts | 396 ++++++++++++--------------------- src/api/game/rawWs.ts | 286 +++--------------------- src/api/realtime/connection.ts | 288 ++++++++++++++++++++++++ 3 files changed, 461 insertions(+), 509 deletions(-) create mode 100644 src/api/realtime/connection.ts diff --git a/src/api/chat/rawWs.ts b/src/api/chat/rawWs.ts index c366592f..572f7bb1 100644 --- a/src/api/chat/rawWs.ts +++ b/src/api/chat/rawWs.ts @@ -1,16 +1,23 @@ // api/chat/rawWs.ts -// SockJS 전용 (STOMP 미사용). 기존 함수명/시그니처 유지. - -// SockJS는 WebSocket 연결 시점에 dynamic import로 로드 -import useUserStore from "../../store/useUserStore"; - -let ws: WebSocket | null = null; +// CHAT 도메인. GAME과 함께 /ws/realtime 단일 연결(api/realtime/connection.ts)을 공유한다. +// 기존 /ws/chats 시절의 평문 { type, ... } 메시지 대신 이제 { version, domain:"CHAT", action/type, requestId, payload/data } envelope을 쓰지만, +// 이 파일 아래의 IncomingMessage 형태(및 함수 시그니처)는 기존과 동일하게 유지해서 이 파일을 쓰는 다른 코드는 손대지 않아도 되게 했다. +// ⚠️ CHAT 도메인의 새 응답 envelope 필드명은 백엔드 공지에서 요청(SUBSCRIBE) 예시만 확인됐고 응답 쪽은 GAME 도메인과의 유사성으로 추정한 것이라, +// 아래 toIncomingMessage()의 매핑이 실제와 다르면 여기만 고치면 된다. +import { + connectRealtimeWs, + disconnectRealtimeWs, + isRealtimeWsOpen, + realtimeWsState, + sendRealtimeFireAndForget, + addRealtimeListener, + addRealtimeOpenListener, + addRealtimeCloseListener, + type ResponseEnvelope, +} from "../realtime/connection"; export type WsStatus = "idle" | "connecting" | "open" | "closed" | "error"; -// 서버 발신 타입들 -//type ServerMessageType = 'CONNECT' | 'ERROR' | 'SUBSCRIBE' | 'SEND' | 'SYSTEM'; - // 서버 → 클라이언트(브로드캐스트)=================================== export type BcFile = { fileKey: string; @@ -20,12 +27,6 @@ export type BcFile = { }; export type BcImage = { - //🌟 - // imgKey: string; - // imgOrder: number; - // originalFileName: string; - // fileSize: number; - // fileType: string; imageId: number; imageUrl: string; imgOrder: number; @@ -48,7 +49,7 @@ export type ErrorResponse = { message: string; }; -// --- 🌟채팅방목록 구독 +// --- 채팅방목록 구독 export type ChatRoomListUpdate = { type: "CHAT_ROOM_LIST_UPDATE"; chatRoomId: number; @@ -61,12 +62,6 @@ export type ChatRoomListUpdate = { timestamp: string; }; -// 🌟export type SubscriptionResponse = { -// type: "SUBSCRIBE"; -// chatRoomId: number; -// message: string; -// timestamp: string; -// }; export type SubscriptionResponse = | { type: "SUBSCRIBE"; @@ -81,12 +76,6 @@ export type SubscriptionResponse = timestamp: string; }; -// 🌟export type UnsubscribeResponse = { -// type: "UNSUBSCRIBE"; // 문서/예시 상 불일치 대비 -// chatRoomId: number; -// message: string; -// timestamp: string; -// }; export type UnsubscribeResponse = | { type: "UNSUBSCRIBE"; @@ -101,7 +90,7 @@ export type UnsubscribeResponse = timestamp: string; }; -// --- 🌟내비바/채팅 탭 안읽음 뱃지용 (memberId 스코프, 방 구독과 무관하게 옴) +// --- 내비바/채팅 탭 안읽음 뱃지용 (memberId 스코프, 방 구독과 무관하게 옴) export type UnreadStatusUpdate = { type: "UNREAD_STATUS_UPDATE"; hasUnread: boolean; @@ -110,7 +99,6 @@ export type UnreadStatusUpdate = { timestamp: string; }; -// 새 브로드캐스트 포맷 export type BroadcastMessage = { type: "SEND"; chatRoomId: number; @@ -132,294 +120,191 @@ export type IncomingMessage = | SubscriptionResponse | UnsubscribeResponse | BroadcastMessage - | ChatRoomListUpdate // 🌟 (채팅방 목록을 구독한 상대방에게 보내는 업데이트) - | UnreadStatusUpdate; // 🌟 내비바/채팅 탭 안읽음 뱃지 + | ChatRoomListUpdate + | UnreadStatusUpdate; + +// CHAT 도메인 envelope(ResponseEnvelope) → 기존 평문 IncomingMessage로 변환 +const toIncomingMessage = (env: ResponseEnvelope): IncomingMessage | null => { + const data = (env.data ?? {}) as Record; + const timestamp = (data.timestamp as string | undefined) ?? env.timestamp; + + switch (env.type) { + case "CONNECT": + return { + type: "CONNECT", + memberId: data.memberId as number, + memberName: data.memberName as string, + connectedAt: (data.connectedAt as string) ?? timestamp, + message: data.message as string, + }; + case "ERROR": { + const err = env.error ?? (data as { code?: string; message?: string }); + return { + type: "ERROR", + errorCode: err?.code ?? "UNKNOWN", + message: err?.message ?? "오류가 발생했어요.", + }; + } + case "SUBSCRIBE": + case "SUBSCRIBED": + return { + type: "SUBSCRIBE", + ...data, + message: (data.message as string) ?? "", + timestamp, + } as SubscriptionResponse; + case "UNSUBSCRIBE": + case "UNSUBSCRIBED": + return { + type: "UNSUBSCRIBE", + ...data, + message: (data.message as string) ?? "", + timestamp, + } as UnsubscribeResponse; + case "SEND": + return { + type: "SEND", + chatRoomId: data.chatRoomId as number, + messageId: data.messageId as number, + content: (data.content as string | null) ?? null, + files: (data.files as BcFile[] | null) ?? null, + images: (data.images as BcImage[] | null) ?? null, + senderId: (data.senderId as number | null) ?? null, + senderName: data.senderName as string, + senderProfileImageUrl: + (data.senderProfileImageUrl as string | null) ?? null, + messageType: data.messageType as "TEXT" | "SYSTEM" | null | undefined, + timestamp, + unreadCount: data.unreadCount as number | undefined, + }; + case "CHAT_ROOM_LIST_UPDATE": + return { + type: "CHAT_ROOM_LIST_UPDATE", + chatRoomId: data.chatRoomId as number, + lastMessage: data.lastMessage as ChatRoomListUpdate["lastMessage"], + newUnreadCount: data.newUnreadCount as number, + timestamp, + }; + case "UNREAD_STATUS_UPDATE": + return { + type: "UNREAD_STATUS_UPDATE", + hasUnread: data.hasUnread as boolean, + hasPartyUnread: data.hasPartyUnread as boolean, + hasDirectUnread: data.hasDirectUnread as boolean, + timestamp, + }; + default: + console.warn("[Chat WS] 알 수 없는 CHAT 응답 type:", env.type, env); + return null; + } +}; //현재 구독 중인 방 목록을 전역으로 유지 (클라이언트 단의 '의도' 상태) // 서버는 Redis에 실제 구독을 보관/복원하므로 재연결시 재구독 전송은 불필요 const currentRooms = new Set(); -// 재연결 백오프 -let reconnectTimer: number | null = null; -let reconnectAttempt = 0; -let isManualClose = false; // 🌟 수동 종료 플래그 추가 - -//🌟전역 리스너(Event Bus) +//전역 리스너(Event Bus) type MsgListener = (data: IncomingMessage) => void; const listeners = new Set(); export const addWsListener = (fn: MsgListener) => { + const off = addRealtimeListener(env => { + if (env.domain !== "CHAT") return; + const msg = toIncomingMessage(env); + if (msg) fn(msg); + }); listeners.add(fn); return () => { listeners.delete(fn); + off(); }; }; -// 연결 상태 리스너: 소켓을 누가 만들었는지와 무관하게 모든 구독자에게 통지 -type OpenListener = () => void; -type CloseListener = (ev?: CloseEvent) => void; -const openListeners = new Set(); -const closeListeners = new Set(); - -export const addWsOpenListener = (fn: OpenListener) => { - openListeners.add(fn); - return () => { - openListeners.delete(fn); - }; -}; - -export const addWsCloseListener = (fn: CloseListener) => { - closeListeners.add(fn); - return () => { - closeListeners.delete(fn); - }; -}; - -// 동시 호출 시 소켓이 중복 생성되지 않도록 진행 중인 연결 시도를 공유 -let connectPromise: Promise | null = null; - -const WS_ORIGIN = ( - import.meta.env.VITE_WS_ORIGIN ?? window.location.origin -).replace(/\/$/, ""); -const WS_PATH = (import.meta.env.VITE_WS_PATH ?? "/ws/chats").replace( - /\/$/, - "", -); - -const buildSockUrl = (origin?: string) => { - const base = (origin ?? WS_ORIGIN) + WS_PATH; - return base; // SockJS는 http/https 사용 -}; - -//토큰 유틸 & 가드 -//const getToken = () => localStorage.getItem("accessToken") || ""; -const getToken = () => { - const { user } = useUserStore.getState(); - return user?.accessToken ?? localStorage.getItem("accessToken") ?? ""; -}; -const hasToken = () => !!getToken(); - -export type WsSendFile = { - fileKey: string; - originalFileName: string; - fileSize: number; - fileType: string; -}; - -export type WsSendImage = { - imgKey: string; - imgOrder: number; - originalFileName: string; - fileSize: number; - fileType: string; -}; - -type WsSendEnvelope = { - type: "SEND"; - chatRoomId: number; - content: string | null; - files: WsSendFile[] | null; - images: WsSendImage[] | null; -}; - -type OutgoingMessage = - | { type: "SUBSCRIBE"; chatRoomId: number } - | { type: "UNSUBSCRIBE"; chatRoomId: number } - | { type: "SUBSCRIBE_CHAT_LIST"; memberRooms: number[] } // 🌟목록 탭용 - | { type: "UNSUBSCRIBE_CHAT_LIST"; memberRooms: number[] } // 🌟목록 탭용 - | WsSendEnvelope; - -const sendJSON = (msg: OutgoingMessage) => { - //if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(msg)); - return true; - } - console.warn("[WS] not open. drop:", msg); - return false; -}; +export const addWsOpenListener = addRealtimeOpenListener; +export const addWsCloseListener = addRealtimeCloseListener; -// --------- 공개 API ---------- -export const connectRawWs = async ({ - memberId, +// --------- 공개 API (연결은 realtime/connection.ts로 위임) ---------- +export const connectRawWs = ({ origin, }: { - memberId: number; + memberId?: number; // 신규 /ws/realtime은 token만 사용 — 호환을 위해 인자만 유지 origin?: string; -}) => { - // accessToken 없으면 연결 시도 안 함 - if (!hasToken()) { - console.info("[WS] skipped: no accessToken"); - return null; - } - - if ( - ws && - (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) - ) { - return ws; - } - - // 동시 호출 시 소켓 중복 생성 방지 (SockJS dynamic import 대기 구간 레이스) - if (connectPromise) return connectPromise; - - connectPromise = (async () => { - const base = buildSockUrl(origin); - const url = new URL(base); - url.searchParams.set("memberId", String(memberId)); - url.searchParams.set("token", getToken()); // 서버가 헤더 대신 쿼리 파라미터로 읽는 형태라면 유지 - - // SockJS dynamic import (초기 번들에서 제외) - const { default: SockJS } = await import("sockjs-client"); - const sock = new SockJS(url.toString()); - ws = sock as WebSocket; - - // readyState가 OPEN이 되면 onopen 호출 (등록된 모든 구독자에게 통지) - sock.onopen = () => { - reconnectAttempt = 0; - isManualClose = false; // 연결 성공 시 플래그 초기화 - openListeners.forEach(fn => { - try { - fn(); - } catch (err) { - console.warn("ws open listener err", err); - } - }); - }; - - sock.onmessage = (e: MessageEvent) => { - try { - const parsed: IncomingMessage = JSON.parse(e.data); - console.log("[WS←]", parsed.type, parsed); - - // 전역 리스너 브로드캐스트 - listeners.forEach(fn => { - try { - fn(parsed); - } catch (err) { - console.warn("ws listener err", err); - } - }); - } catch { - console.warn("[SockJS] Non-JSON message:", e.data); - } - }; - - sock.onerror = (ev: Event) => { - console.warn("[WS error]", ev); - }; - - sock.onclose = (ev: CloseEvent) => { - console.warn("[WS close]", ev.code, ev.reason); - closeListeners.forEach(fn => { - try { - fn(ev); - } catch (err) { - console.warn("ws close listener err", err); - } - }); - ws = null; - connectPromise = null; - - // 🌟 수동으로 끊은 경우 재연결 시도 안 함 - if (isManualClose) { - console.log("[WS] Manual disconnect. No reconnect."); - return; - } - - // 토큰 없으면 재시도 안 함 - if (!hasToken()) return; - - // 백오프 재연결 - if (!reconnectTimer) { - const delay = Math.min(500 * 2 ** reconnectAttempt, 8000); - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - reconnectAttempt++; - connectRawWs({ memberId, origin }); - }, delay); - } - }; +} = {}) => connectRealtimeWs({ origin }); - return ws!; - })(); +export const disconnectRawWs = () => disconnectRealtimeWs(); - const result = await connectPromise; - connectPromise = null; - return result; -}; +export const rawWsState = () => realtimeWsState(); // 0/1/2/3 +export const isRawWsOpen = () => isRealtimeWsOpen(); -export const disconnectRawWs = () => { - if ( - ws && - (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) - ) { - isManualClose = true; // 🌟 수동 종료임을 명시 - ws.close(); - } - ws = null; +const sendChat = (action: string, payload: Record) => { + const ok = sendRealtimeFireAndForget("CHAT", action, payload); + if (!ok) console.warn("[Chat WS] not open. drop:", action, payload); + return ok; }; -export const rawWsState = () => ws?.readyState; // 0/1/2/3 -export const isRawWsOpen = () => ws?.readyState === WebSocket.OPEN; - -//🌟 export const subscribeChatList = (roomIds: number[]) => { if (!roomIds.length) return; - const ok = sendJSON({ type: "SUBSCRIBE_CHAT_LIST", memberRooms: roomIds }); + const ok = sendChat("SUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); console.log("[WS→] SUBSCRIBE_CHAT_LIST", roomIds, ok ? "OK" : "DEFER"); }; export const subscribeRoom = (roomId: number) => { if (currentRooms.has(roomId)) return; // 중복 방지 currentRooms.add(roomId); - const ok = sendJSON({ type: "SUBSCRIBE", chatRoomId: roomId }); + const ok = sendChat("SUBSCRIBE", { chatRoomId: roomId }); console.log("[WS→] SUBSCRIBE", roomId, ok ? "OK" : "DEFER"); }; -// export const subscribeMany = (roomIds: number[]) => { roomIds.forEach(id => subscribeRoom(id)); }; -//🌟 export const unsubscribeChatList = (roomIds: number[]) => { if (!roomIds.length) return; - const ok = sendJSON({ type: "UNSUBSCRIBE_CHAT_LIST", memberRooms: roomIds }); + const ok = sendChat("UNSUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); console.log("[WS→] UNSUBSCRIBE_CHAT_LIST", roomIds, ok ? "OK" : "DEFER"); }; -// export const unsubscribeRoom = (roomId: number) => { if (!currentRooms.has(roomId)) return; currentRooms.delete(roomId); - const ok = sendJSON({ type: "UNSUBSCRIBE", chatRoomId: roomId }); + const ok = sendChat("UNSUBSCRIBE", { chatRoomId: roomId }); if (!ok) { - // 소켓이 닫혀있으면 재접속 시 자동 재구독되지 않도록만 유지. console.warn("[WS] UNSUBSCRIBE send failed (socket closed)"); } }; -// export const unsubscribeAll = () => { // 서버가 구독을 영속화하므로, 진짜로 모두 끊고 싶을 때만 호출하세요. - [...currentRooms].forEach(id => - sendJSON({ type: "UNSUBSCRIBE", chatRoomId: id }), - ); - + [...currentRooms].forEach(id => sendChat("UNSUBSCRIBE", { chatRoomId: id })); currentRooms.clear(); }; // 채팅 SEND +export type WsSendFile = { + fileKey: string; + originalFileName: string; + fileSize: number; + fileType: string; +}; + +export type WsSendImage = { + imgKey: string; + imgOrder: number; + originalFileName: string; + fileSize: number; + fileType: string; +}; + // ---------- 송신 헬퍼 (혼합 허용) ---------- export const sendTextWS = (chatRoomId: number, content: string) => - sendJSON({ type: "SEND", chatRoomId, content, files: null, images: null }); + sendChat("SEND", { chatRoomId, content, files: null, images: null }); export const sendImagesWS = (chatRoomId: number, images: WsSendImage[]) => - sendJSON({ type: "SEND", chatRoomId, content: null, files: null, images }); + sendChat("SEND", { chatRoomId, content: null, files: null, images }); export const sendFilesWS = (chatRoomId: number, files: WsSendFile[]) => - sendJSON({ type: "SEND", chatRoomId, content: null, files, images: null }); + sendChat("SEND", { chatRoomId, content: null, files, images: null }); export const sendMixedWS = (args: { chatRoomId: number; @@ -427,8 +312,7 @@ export const sendMixedWS = (args: { files?: WsSendFile[] | null; images?: WsSendImage[] | null; }) => - sendJSON({ - type: "SEND", + sendChat("SEND", { chatRoomId: args.chatRoomId, content: args.content ?? null, files: args.files ?? null, diff --git a/src/api/game/rawWs.ts b/src/api/game/rawWs.ts index 8a1dddb5..79df5212 100644 --- a/src/api/game/rawWs.ts +++ b/src/api/game/rawWs.ts @@ -1,16 +1,18 @@ // api/game/rawWs.ts -// GAME 도메인 실시간 소켓 (Raw WebSocket, SockJS/STOMP 미사용). -// 엔드포인트: GET /ws/realtime?token={accessToken} -// 요청/응답이 requestId로 매칭되는 envelope 구조라, 액션 전송은 Promise 기반 request()로 처리한다. - -import useUserStore from "../../store/useUserStore"; +// GAME 도메인. CHAT과 함께 /ws/realtime 단일 연결(api/realtime/connection.ts)을 공유한다. +import { + connectRealtimeWs, + disconnectRealtimeWs, + isRealtimeWsOpen, + realtimeWsState, + sendRealtimeRequest, + addRealtimeListener, + addRealtimeOpenListener, + addRealtimeCloseListener, + type ResponseEnvelope, +} from "../realtime/connection"; import type { GameBoardPlayer, GameBoardResponse } from "./board"; -let ws: WebSocket | null = null; - -export type WsStatus = "idle" | "connecting" | "open" | "closed" | "error"; - -export const GAME_PROTOCOL_VERSION = 1; export const GAME_DOMAIN = "GAME" as const; // ---------- 요청 envelope ---------- @@ -24,14 +26,6 @@ export type GameAction = | "MOVE_COURT" | "MOVE_TO_WAITING"; -export type GameRequestEnvelope> = { - version: number; - domain: typeof GAME_DOMAIN; - action: GameAction; - requestId: string; - payload: TPayload; -}; - // ---------- 응답 envelope ---------- export type GameResponseType = | "SUBSCRIBED" @@ -47,255 +41,41 @@ export type GameErrorPayload = { code: string; message: string }; // GameBoardDTO.Response (게임 코트 보드 조회 Response와 동일한 스냅샷) export type GameBoardSnapshot = GameBoardResponse; -export type GameResponseEnvelope = { - version: number; - domain: typeof GAME_DOMAIN; +export type GameResponseEnvelope = ResponseEnvelope & { type: GameResponseType; - requestId?: string; - data?: TData; - // 명세상 ERROR의 데이터는 "error: { code, message }"로 표기되어 있어 - // 서버가 data 대신 error 필드로 내려줄 가능성을 함께 대비한다. - error?: GameErrorPayload; - timestamp: string; -}; - -//전역 리스너(Event Bus) — SUBSCRIBED 이후의 브로드캐스트(BOARD_UPDATED/MEMBERS_UPDATED 등) 수신용 -type MsgListener = (msg: GameResponseEnvelope) => void; -const listeners = new Set(); - -export const addGameWsListener = (fn: MsgListener) => { - listeners.add(fn); - return () => { - listeners.delete(fn); - }; }; -type OpenListener = () => void; -type CloseListener = (ev?: CloseEvent) => void; -const openListeners = new Set(); -const closeListeners = new Set(); - -export const addGameWsOpenListener = (fn: OpenListener) => { - openListeners.add(fn); - return () => { - openListeners.delete(fn); - }; -}; +// --------- 공개 API (연결은 realtime/connection.ts로 위임) ---------- +export const connectGameWs = (opts: { origin?: string } = {}) => + connectRealtimeWs(opts); -export const addGameWsCloseListener = (fn: CloseListener) => { - closeListeners.add(fn); - return () => { - closeListeners.delete(fn); - }; -}; +export const disconnectGameWs = () => disconnectRealtimeWs(); -// requestId → pending Promise 매칭 -type PendingEntry = { - resolve: (msg: GameResponseEnvelope) => void; - reject: (err: Error) => void; - timer: number; -}; -const pending = new Map(); -const REQUEST_TIMEOUT_MS = 10000; +export const gameWsState = () => realtimeWsState(); +export const isGameWsOpen = () => isRealtimeWsOpen(); -// 재연결 백오프 -let reconnectTimer: number | null = null; -let reconnectAttempt = 0; -let isManualClose = false; - -let connectPromise: Promise | null = null; - -const WS_ORIGIN = ( - import.meta.env.VITE_WS_ORIGIN ?? window.location.origin -).replace(/\/$/, ""); -const WS_REALTIME_PATH = ( - import.meta.env.VITE_WS_REALTIME_PATH ?? "/ws/realtime" -).replace(/\/$/, ""); - -const toWsUrl = (origin: string) => - origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:"); - -const buildWsUrl = (origin?: string) => toWsUrl(origin ?? WS_ORIGIN) + WS_REALTIME_PATH; - -const getToken = () => { - const { user } = useUserStore.getState(); - return user?.accessToken ?? localStorage.getItem("accessToken") ?? ""; -}; -const hasToken = () => !!getToken(); - -const genRequestId = () => - typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `req-${Date.now()}-${Math.random().toString(36).slice(2)}`; - -const rejectAllPending = (reason: string) => { - pending.forEach(({ reject, timer }) => { - window.clearTimeout(timer); - reject(new Error(reason)); +// GAME 도메인 메시지만 걸러서 전달 +export const addGameWsListener = (fn: (msg: GameResponseEnvelope) => void) => + addRealtimeListener(msg => { + if (msg.domain === "GAME") fn(msg as GameResponseEnvelope); }); - pending.clear(); -}; - -// --------- 공개 API ---------- -export const connectGameWs = async ({ - origin, -}: { origin?: string } = {}) => { - if (!hasToken()) { - console.info("[GAME WS] skipped: no accessToken"); - return null; - } - - if ( - ws && - (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) - ) { - return ws; - } - if (connectPromise) return connectPromise; - - connectPromise = (async () => { - const url = new URL(buildWsUrl(origin)); - url.searchParams.set("token", getToken()); - - const sock = new WebSocket(url.toString()); - ws = sock; - - sock.onopen = () => { - reconnectAttempt = 0; - isManualClose = false; - openListeners.forEach(fn => { - try { - fn(); - } catch (err) { - console.warn("game ws open listener err", err); - } - }); - }; - - sock.onmessage = (e: MessageEvent) => { - try { - const parsed: GameResponseEnvelope = JSON.parse(e.data); - console.log("[GAME WS←]", parsed.type, parsed); - - if (parsed.requestId && pending.has(parsed.requestId)) { - const entry = pending.get(parsed.requestId)!; - window.clearTimeout(entry.timer); - pending.delete(parsed.requestId); - if (parsed.type === "ERROR") { - const err = parsed.error ?? (parsed.data as GameErrorPayload | undefined); - entry.reject( - new Error(err?.message ?? "GAME_WS_ERROR") - ); - } else { - entry.resolve(parsed); - } - } - - // 요청자 응답 여부와 무관하게 구독자 브로드캐스트로도 전달 - listeners.forEach(fn => { - try { - fn(parsed); - } catch (err) { - console.warn("game ws listener err", err); - } - }); - } catch { - console.warn("[GAME WS] Non-JSON message:", e.data); - } - }; - - sock.onerror = (ev: Event) => { - console.warn("[GAME WS error]", ev); - }; - - sock.onclose = (ev: CloseEvent) => { - console.warn("[GAME WS close]", ev.code, ev.reason); - rejectAllPending("GAME_WS_CLOSED"); - closeListeners.forEach(fn => { - try { - fn(ev); - } catch (err) { - console.warn("game ws close listener err", err); - } - }); - ws = null; - connectPromise = null; - - if (isManualClose) { - console.log("[GAME WS] Manual disconnect. No reconnect."); - return; - } - - if (!hasToken()) return; - - if (!reconnectTimer) { - const delay = Math.min(500 * 2 ** reconnectAttempt, 8000); - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - reconnectAttempt++; - connectGameWs({ origin }); - }, delay); - } - }; - - return ws!; - })(); - - const result = await connectPromise; - connectPromise = null; - return result; -}; - -export const disconnectGameWs = () => { - if ( - ws && - (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) - ) { - isManualClose = true; - ws.close(); - } - ws = null; - rejectAllPending("GAME_WS_DISCONNECTED"); -}; - -export const gameWsState = () => ws?.readyState; // 0/1/2/3 -export const isGameWsOpen = () => ws?.readyState === WebSocket.OPEN; +export const addGameWsOpenListener = addRealtimeOpenListener; +export const addGameWsCloseListener = addRealtimeCloseListener; // requestId 기반 요청 → 매칭되는 응답(or ERROR)을 Promise로 반환 -export const gameWsRequest = >( +export const gameWsRequest = < + TData = unknown, + TPayload = Record, +>( action: GameAction, payload: TPayload, -): Promise> => { - if (!ws || ws.readyState !== WebSocket.OPEN) { - return Promise.reject(new Error("GAME_WS_NOT_OPEN")); - } - - const requestId = genRequestId(); - const envelope: GameRequestEnvelope = { - version: GAME_PROTOCOL_VERSION, - domain: GAME_DOMAIN, +) => + sendRealtimeRequest( + "GAME", action, - requestId, payload, - }; - - return new Promise>((resolve, reject) => { - const timer = window.setTimeout(() => { - pending.delete(requestId); - reject(new Error("GAME_WS_TIMEOUT")); - }, REQUEST_TIMEOUT_MS); - - pending.set(requestId, { - resolve: resolve as (msg: GameResponseEnvelope) => void, - reject, - timer, - }); - - console.log("[GAME WS→]", action, envelope); - ws!.send(JSON.stringify(envelope)); - }); -}; + ) as Promise>; // ---------- 액션별 헬퍼 ---------- // 각 액션의 payload/data 필드는 액션별 API 명세가 확정되면 구체 타입으로 교체한다. diff --git a/src/api/realtime/connection.ts b/src/api/realtime/connection.ts new file mode 100644 index 00000000..15fded8c --- /dev/null +++ b/src/api/realtime/connection.ts @@ -0,0 +1,288 @@ +// api/realtime/connection.ts +// CHAT과 GAME 도메인이 함께 쓰는 단일 SockJS 연결. +// 엔드포인트: GET /ws/realtime?token={accessToken} (기존 /ws/chats를 대체, 도메인은 envelope.domain으로 라우팅) +// 두 도메인이 각자 별도 연결을 열면 서버에서 중복 발송될 수 있어(백엔드 공지) 연결은 반드시 하나만 유지한다. +// api/chat/rawWs.ts, api/game/rawWs.ts는 이 모듈을 통해 envelope을 주고받는 얇은 래퍼다. +import useUserStore from "../../store/useUserStore"; + +let ws: WebSocket | null = null; + +export const REALTIME_PROTOCOL_VERSION = 1; +export type RealtimeDomain = "CHAT" | "GAME"; + +export type RequestEnvelope> = { + version: number; + domain: RealtimeDomain; + action: string; + requestId: string; + payload: TPayload; +}; + +export type ErrorPayload = { code: string; message: string }; + +export type ResponseEnvelope = { + version: number; + domain: RealtimeDomain; + type: string; + requestId?: string; + data?: TData; + // 에러 데이터가 data 대신 별도 error 필드로 올 가능성 대비 + error?: ErrorPayload; + timestamp: string; +}; + +type MsgListener = (msg: ResponseEnvelope) => void; +const listeners = new Set(); + +export const addRealtimeListener = (fn: MsgListener) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +}; + +type OpenListener = () => void; +type CloseListener = (ev?: CloseEvent) => void; +const openListeners = new Set(); +const closeListeners = new Set(); + +export const addRealtimeOpenListener = (fn: OpenListener) => { + openListeners.add(fn); + return () => { + openListeners.delete(fn); + }; +}; + +export const addRealtimeCloseListener = (fn: CloseListener) => { + closeListeners.add(fn); + return () => { + closeListeners.delete(fn); + }; +}; + +type PendingEntry = { + resolve: (msg: ResponseEnvelope) => void; + reject: (err: Error) => void; + timer: number; +}; +const pending = new Map(); +const REQUEST_TIMEOUT_MS = 10000; + +let reconnectTimer: number | null = null; +let reconnectAttempt = 0; +let isManualClose = false; +let connectPromise: Promise | null = null; + +const WS_ORIGIN = ( + import.meta.env.VITE_WS_ORIGIN ?? window.location.origin +).replace(/\/$/, ""); +const WS_REALTIME_PATH = ( + import.meta.env.VITE_WS_REALTIME_PATH ?? "/ws/realtime" +).replace(/\/$/, ""); + +const buildWsUrl = (origin?: string) => (origin ?? WS_ORIGIN) + WS_REALTIME_PATH; // SockJS는 http/https 사용 + +const getToken = () => { + const { user } = useUserStore.getState(); + return user?.accessToken ?? localStorage.getItem("accessToken") ?? ""; +}; +const hasToken = () => !!getToken(); + +const genRequestId = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `req-${Date.now()}-${Math.random().toString(36).slice(2)}`; + +const rejectAllPending = (reason: string) => { + pending.forEach(({ reject, timer }) => { + window.clearTimeout(timer); + reject(new Error(reason)); + }); + pending.clear(); +}; + +export const connectRealtimeWs = async ({ + origin, +}: { origin?: string } = {}) => { + if (!hasToken()) { + console.info("[Realtime WS] skipped: no accessToken"); + return null; + } + + if ( + ws && + (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) + ) { + return ws; + } + + if (connectPromise) return connectPromise; + + connectPromise = (async () => { + const url = new URL(buildWsUrl(origin)); + url.searchParams.set("token", getToken()); + + // SockJS dynamic import (초기 번들에서 제외) + const { default: SockJS } = await import("sockjs-client"); + const sock = new SockJS(url.toString()); + ws = sock as WebSocket; + + sock.onopen = () => { + reconnectAttempt = 0; + isManualClose = false; + openListeners.forEach(fn => { + try { + fn(); + } catch (err) { + console.warn("realtime ws open listener err", err); + } + }); + }; + + sock.onmessage = (e: MessageEvent) => { + try { + const parsed: ResponseEnvelope = JSON.parse(e.data); + console.log("[Realtime WS←]", parsed.domain, parsed.type, parsed); + + if (parsed.requestId && pending.has(parsed.requestId)) { + const entry = pending.get(parsed.requestId)!; + window.clearTimeout(entry.timer); + pending.delete(parsed.requestId); + if (parsed.type === "ERROR") { + const err = parsed.error ?? (parsed.data as ErrorPayload | undefined); + entry.reject(new Error(err?.message ?? "REALTIME_WS_ERROR")); + } else { + entry.resolve(parsed); + } + } + + // 요청자 응답 여부와 무관하게 도메인별 구독자에게도 전달 + listeners.forEach(fn => { + try { + fn(parsed); + } catch (err) { + console.warn("realtime ws listener err", err); + } + }); + } catch { + console.warn("[Realtime WS] Non-JSON message:", e.data); + } + }; + + sock.onerror = (ev: Event) => { + console.warn("[Realtime WS error]", ev); + }; + + sock.onclose = (ev: CloseEvent) => { + console.warn("[Realtime WS close]", ev.code, ev.reason); + rejectAllPending("REALTIME_WS_CLOSED"); + closeListeners.forEach(fn => { + try { + fn(ev); + } catch (err) { + console.warn("realtime ws close listener err", err); + } + }); + ws = null; + connectPromise = null; + + if (isManualClose) { + console.log("[Realtime WS] Manual disconnect. No reconnect."); + return; + } + + if (!hasToken()) return; + + if (!reconnectTimer) { + const delay = Math.min(500 * 2 ** reconnectAttempt, 8000); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + reconnectAttempt++; + connectRealtimeWs({ origin }); + }, delay); + } + }; + + return ws!; + })(); + + const result = await connectPromise; + connectPromise = null; + return result; +}; + +export const disconnectRealtimeWs = () => { + if ( + ws && + (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) + ) { + isManualClose = true; + ws.close(); + } + ws = null; + rejectAllPending("REALTIME_WS_DISCONNECTED"); +}; + +export const realtimeWsState = () => ws?.readyState; // 0/1/2/3 +export const isRealtimeWsOpen = () => ws?.readyState === WebSocket.OPEN; + +// requestId 기반 요청 → 매칭되는 응답(or ERROR)을 Promise로 반환 +export const sendRealtimeRequest = < + TData = unknown, + TPayload = Record, +>( + domain: RealtimeDomain, + action: string, + payload: TPayload, +): Promise> => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("REALTIME_WS_NOT_OPEN")); + } + + const requestId = genRequestId(); + const envelope: RequestEnvelope = { + version: REALTIME_PROTOCOL_VERSION, + domain, + action, + requestId, + payload, + }; + + return new Promise>((resolve, reject) => { + const timer = window.setTimeout(() => { + pending.delete(requestId); + reject(new Error("REALTIME_WS_TIMEOUT")); + }, REQUEST_TIMEOUT_MS); + + pending.set(requestId, { + resolve: resolve as (msg: ResponseEnvelope) => void, + reject, + timer, + }); + + console.log("[Realtime WS→]", domain, action, envelope); + ws!.send(JSON.stringify(envelope)); + }); +}; + +// 응답(ack)을 기다리지 않는 전송. ack/브로드캐스트는 addRealtimeListener로 별도 수신한다. +export const sendRealtimeFireAndForget = ( + domain: RealtimeDomain, + action: string, + payload: Record, +) => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.warn("[Realtime WS] not open. drop:", domain, action, payload); + return false; + } + const envelope: RequestEnvelope = { + version: REALTIME_PROTOCOL_VERSION, + domain, + action, + requestId: genRequestId(), + payload, + }; + console.log("[Realtime WS→]", domain, action, envelope); + ws.send(JSON.stringify(envelope)); + return true; +}; From 560e00c1ec0bfcc7e3a1d752f0078e7cc3858434 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 22 Aug 2026 13:54:56 +0900 Subject: [PATCH 29/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=AA=85=EB=8B=A8=20=EC=B6=94=EA=B0=80/=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95/=EC=B0=B8=EC=97=AC=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST/PATCH 요청 바디의 한글 급수·나이대 라벨 → 서버 코드 변환 로직 공용화 - 명단 조회 응답에 gender가 없어 재조회 시 화면이 알고 있던 값 유지 Co-Authored-By: Claude Opus 5 --- src/api/game/members.ts | 81 ++++++++++++ .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 120 +++++++++++------- 2 files changed, 158 insertions(+), 43 deletions(-) diff --git a/src/api/game/members.ts b/src/api/game/members.ts index 4d65704f..7d2bf574 100644 --- a/src/api/game/members.ts +++ b/src/api/game/members.ts @@ -42,3 +42,84 @@ export const getGameBoardMembers = async ( ); return response.data.data; }; + +// 한글 급수 라벨(LEVEL_KEY) → API 코드. convertLevel.ts/filterUtils.ts의 매핑과 동일한 규칙. +const LEVEL_KO_TO_EN: Record = { + 자강: "EXPERT", + 준자강: "SEMI_EXPERT", + A조: "A", + B조: "B", + C조: "C", + D조: "D", + 초심: "BEGINNER", + 왕초심: "NOVICE", + 급수없음: "NONE", +}; + +// 한글 나이대 라벨 → API 코드. "30대"=THIRTIES만 명세로 확인됐고 나머지는 동일 규칙으로 추정 — 틀리면 여기만 고치면 됨. +const AGE_GROUP_KO_TO_EN: Record = { + "10대": "TEENS", + "20대": "TWENTIES", + "30대": "THIRTIES", + "40대": "FORTIES", + "50대": "FIFTIES", + "60대 이상": "SIXTIES", +}; + +export interface GameBoardMemberPayload { + name: string; + gender: "MALE" | "FEMALE"; + level: string; // 한글 급수 라벨 (예: "D조", "급수없음") + ageGroup?: string; // 한글 나이대 라벨 (예: "30대") +} + +export type CreateGameBoardMemberRequest = GameBoardMemberPayload; +export type UpdateGameBoardMemberRequest = GameBoardMemberPayload; + +const toMemberRequestBody = (player: GameBoardMemberPayload) => ({ + name: player.name, + gender: player.gender, + level: LEVEL_KO_TO_EN[player.level] ?? player.level, + ageGroup: player.ageGroup + ? (AGE_GROUP_KO_TO_EN[player.ageGroup] ?? player.ageGroup) + : undefined, +}); + +export interface CreateGameBoardMemberResponseData { + gameBoardMemberId: number; +} + +export const createGameBoardMember = async ( + gameBoardId: number, + player: CreateGameBoardMemberRequest, +): Promise => { + const response = await api.post>( + `/api/game-boards/${gameBoardId}/gameBoardMembers`, + toMemberRequestBody(player), + ); + return response.data.data; +}; + +// 참여 상태 변경 (PATCH /api/game-boards/{boardId}/gameBoardMembers/{gameBoardMemberId}/participation) +export const updateGameBoardMemberParticipation = async ( + gameBoardId: number, + gameBoardMemberId: number, + participating: boolean, +): Promise => { + await api.patch>( + `/api/game-boards/${gameBoardId}/gameBoardMembers/${gameBoardMemberId}/participation`, + { participating }, + ); +}; + +// 플레이어 정보 수정 (PATCH /api/game-boards/{boardId}/gameBoardMembers/{gameBoardMemberId}) +export const updateGameBoardMember = async ( + gameBoardId: number, + gameBoardMemberId: number, + player: UpdateGameBoardMemberRequest, +): Promise => { + await api.patch>( + `/api/game-boards/${gameBoardId}/gameBoardMembers/${gameBoardMemberId}`, + toMemberRequestBody(player), + ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 00c636e2..633fb751 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -5,7 +5,12 @@ import Filter from "@/assets/icons/filter.svg"; import Sparkle from "@/assets/icons/sparkle_filled.svg"; import Dismiss from "@/assets/icons/dismiss.svg"; import { getGameBoard, type GameBoardResponse } from "@/api/game/board"; -import { getGameBoardMembers } from "@/api/game/members"; +import { + createGameBoardMember, + getGameBoardMembers, + updateGameBoardMember, + updateGameBoardMemberParticipation, +} from "@/api/game/members"; import { updateCourts } from "@/api/game/courts"; import { useGameWs } from "@/hooks/useGameWs"; import { CourtCard, WaitingCard } from "./CourtCard"; @@ -57,7 +62,14 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { const refreshMembers = () => { getGameBoardMembers(gameBoardId).then(res => { - setMembers(res.gameBoardMembers.map(toGameMember)); + // 명단 조회 응답에 gender가 없어, 화면에서 알고 있던 값은 유지한다. + setMembers(prev => + res.gameBoardMembers.map(m => { + const next = toGameMember(m); + const known = prev.find(p => p.id === next.id); + return known?.gender ? { ...next, gender: known.gender } : next; + }), + ); }); }; @@ -115,57 +127,79 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { const editingMember = members.find(m => m.id === editingMemberId) ?? null; - const handleSaveMemberEdit = (updated: EditedGamePlayer) => { + const handleSaveMemberEdit = async (updated: EditedGamePlayer) => { if (editingMemberId === null) return; - setMembers(prev => - prev.map(m => - m.id === editingMemberId - ? { - ...m, - name: updated.name, - gender: updated.gender, - group: updated.level, - ageGroup: updated.ageGroup, - } - : m, - ), - ); - setEditingMemberId(null); + const memberId = editingMemberId; + try { + await updateGameBoardMember(gameBoardId, memberId, { + name: updated.name, + gender: updated.gender, + level: updated.level, + ageGroup: updated.ageGroup || undefined, + }); + setMembers(prev => + prev.map(m => + m.id === memberId + ? { + ...m, + name: updated.name, + gender: updated.gender, + group: updated.level, + ageGroup: updated.ageGroup, + } + : m, + ), + ); + setEditingMemberId(null); + } catch (err) { + console.error("[GAME] 플레이어 정보 수정 실패", err); + alert("정보 수정에 실패했어요."); + } }; - const handleAddPlayer = (player: { + const handleAddPlayer = async (player: { name: string; gender: "MALE" | "FEMALE"; level: string; ageGroup: string; }) => { - setMembers(prev => [ - ...prev, - { - id: Math.max(0, ...prev.map(m => m.id)) + 1, - name: player.name, - gender: player.gender, - ageGroup: player.ageGroup, - group: player.level, - playCount: 0, - tags: ["미참여"], - selectable: true, - }, - ]); - setIsAddPlayerOpen(false); + try { + await createGameBoardMember(gameBoardId, player); + refreshMembers(); + setIsAddPlayerOpen(false); + } catch (err) { + console.error("[GAME] 명단 추가 실패", err); + alert("명단 추가에 실패했어요."); + } }; - const handleToggleParticipation = (id: number) => { - setMembers(prev => - prev.map(m => { - if (m.id !== id) return m; - const isWithdrawn = m.tags.includes("미참여"); - return isWithdrawn - ? { ...m, tags: m.tags.filter(t => t !== "미참여"), selectable: true } - : { ...m, tags: ["미참여"], selectable: false }; - }), - ); - setSelectedIds(prev => prev.filter(v => v !== id)); + const handleToggleParticipation = async (id: number) => { + const member = members.find(m => m.id === id); + if (!member) return; + const nextParticipating = member.tags.includes("미참여"); + try { + await updateGameBoardMemberParticipation( + gameBoardId, + id, + nextParticipating, + ); + setMembers(prev => + prev.map(m => { + if (m.id !== id) return m; + return nextParticipating + ? { + ...m, + tags: m.tags.filter(t => t !== "미참여"), + selectable: true, + } + : { ...m, tags: ["미참여"], selectable: false }; + }), + ); + setSelectedIds(prev => prev.filter(v => v !== id)); + } catch (err) { + console.error("[GAME] 참여 상태 변경 실패", err); + alert("참여 상태 변경에 실패했어요."); + } }; const handleAddToWaitingQueue = async () => { From 8d3c2e117bfe0783950563aea8020c39c926c3dd Mon Sep 17 00:00:00 2001 From: kimtaeyeon04 Date: Sun, 23 Aug 2026 00:15:51 +0900 Subject: [PATCH 30/52] =?UTF-8?q?feat=20:=20=EA=B2=8C=EC=9E=84=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=9E=90=20=EC=88=98=EC=A0=95,=20=EA=B2=8C=EC=9E=84?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20=ED=83=AD=20api=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 6 +- src/api/exercise/exercises.ts | 10 - src/api/game/game.ts | 97 ++++++++++ src/components/common/contentcard/Member.tsx | 5 +- src/layout/PrivateRoute.tsx | 1 + .../group/MyGroupDetail/GameFinishedTab.tsx | 176 +++++++++++------- .../group/MyGroupDetail/GameManagerPage.tsx | 123 +++--------- .../group/MyGroupDetail/MyExerciseDetail.tsx | 20 +- vite.config.ts | 4 +- 9 files changed, 260 insertions(+), 182 deletions(-) create mode 100644 src/api/game/game.ts diff --git a/src/App.tsx b/src/App.tsx index 02a9f6cc..eec94cb1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -224,7 +224,11 @@ const router = createBrowserRouter([ element: , }, { - path: "/group/Mygroup/GameManager/:exerciseId", + path: "/Group/MyGroup/Detail/Exercise_Detail/EndGame/:exerciseId", + element: , + }, + { + path: "/Group/MyGroup/Detail/Exercise_Detail/ChangeHost/:exerciseId", element: , }, { diff --git a/src/api/exercise/exercises.ts b/src/api/exercise/exercises.ts index 1b6ec67b..a297b6c6 100644 --- a/src/api/exercise/exercises.ts +++ b/src/api/exercise/exercises.ts @@ -221,13 +221,3 @@ export const deleteExercise = async (exerciseId: number) => { } }; -// 게임 진행자 지정하기 (API가 아직 없을 수 있으므로 일단 추가) -export const setGameManager = async (exerciseId: number, participantId: number) => { - try { - const response = await api.patch(`/api/exercises/${exerciseId}/participants/${participantId}/manager`); - return response.data; - } catch (error: any) { - console.error("게임 진행자 지정 API 호출 실패:", error); - throw error.response?.data || error; - } -}; diff --git a/src/api/game/game.ts b/src/api/game/game.ts new file mode 100644 index 00000000..9889155c --- /dev/null +++ b/src/api/game/game.ts @@ -0,0 +1,97 @@ +import api from "../api"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; + +export interface CompletedGamePlayer { + name: string; + tag: string; + team?: "A" | "B"; +} + +export interface CompletedGame { + gameId: number; + courtNo: number; + durationMin: number; + players: CompletedGamePlayer[]; +} + +export interface CompletedGamesResponse { + games: CompletedGame[]; + nextCursor: string | null; + hasNext: boolean; +} + +export const getCompletedGames = async ( + gameBoardId: number, + params?: { + courtNo?: number; + cursor?: string; + size?: number; + } +): Promise => { + const { data } = await api.get(`/api/game-boards/${gameBoardId}/games/completed`, { + params, + }); + return data.data; +}; + +// 게임 진행자 수정 +export const changeGameHost = async ( + exerciseId: number, + participantId: number +) => { + const { data } = await api.patch(`/api/exercises/${exerciseId}/game-host`, { + participantId, + }); + return data; +}; + +export interface GameHostParticipant { + participantId: number; + profileImageUrl: string | null; + partyPosition: string; + isGameHost: boolean; + name: string; + gender: string; + level: string; + lastExerciseDate: string; +} + +export interface GameHostCandidatesResponse { + totalCount: number; + participants: GameHostParticipant[]; +} + +export const getGameHostCandidates = async ( + exerciseId: number +): Promise => { + const { data } = await api.get(`/api/exercises/${exerciseId}/game-host`); + return data.data; +}; + +export const useGetGameHostCandidates = (exerciseId: number) => { + return useQuery({ + queryKey: ["gameHostCandidates", exerciseId], + queryFn: () => getGameHostCandidates(exerciseId), + enabled: !!exerciseId, + }); +}; + +export const useGetCompletedGames = ( + gameBoardId: number, + courtNo?: number, + size: number = 20 +) => { + return useInfiniteQuery({ + queryKey: ["completedGames", gameBoardId, courtNo], + queryFn: ({ pageParam }) => + getCompletedGames(gameBoardId, { + courtNo, + cursor: pageParam, + size, + }), + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor : undefined, + initialPageParam: undefined as string | undefined, + }); +}; + diff --git a/src/components/common/contentcard/Member.tsx b/src/components/common/contentcard/Member.tsx index 25065e61..65b25efe 100644 --- a/src/components/common/contentcard/Member.tsx +++ b/src/components/common/contentcard/Member.tsx @@ -120,7 +120,7 @@ const MemberInfo = ({ )} {isManager && ( - + ★ 게임 진행자 )} @@ -174,6 +174,7 @@ const ListMemberLayout = ({ onAppointClick, useDeleteModal, onDelete, + isManager, } = props; const getNumberText = () => { @@ -210,7 +211,7 @@ const ListMemberLayout = ({ /> - {selectMode && !isLeader && ( + {selectMode && !isLeader && !isManager && ( { diff --git a/src/layout/PrivateRoute.tsx b/src/layout/PrivateRoute.tsx index fd82e7d1..f3f57e77 100644 --- a/src/layout/PrivateRoute.tsx +++ b/src/layout/PrivateRoute.tsx @@ -10,6 +10,7 @@ interface PrivateRouteProps { export const PrivateRoute = ({ children }: PrivateRouteProps) => { const navigate = useNavigate(); const isAuthenticated = localStorage.getItem("accessToken"); + useEffect(() => { if (!isAuthenticated) { navigate("/login"); diff --git a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx index d3f7d423..e64d5a94 100644 --- a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx +++ b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx @@ -1,6 +1,10 @@ +import React, { useState, useMemo, useRef } from "react"; +import { useGetCompletedGames } from "../../../api/game/game"; + export interface PlayerInfo { name: string; tag: string; + team?: "A" | "B"; } export interface MatchInfo { @@ -13,104 +17,120 @@ export interface MatchInfo { interface GameFinishedTabProps { exerciseId: number; - // 추후 API 데이터 통신 시 props로 넘겨주거나, 내부에서 useQuery 등으로 호출할 수 있습니다. - // matches?: MatchInfo[]; } export const GameFinishedTab = ({ exerciseId }: GameFinishedTabProps) => { - // TODO: 실제 API 연동 시 아래 더미 배열 대신 서버 상태(useQuery)를 사용하세요. - // 예: const { data: mockMatches } = useGameFinishedQuery(exerciseId); - const mockMatches: MatchInfo[] = [ - { - id: 1, - duration: "15분", - endTime: "22:30 완료", - teamA: [ - { name: "김셰익", tag: "A" }, - { name: "김셰익", tag: "B" } - ], - teamB: [ - { name: "김셰익", tag: "C" }, - { name: "김셰익", tag: "준자강" } - ] - }, - { - id: 2, - duration: "15분", - endTime: "22:30 완료", - teamA: [ - { name: "김셰익", tag: "A" }, - { name: "김셰익", tag: "B" } - ], - teamB: [ - { name: "김셰익", tag: "C" }, - { name: "김셰익", tag: "준자강" } - ] - }, - { - id: 3, - duration: "15분", - endTime: "22:30 완료", - teamA: [ - { name: "김셰익", tag: "A" }, - { name: "김셰익", tag: "B" } - ], - teamB: [ - { name: "김셰익", tag: "C" }, - { name: "김셰익", tag: "준자강" } - ] - }, - { - id: 4, - duration: "15분", - endTime: "22:30 완료", - teamA: [ - { name: "김셰익", tag: "A" }, - { name: "김셰익", tag: "B" } - ], - teamB: [ - { name: "김셰익", tag: "C" }, - { name: "김셰익", tag: "준자강" } - ] - } - ]; + const [courtNo, setCourtNo] = useState(1); + + const { data, fetchNextPage, hasNextPage, isLoading } = useGetCompletedGames( + exerciseId, + courtNo + ); + + const matches: MatchInfo[] = useMemo(() => { + if (!data) return []; + + return data.pages.flatMap((page) => + page.games.map((game) => { + const teamA = game.players.filter(p => p.team === "A"); + const teamB = game.players.filter(p => p.team === "B"); + + const finalTeamA = teamA.length > 0 ? teamA : game.players.slice(0, Math.ceil(game.players.length / 2)); + const finalTeamB = teamB.length > 0 ? teamB : game.players.slice(Math.ceil(game.players.length / 2)); + + return { + id: game.gameId, + duration: `${game.durationMin}분`, + endTime: "완료", + teamA: finalTeamA.map(p => ({ name: p.name, tag: p.tag })), + teamB: finalTeamB.map(p => ({ name: p.name, tag: p.tag })) + }; + }) + ); + }, [data]); + + const handlePrevCourt = () => { + if (courtNo > 1) setCourtNo((prev) => prev - 1); + }; + + const handleNextCourt = () => { + setCourtNo((prev) => prev + 1); + }; + + const scrollRef = React.useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [startY, setStartY] = useState(0); + const [scrollTop, setScrollTop] = useState(0); + + const onMouseDown = (e: React.MouseEvent) => { + if (!scrollRef.current) return; + setIsDragging(true); + setStartY(e.pageY - scrollRef.current.offsetTop); + setScrollTop(scrollRef.current.scrollTop); + }; + + const onMouseLeave = () => setIsDragging(false); + const onMouseUp = () => setIsDragging(false); + + const onMouseMove = (e: React.MouseEvent) => { + if (!isDragging || !scrollRef.current) return; + e.preventDefault(); + const y = e.pageY - scrollRef.current.offsetTop; + const walk = (y - startY) * 1.5; // 스크롤 민감도 + scrollRef.current.scrollTop = scrollTop - walk; + }; return (
- {/* 코트명 (배경색 없음) */} -
- 01 코트 + {/* 코트명 */} +
+ {String(courtNo).padStart(2, '0')} 코트
-
+
{/* 왼쪽 화살표 */} - - {/* 매치 리스트를 담는 회색 배경 컴포넌트 */} -
- {mockMatches.map(match => ( + {/* 매치 리스트 */} +
+ {isLoading &&
로딩 중...
} + {!isLoading && matches.length === 0 && ( +
완료된 게임이 없습니다.
+ )} + {matches.map(match => (
- {match.duration} + {match.duration} {match.endTime}
{match.teamA.map((p, idx) => ( -
- {p.name} +
+ {p.name} {p.tag}
))}
{match.teamB.map((p, idx) => ( -
- {p.name} +
+ {p.name} {p.tag}
))} @@ -118,10 +138,21 @@ export const GameFinishedTab = ({ exerciseId }: GameFinishedTabProps) => {
))} + {hasNextPage && ( + + )}
{/* 오른쪽 화살표 */} -
); }; + diff --git a/src/pages/group/MyGroupDetail/GameManagerPage.tsx b/src/pages/group/MyGroupDetail/GameManagerPage.tsx index e879fc53..dd5b264f 100644 --- a/src/pages/group/MyGroupDetail/GameManagerPage.tsx +++ b/src/pages/group/MyGroupDetail/GameManagerPage.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { PageHeader } from "../../../components/common/system/header/PageHeader"; import { Member, type MemberProps } from "../../../components/common/contentcard/Member"; -import { getExerciseDetail, setGameManager } from "../../../api/exercise/exercises"; +import { getExerciseDetail } from "../../../api/exercise/exercises"; +import { changeGameHost, useGetGameHostCandidates } from "../../../api/game/game"; import useUserStore from "../../../store/useUserStore"; import { useQueryClient } from "@tanstack/react-query"; import DismissIcon from "../../../assets/icons/dismiss.svg?react"; @@ -18,78 +19,39 @@ export const GameManagerPage = () => { const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); - const [members, setMembers] = useState([ - { - participantId: 1, - memberId: 101, - status: "Participating", - name: "탕수육", - gender: "FEMALE", - level: "전국 D조", - lastExerciseDate: "2000-00-00", - isLeader: true, - isManager: true, - }, - { - participantId: 2, - memberId: 102, - status: "Participating", - name: "마라탕", - gender: "FEMALE", - level: "전국 D조", - lastExerciseDate: "2000-00-00", - isLeader: false, - isManager: false, - }, - { - participantId: 3, - memberId: 103, - status: "Participating", - name: "고구마", - gender: "MALE", - level: "전국 D조", - lastExerciseDate: "2000-00-00", - isLeader: false, - isManager: false, - }, - { - participantId: 4, - memberId: 104, - status: "Participating", - name: "옥수수", - gender: "MALE", - level: "전국 D조", - lastExerciseDate: "2000-00-00", - isLeader: false, - isManager: false, + const { data, isLoading } = useGetGameHostCandidates(exerciseIdNumber); + const totalCount = data?.totalCount || 0; + useEffect(() => { + if (data) { + console.log("Game Host Candidates Data:", data); + data.participants.forEach(p => console.log("Participant:", p.name, "partyPosition:", p.partyPosition)); } - ]); - const [totalCount, setTotalCount] = useState(4); - + }, [data]); + + const members: MemberProps[] = data?.participants.map(p => ({ + participantId: p.participantId, + // memberId가 응답에 없으므로 일단 participantId로 임시 사용 또는 제외 + memberId: p.participantId, + status: "Participating" as const, + name: p.name, + gender: p.gender as "MALE" | "FEMALE", + level: p.level, + lastExerciseDate: p.lastExerciseDate, + imgUrl: p.profileImageUrl, + // partyPosition: "모임장", "부모임장", "일반 회원" 등 한글 또는 영문 처리 + isLeader: p.partyPosition === "OWNER" || p.partyPosition === "MANAGER" || p.partyPosition === "PARTY_MANAGER" || p.partyPosition === "모임장", + isManager: p.isGameHost, + position: p.partyPosition === "OWNER" || p.partyPosition === "MANAGER" || p.partyPosition === "PARTY_MANAGER" || p.partyPosition === "모임장" + ? "leader" + : p.partyPosition === "SUBOWNER" || p.partyPosition === "PARTY_SUBMANAGER" || p.partyPosition === "부모임장" + ? "sub_leader" + : null, + })) || []; const [selectedMember, setSelectedMember] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [selectMode, setSelectMode] = useState(false); - useEffect(() => { - if (exerciseIdNumber) { - fetchMembers(); - } - }, [exerciseIdNumber, user?.memberId]); - - const fetchMembers = async () => { - // API 호출 대신 더미 데이터를 그대로 사용합니다. - /* - try { - const res = await getExerciseDetail(exerciseIdNumber, user?.memberId); - setMembers(res.participantMembers); - setTotalCount(res.participantsCount); - } catch (error) { - console.error("운동 상세 조회 실패", error); - } - */ - }; - const handleAppointClick = (member: MemberProps) => { @@ -105,37 +67,15 @@ export const GameManagerPage = () => { if (!selectedMember || selectedMember.participantId === undefined) return; try { - // 실제 API 호출 - // 만약 API가 없다면 아래 로직은 에러를 내뿜을 수 있음. - // API 호출 대신 프론트엔드 상태만 즉시 업데이트합니다. - const updatedMembers = members.map(m => { - if (m.participantId === selectedMember.participantId) { - return { ...m, isManager: true }; - } else if (m.isManager) { - // 기존 게임 진행자 권한 즉시 해제 - return { ...m, isManager: false }; - } - return m; - }); - setMembers(updatedMembers); - - /* - try { - await setGameManager(exerciseIdNumber, selectedMember.participantId); - } catch (err) { - console.warn("API 호출 실패, 하지만 프론트엔드 상태는 업데이트됨:", err); - } + await changeGameHost(exerciseIdNumber, selectedMember.participantId); queryClient.invalidateQueries({ - queryKey: ["exerciseDetail"], + queryKey: ["gameHostCandidates", exerciseIdNumber], }); - */ setIsModalOpen(false); setSelectMode(false); alert("게임 진행자가 변경되었습니다."); - // 모달 닫기 후 뒤로가기? 사용자 요구사항엔 없지만 보통 권한 변경 후 유지 또는 뒤로가기. - // 유지하는 것으로 결정. } catch (error: any) { alert(error?.message || "게임 진행자 변경에 실패했습니다."); } @@ -235,7 +175,6 @@ export const GameManagerPage = () => { className="bg-white w-[21.4375rem] flex flex-col px-3 pt-6 pb-4 shadow-ds300 rounded-2xl relative" onClick={(e) => e.stopPropagation()} > - {/* 닫기 버튼: absolute로 띄워서 내용물을 아래로 밀어내지 않게 함 */} setIsModalOpen(false)} diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index f7611ea8..96c2b3d2 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -80,14 +80,28 @@ export const MyExerciseDetail = () => { const [isWithdrawnModal, setIsWithdrawnModal] = useState(false); + const location = window.location; type TabType = "detail" | "game" | "finished"; - const [activeTab, setActiveTab] = useState("detail"); + const [activeTab, setActiveTab] = useState(() => { + if (location.pathname.includes("/EndGame")) return "finished"; + return "detail"; + }); const tabOptions = [ { label: "운동 상세", value: "detail" }, { label: "게임판", value: "game" }, { label: "게임 완료", value: "finished" }, ]; + const handleTabChange = (value: TabType) => { + setActiveTab(value); + const returnPathQuery = returnPath !== -1 ? `?returnPath=${returnPath}` : ""; + if (value === "finished") { + window.history.replaceState(null, "", `/Group/MyGroup/Detail/Exercise_Detail/EndGame/${exerciseIdNumber}${returnPathQuery}`); + } else { + window.history.replaceState(null, "", `/group/Mygroup/MyExerciseDetail/${exerciseIdNumber}${returnPathQuery}`); + } + }; + // 운동 상세 조회 useEffect(() => { if (exerciseIdNumber) { @@ -202,7 +216,7 @@ export const MyExerciseDetail = () => { setActiveTab(val)} + onChange={(val) => handleTabChange(val as TabType)} />
@@ -386,7 +400,7 @@ export const MyExerciseDetail = () => { onSelect={option => { if (option === "게임 진행자 관리") { navigate( - `/group/Mygroup/GameManager/${exerciseId}?returnPath=${returnPath}`, + `/Group/MyGroup/Detail/Exercise_Detail/ChangeHost/${exerciseId}?returnPath=${returnPath}`, ); } if (option === "운동 수정하기") { diff --git a/vite.config.ts b/vite.config.ts index a9bc1fa3..9415c7f2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -89,11 +89,11 @@ export default defineConfig(({ mode }) => { host: true, proxy: { "/api": { - target: "https://api.cockple.site", + target: "https://staging.cockple.site", changeOrigin: true, }, "/ws": { - target: "https://api.cockple.site", + target: "https://staging.cockple.site", changeOrigin: true, ws: true, }, From 6a590ac476976c56435c8f7d9242179dfbbdc03e Mon Sep 17 00:00:00 2001 From: kimtaeyeon04 Date: Sun, 23 Aug 2026 00:19:15 +0900 Subject: [PATCH 31/52] =?UTF-8?q?feat=20:=20=EA=B2=8C=EC=9E=84=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=9E=90=20=EC=88=98=EC=A0=95,=20=EA=B2=8C=EC=9E=84?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20=ED=83=AD=20api=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/MyGroupDetail/GameManagerPage.tsx | 6 ------ vite.config.ts | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameManagerPage.tsx b/src/pages/group/MyGroupDetail/GameManagerPage.tsx index dd5b264f..e59161c2 100644 --- a/src/pages/group/MyGroupDetail/GameManagerPage.tsx +++ b/src/pages/group/MyGroupDetail/GameManagerPage.tsx @@ -21,12 +21,6 @@ export const GameManagerPage = () => { const { data, isLoading } = useGetGameHostCandidates(exerciseIdNumber); const totalCount = data?.totalCount || 0; - useEffect(() => { - if (data) { - console.log("Game Host Candidates Data:", data); - data.participants.forEach(p => console.log("Participant:", p.name, "partyPosition:", p.partyPosition)); - } - }, [data]); const members: MemberProps[] = data?.participants.map(p => ({ participantId: p.participantId, diff --git a/vite.config.ts b/vite.config.ts index 9415c7f2..a9bc1fa3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -89,11 +89,11 @@ export default defineConfig(({ mode }) => { host: true, proxy: { "/api": { - target: "https://staging.cockple.site", + target: "https://api.cockple.site", changeOrigin: true, }, "/ws": { - target: "https://staging.cockple.site", + target: "https://api.cockple.site", changeOrigin: true, ws: true, }, From dea30ff5765d37da5bbe72e4eba1e6e93a751770 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sun, 23 Aug 2026 20:18:17 +0900 Subject: [PATCH 32/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A7=A4=EC=B9=AD=20=EB=8C=80=EA=B2=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20mock=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 22 ++++++++---- .../MyGroupDetail/GameBoard/gameAutoMatch.ts | 27 ++++++++++---- .../GameBoard/mockGameBoardData.ts | 35 ------------------- 3 files changed, 36 insertions(+), 48 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 633fb751..655b38aa 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -270,13 +270,23 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { } }; - const handleAutoMatch = () => { - const matchedIds = autoMatchMembers(members, courts, waitingGroups); - if (!matchedIds) { - alert("자동 매칭할 인원이 부족해요. (대기 가능 인원 최소 4명 필요)"); - return; + const handleAutoMatch = async () => { + try { + const matchedIds = await autoMatchMembers( + gameBoardId, + members, + courts, + waitingGroups, + ); + if (!matchedIds) { + alert("자동 매칭할 인원이 부족해요. (대기 가능 인원 최소 4명 필요)"); + return; + } + setSelectedIds(matchedIds); + } catch (err) { + console.error("[GAME] AUTO_MATCH 실패", err); + alert("자동 매칭에 실패했어요."); } - setSelectedIds(matchedIds); }; const handleSaveCourts = async (items: CourtManageItem[]) => { diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts index 342ac632..3f9b83ec 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts @@ -1,5 +1,5 @@ +import { getGameDuplicateCheck } from "@/api/game/duplicateCheck"; import { - getMatchupInfo, getMatchupKey, type CourtGroup, type GameMember, @@ -77,13 +77,15 @@ const bestSplitSkillDiff = (four: GameMember[]) => { }; // 4명 사이 6가지 조합 전체에 대해 과거 함께 경기한 누적 횟수 합산 (중복 페널티) -const repeatPenaltyForQuartet = (four: GameMember[]) => { - const matchupInfo = getMatchupInfo(four.map(m => m.id)); +const repeatPenaltyForQuartet = ( + four: GameMember[], + matchCounts: Map, +) => { let penalty = 0; for (let i = 0; i < 4; i++) { for (let j = i + 1; j < 4; j++) { const key = getMatchupKey(four[i].id, four[j].id); - penalty += matchupInfo.get(key)?.count ?? 0; + penalty += matchCounts.get(key) ?? 0; } } return penalty; @@ -98,11 +100,12 @@ const parseElapsedMinutes = (timer?: string) => { }; // 대기/명단 인원 중 4명을 자동으로 뽑아 회원 id 배열을 반환. 매칭 가능한 인원이 없으면 null. -export const autoMatchMembers = ( +export const autoMatchMembers = async ( + gameBoardId: number, members: GameMember[], courts: CourtGroup[], waitingGroups: WaitingGroup[], -): number[] | null => { +): Promise => { const queuedIds = new Set(waitingGroups.flatMap(g => g.memberIds)); const playingCourtOf = new Map(); courts.forEach(court => { @@ -145,12 +148,22 @@ export const autoMatchMembers = ( } candidatePool = candidatePool.slice(0, 12); // 성능을 위해 최대 12명까지만 후보로 사용 + // 후보군 전체 쌍에 대한 과거 대전 횟수를 한 번에 조회 + const duplicateCheck = await getGameDuplicateCheck( + gameBoardId, + candidatePool.map(m => m.id), + ); + const matchCounts = new Map(); + duplicateCheck.pairs.forEach(p => { + matchCounts.set(getMatchupKey(p.memberIdA, p.memberIdB), p.count); + }); + // 2단계: 후보군 내 모든 4인 조합에 대해 급수 밸런스 + 중복 이력 + 게임수 공정성 점수 계산 let best: GameMember[] | null = null; let bestScore = Infinity; combinations(candidatePool, 4).forEach(four => { const skillDiff = bestSplitSkillDiff(four); - const repeatPenalty = repeatPenaltyForQuartet(four); + const repeatPenalty = repeatPenaltyForQuartet(four, matchCounts); const gamesSum = four.reduce((sum, m) => sum + m.playCount, 0); const score = skillDiff * 100 + repeatPenalty * 15 + gamesSum; if (score < bestScore) { diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts index 8158f645..6dc0459e 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -48,38 +48,3 @@ export const getMatchupKey = (memberIdA: number, memberIdB: number) => memberIdA < memberIdB ? `${memberIdA}-${memberIdB}` : `${memberIdB}-${memberIdA}`; - -// 실제 매치 이력 API가 아직 없어 두 회원 id로 결정적인 가짜 대결 횟수(0~3)를 만든다. -const mockMatchCount = (memberIdA: number, memberIdB: number) => { - const [lo, hi] = - memberIdA < memberIdB ? [memberIdA, memberIdB] : [memberIdB, memberIdA]; - return (lo * 31 + hi * 17) % 4; -}; - -export const getMatchupInfo = ( - memberIds: number[], -): Map => { - const counts = new Map(); - let mostRecentKey: string | null = null; - let maxCount = 0; - - for (let i = 0; i < memberIds.length; i++) { - for (let j = i + 1; j < memberIds.length; j++) { - const key = getMatchupKey(memberIds[i], memberIds[j]); - const count = mockMatchCount(memberIds[i], memberIds[j]); - counts.set(key, count); - if (count > maxCount) { - maxCount = count; - mostRecentKey = key; - } - } - } - - const result = new Map(); - counts.forEach((count, key) => { - const type: MatchupType = - count === 0 ? "first" : key === mostRecentKey ? "recent" : "previous"; - result.set(key, { count, type }); - }); - return result; -}; From 9cffcca6e5a7d39a3b96fb3c826b80c8237d07d7 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sun, 23 Aug 2026 20:23:55 +0900 Subject: [PATCH 33/52] =?UTF-8?q?feat:=20=EC=BD=94=ED=8A=B8=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A8=EB=8B=AC=EC=97=90=EC=84=9C=20=EC=B2=AB?= =?UTF-8?q?=EB=B2=88=EC=A7=B8=20=EC=BD=94=ED=8A=B8=20=EB=8B=AB=EA=B8=B0=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx index d6c09d18..4d871c3f 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx @@ -61,9 +61,11 @@ export const CourtManageBottomSheet = ({ {item.courtName} - + {index !== 0 && ( + + )}
))} From b1f340726aa8cacfc3617a50623461abaa3dc39e Mon Sep 17 00:00:00 2001 From: qowldud Date: Sun, 23 Aug 2026 20:36:22 +0900 Subject: [PATCH 34/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=AA=85=EB=8B=A8=20=ED=95=84=ED=84=B0=20api=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/members.ts | 2 +- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 59 ++++++-- .../GameBoard/GameBoardWebView.tsx | 23 ++- .../GameBoard/GameFilterPage.tsx | 139 +++++++++++++----- .../GameBoard/gameBoardAdapter.ts | 32 +++- .../GameBoard/gameBoardShared.tsx | 31 ---- 6 files changed, 192 insertions(+), 94 deletions(-) delete mode 100644 src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx diff --git a/src/api/game/members.ts b/src/api/game/members.ts index 7d2bf574..5d37bc42 100644 --- a/src/api/game/members.ts +++ b/src/api/game/members.ts @@ -44,7 +44,7 @@ export const getGameBoardMembers = async ( }; // 한글 급수 라벨(LEVEL_KEY) → API 코드. convertLevel.ts/filterUtils.ts의 매핑과 동일한 규칙. -const LEVEL_KO_TO_EN: Record = { +export const LEVEL_KO_TO_EN: Record = { 자강: "EXPERT", 준자강: "SEMI_EXPERT", A조: "A", diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 655b38aa..8e0090c9 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -10,6 +10,7 @@ import { getGameBoardMembers, updateGameBoardMember, updateGameBoardMemberParticipation, + type GameBoardMember, } from "@/api/game/members"; import { updateCourts } from "@/api/game/courts"; import { useGameWs } from "@/hooks/useGameWs"; @@ -24,7 +25,13 @@ import { GameFilterPage } from "./GameFilterPage"; import { GameEndModal } from "./GameEndModal"; import { GameDuplicateCheckModal } from "./GameDuplicateCheckModal"; import { autoMatchMembers } from "./gameAutoMatch"; -import { formatElapsed, toBoardViewModel, toGameMember } from "./gameBoardAdapter"; +import { + formatElapsed, + toBoardViewModel, + toGameBoardMembersParams, + toGameMember, + type GameBoardMemberFilters, +} from "./gameBoardAdapter"; interface GameBoardTabProps { gameBoardId: number; @@ -47,6 +54,11 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { const [courtManageVariant, setCourtManageVariant] = useState< "sheet" | "overlay" | null >(null); + const [filters, setFilters] = useState({ + levels: [], + gender: "전체", + shuttle: null, + }); const gameWs = useGameWs({ gameBoardId }); @@ -60,27 +72,42 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { } }; + // 명단 조회 응답에 gender가 없어, 화면에서 알고 있던 값은 유지한다. + const applyMembersResponse = (res: { gameBoardMembers: GameBoardMember[] }) => { + setMembers(prev => + res.gameBoardMembers.map(m => { + const next = toGameMember(m); + const known = prev.find(p => p.id === next.id); + return known?.gender ? { ...next, gender: known.gender } : next; + }), + ); + }; + const refreshMembers = () => { - getGameBoardMembers(gameBoardId).then(res => { - // 명단 조회 응답에 gender가 없어, 화면에서 알고 있던 값은 유지한다. - setMembers(prev => - res.gameBoardMembers.map(m => { - const next = toGameMember(m); - const known = prev.find(p => p.id === next.id); - return known?.gender ? { ...next, gender: known.gender } : next; - }), - ); - }); + getGameBoardMembers(gameBoardId, toGameBoardMembersParams(filters)).then( + applyMembersResponse, + ); + }; + + const handleApplyFilters = (next: GameBoardMemberFilters) => { + setFilters(next); + getGameBoardMembers(gameBoardId, toGameBoardMembersParams(next)).then( + applyMembersResponse, + ); }; useEffect(() => { setIsLoading(true); - Promise.all([getGameBoard(gameBoardId), getGameBoardMembers(gameBoardId)]) + Promise.all([ + getGameBoard(gameBoardId), + getGameBoardMembers(gameBoardId, toGameBoardMembersParams(filters)), + ]) .then(([board, memberRes]) => { applyBoard(board); setMembers(memberRes.gameBoardMembers.map(toGameMember)); }) .finally(() => setIsLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameBoardId]); // 진행중인 코트의 경과 시간 스톱워치 (startedAt 기준 1초마다 재계산) @@ -514,6 +541,7 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { onEditMember={setEditingMemberId} onAddPlayer={() => setIsAddPlayerOpen(true)} onManageCourts={() => setCourtManageVariant("overlay")} + onOpenFilter={() => setIsFilterOpen(true)} onClose={() => setIsWebViewOpen(false)} /> )} @@ -545,7 +573,12 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { )} {isFilterOpen && ( - setIsFilterOpen(false)} /> + setIsFilterOpen(false)} + /> )} {completingCourtId !== null && ( diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx index ffd336c6..882e7852 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -4,6 +4,7 @@ import AddWhite from "@/assets/icons/add_white.svg"; import Sparkle from "@/assets/icons/sparkle_filled.svg"; import Dismiss from "@/assets/icons/dismiss.svg"; import ArrowLeft from "@/assets/icons/arrow_left.svg"; +import Filter from "@/assets/icons/filter.svg"; import { CourtCard, WaitingCard } from "./CourtCard"; import { GameMemberCard } from "./GameMemberCard"; import { GameEndModal } from "./GameEndModal"; @@ -12,12 +13,6 @@ import { type GameMember, type WaitingGroup, } from "./mockGameBoardData"; -import { - FILTER_LEVEL_OPTIONS, - FILTER_GENDER_OPTIONS, - FILTER_SHUTTLE_OPTIONS, - FilterChipRow, -} from "./gameBoardShared"; interface GameBoardWebViewProps { courts: CourtGroup[]; @@ -35,6 +30,7 @@ interface GameBoardWebViewProps { onEditMember: (id: number) => void; onAddPlayer: () => void; onManageCourts: () => void; + onOpenFilter: () => void; onClose: () => void; } @@ -54,6 +50,7 @@ export const GameBoardWebView = ({ onEditMember, onAddPlayer, onManageCourts, + onOpenFilter, onClose, }: GameBoardWebViewProps) => { const selectedMembers = members.filter(m => selectedIds.includes(m.id)); @@ -148,6 +145,14 @@ export const GameBoardWebView = ({ > 추가 +
@@ -193,12 +198,6 @@ export const GameBoardWebView = ({
-
- - - -
-
{members.map(member => ( boolean; onSelect: (value: string) => void; } const FilterToggleSection = ({ title, options, - selected, + isSelected, onSelect, }: FilterToggleSectionProps) => { const [isOpen, setIsOpen] = useState(true); @@ -47,7 +49,7 @@ const FilterToggleSection = ({ {options.map(option => ( onSelect(option)} className="w-19" > @@ -61,51 +63,116 @@ const FilterToggleSection = ({ }; interface GameFilterPageProps { + variant?: "sheet" | "overlay"; + initialFilters: GameBoardMemberFilters; + onApply: (filters: GameBoardMemberFilters) => void; onClose: () => void; } -export const GameFilterPage = ({ onClose }: GameFilterPageProps) => { - const [level, setLevel] = useState("전체"); - const [gender, setGender] = useState("전체"); - const [shuttle, setShuttle] = useState(null); +export const GameFilterPage = ({ + variant = "sheet", + initialFilters, + onApply, + onClose, +}: GameFilterPageProps) => { + useLockBodyScroll(true); + const [levels, setLevels] = useState(initialFilters.levels); + const [gender, setGender] = useState(initialFilters.gender); + const [shuttle, setShuttle] = useState( + initialFilters.shuttle, + ); + + // "전체" 선택 시 개별 급수 선택을 모두 해제, 개별 급수 선택 시 다중 토글 + const handleToggleLevel = (option: string) => { + if (option === "전체") { + setLevels([]); + return; + } + setLevels(prev => + prev.includes(option) + ? prev.filter(l => l !== option) + : [...prev, option], + ); + }; const handleReset = () => { - setLevel("전체"); + setLevels([]); setGender("전체"); setShuttle(null); }; + const handleApply = () => { + onApply({ levels, gender, shuttle }); + onClose(); + }; + + const isOverlay = variant === "overlay"; + + const sections = ( + <> + + option === "전체" ? levels.length === 0 : levels.includes(option) + } + onSelect={handleToggleLevel} + /> +
+ + gender === option} + onSelect={setGender} + /> +
+ + shuttle === option} + onSelect={value => + setShuttle(prev => (prev === value ? null : value)) + } + /> + + ); + + if (isOverlay) { + return ( +
+
e.stopPropagation()} + > + 필터 +
{sections}
+
+ +
+ +
+
+
+
+ ); + } + return (
-
- -
- - -
- - - setShuttle(prev => (prev === value ? null : value)) - } - /> -
-
+
{sections}
- +
diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts index e05ecef5..a151e63a 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts @@ -5,7 +5,11 @@ import type { GameBoardResponse, GameBoardWaiting, } from "@/api/game/board"; -import type { GameBoardMember } from "@/api/game/members"; +import { + LEVEL_KO_TO_EN, + type GameBoardMember, + type GetGameBoardMembersParams, +} from "@/api/game/members"; import type { CourtGroup, GameMember, GamePlayer, WaitingGroup } from "./mockGameBoardData"; export const formatElapsed = (startedAt: string) => { @@ -62,3 +66,29 @@ export const toGameMember = (m: GameBoardMember): GameMember => { selectable: m.participating, }; }; + +export interface GameBoardMemberFilters { + levels: string[]; // 한글 급수 라벨, 다중 선택 + gender: string | null; // "전체" | "남성" | "여성" + shuttle: string | null; // "제출함" | "미제출" | null +} + +const GENDER_KO_TO_EN: Record = { + 남성: "MALE", + 여성: "FEMALE", +}; + +export const toGameBoardMembersParams = ( + filters: GameBoardMemberFilters, +): GetGameBoardMembersParams => ({ + level: filters.levels.length + ? filters.levels.map(l => LEVEL_KO_TO_EN[l] ?? l) + : undefined, + gender: filters.gender ? GENDER_KO_TO_EN[filters.gender] : undefined, + shuttlecockSubmitted: + filters.shuttle === "제출함" + ? true + : filters.shuttle === "미제출" + ? false + : undefined, +}); diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx b/src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx deleted file mode 100644 index 150712a6..00000000 --- a/src/pages/group/MyGroupDetail/GameBoard/gameBoardShared.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { LEVEL_KEY } from "@/constants/options"; - -export const notReady = () => alert("준비 중인 기능이에요."); - -export const FILTER_LEVEL_OPTIONS = [...LEVEL_KEY.slice(1), "급수없음"]; -export const FILTER_GENDER_OPTIONS = ["남성", "여성"]; -export const FILTER_SHUTTLE_OPTIONS = ["제출", "미제출"]; - -export const FilterChipRow = ({ - label, - options, -}: { - label: string; - options: string[]; -}) => ( -
- {label} -
- {options.map(option => ( - - ))} -
-
-); From 17b6dd4f8963332d39d6e8c62e367686baad2e52 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sun, 23 Aug 2026 21:08:19 +0900 Subject: [PATCH 35/52] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=AA=85=EB=8B=A8=20=ED=95=84=ED=84=B0/=EC=84=B1=EB=B3=84=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20api=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/members.ts | 12 ++++---- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 9 +----- .../GameBoard/GameEditPlayerModal.tsx | 2 +- .../MyGroupDetail/GameBoard/gameAutoMatch.ts | 1 - .../GameBoard/gameBoardAdapter.ts | 29 ++++++++++--------- .../GameBoard/mockGameBoardData.ts | 2 +- 6 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src/api/game/members.ts b/src/api/game/members.ts index 5d37bc42..9f23ad55 100644 --- a/src/api/game/members.ts +++ b/src/api/game/members.ts @@ -11,19 +11,21 @@ export interface GameBoardMember { gameCount: number; // 게임 참여 횟수 profileImageUrl: string | null; name: string; - ageGroup: string; - level: string; + gender: string; // 한글 표시값 ("남성" | "여성") + ageGroup: string; // 한글 표시값 (예: "30대") + level: string; // 한글 표시값 (예: "A조") shuttlecockSubmitted: boolean; } export interface GameBoardMembersResponse { - totalCount: number; // 전체 운동 인원 + totalCount: number; // 필터와 무관한 전체 명단 수 gameBoardMembers: GameBoardMember[]; } +// level/gender는 한글 표시값을 그대로 전달 (예: level=A조&level=B조, gender=남성). 서로 다른 필터는 AND, 같은 level 다중 선택은 OR. export interface GetGameBoardMembersParams { - level?: string[]; // 급수, 다중 선택 - gender?: "MALE" | "FEMALE"; + level?: string[]; + gender?: "남성" | "여성"; shuttlecockSubmitted?: boolean; } diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 8e0090c9..7a4220b2 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -72,15 +72,8 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { } }; - // 명단 조회 응답에 gender가 없어, 화면에서 알고 있던 값은 유지한다. const applyMembersResponse = (res: { gameBoardMembers: GameBoardMember[] }) => { - setMembers(prev => - res.gameBoardMembers.map(m => { - const next = toGameMember(m); - const known = prev.find(p => p.id === next.id); - return known?.gender ? { ...next, gender: known.gender } : next; - }), - ); + setMembers(res.gameBoardMembers.map(toGameMember)); }; const refreshMembers = () => { diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx index 98eab525..55c9edf7 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx @@ -36,7 +36,7 @@ export const GameEditPlayerModal = ({ useLockBodyScroll(true); const [name, setName] = useState(member.name); const [gender, setGender] = useState<"MALE" | "FEMALE" | null>( - member.gender ?? null, + member.gender, ); const [level, setLevel] = useState( member.group === "급수없음" ? "disabled" : member.group, diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts index 3f9b83ec..df904220 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts @@ -28,7 +28,6 @@ const AGE_BONUS_MALE: Record = { const getSkillScore = (member: GameMember) => { const base = GRADE_SCORE[member.group] ?? 5; - if (!member.gender) return base; // 성별 정보 없음 - 보정 없이 기본 점수만 사용 if (member.gender === "FEMALE") return base - 1; return base + (AGE_BONUS_MALE[member.ageGroup] ?? 0.2); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts index a151e63a..536f4f77 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts @@ -5,10 +5,9 @@ import type { GameBoardResponse, GameBoardWaiting, } from "@/api/game/board"; -import { - LEVEL_KO_TO_EN, - type GameBoardMember, - type GetGameBoardMembersParams, +import type { + GameBoardMember, + GetGameBoardMembersParams, } from "@/api/game/members"; import type { CourtGroup, GameMember, GamePlayer, WaitingGroup } from "./mockGameBoardData"; @@ -49,6 +48,11 @@ export const toBoardViewModel = (board: GameBoardResponse) => ({ waitingGroups: board.waitings.map(toWaitingGroup), }); +const GENDER_KO_TO_EN: Record = { + 남성: "MALE", + 여성: "FEMALE", +}; + export const toGameMember = (m: GameBoardMember): GameMember => { const tags = !m.participating ? (["미참여"] as const) @@ -57,7 +61,7 @@ export const toGameMember = (m: GameBoardMember): GameMember => { return { id: m.gameBoardMemberId, name: m.name, - // gender: 명단 조회 API에 필드가 없어 매핑 불가 — 확정되면 채운다. + gender: GENDER_KO_TO_EN[m.gender], ageGroup: m.ageGroup, group: m.level, playCount: m.gameCount, @@ -73,18 +77,15 @@ export interface GameBoardMemberFilters { shuttle: string | null; // "제출함" | "미제출" | null } -const GENDER_KO_TO_EN: Record = { - 남성: "MALE", - 여성: "FEMALE", -}; - +// level/gender는 한글 표시값을 그대로 API 쿼리에 전달한다 (명단조회 API 스펙). export const toGameBoardMembersParams = ( filters: GameBoardMemberFilters, ): GetGameBoardMembersParams => ({ - level: filters.levels.length - ? filters.levels.map(l => LEVEL_KO_TO_EN[l] ?? l) - : undefined, - gender: filters.gender ? GENDER_KO_TO_EN[filters.gender] : undefined, + level: filters.levels.length ? filters.levels : undefined, + gender: + filters.gender === "남성" || filters.gender === "여성" + ? filters.gender + : undefined, shuttlecockSubmitted: filters.shuttle === "제출함" ? true diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts index 6dc0459e..49acd4c2 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -27,7 +27,7 @@ export type MemberTag = "운동" | "대기" | "미참여"; export interface GameMember { id: number; name: string; - gender?: "MALE" | "FEMALE"; // 명단 조회 API 미제공 필드 — 확정되면 필수로 변경 + gender: "MALE" | "FEMALE"; ageGroup: string; group: string; playCount: number; From 30fccad6e8d4dc174917fba62ffade97a997f7ca Mon Sep 17 00:00:00 2001 From: qowldud Date: Sun, 23 Aug 2026 21:36:48 +0900 Subject: [PATCH 36/52] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=AA=85=EB=8B=A8=20=EC=B6=94=EA=B0=80/=EC=88=98=EC=A0=95=20ap?= =?UTF-8?q?i=EB=8F=84=20=EA=B8=89=EC=88=98,=20=EB=82=98=EC=95=A0=EB=94=94?= =?UTF-8?q?=EB=A5=BC=20=ED=95=9C=EA=B8=80=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/members.ts | 36 ++---------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/src/api/game/members.ts b/src/api/game/members.ts index 9f23ad55..08aedf3d 100644 --- a/src/api/game/members.ts +++ b/src/api/game/members.ts @@ -45,29 +45,6 @@ export const getGameBoardMembers = async ( return response.data.data; }; -// 한글 급수 라벨(LEVEL_KEY) → API 코드. convertLevel.ts/filterUtils.ts의 매핑과 동일한 규칙. -export const LEVEL_KO_TO_EN: Record = { - 자강: "EXPERT", - 준자강: "SEMI_EXPERT", - A조: "A", - B조: "B", - C조: "C", - D조: "D", - 초심: "BEGINNER", - 왕초심: "NOVICE", - 급수없음: "NONE", -}; - -// 한글 나이대 라벨 → API 코드. "30대"=THIRTIES만 명세로 확인됐고 나머지는 동일 규칙으로 추정 — 틀리면 여기만 고치면 됨. -const AGE_GROUP_KO_TO_EN: Record = { - "10대": "TEENS", - "20대": "TWENTIES", - "30대": "THIRTIES", - "40대": "FORTIES", - "50대": "FIFTIES", - "60대 이상": "SIXTIES", -}; - export interface GameBoardMemberPayload { name: string; gender: "MALE" | "FEMALE"; @@ -78,15 +55,6 @@ export interface GameBoardMemberPayload { export type CreateGameBoardMemberRequest = GameBoardMemberPayload; export type UpdateGameBoardMemberRequest = GameBoardMemberPayload; -const toMemberRequestBody = (player: GameBoardMemberPayload) => ({ - name: player.name, - gender: player.gender, - level: LEVEL_KO_TO_EN[player.level] ?? player.level, - ageGroup: player.ageGroup - ? (AGE_GROUP_KO_TO_EN[player.ageGroup] ?? player.ageGroup) - : undefined, -}); - export interface CreateGameBoardMemberResponseData { gameBoardMemberId: number; } @@ -97,7 +65,7 @@ export const createGameBoardMember = async ( ): Promise => { const response = await api.post>( `/api/game-boards/${gameBoardId}/gameBoardMembers`, - toMemberRequestBody(player), + player, ); return response.data.data; }; @@ -122,6 +90,6 @@ export const updateGameBoardMember = async ( ): Promise => { await api.patch>( `/api/game-boards/${gameBoardId}/gameBoardMembers/${gameBoardMemberId}`, - toMemberRequestBody(player), + player, ); }; From 5bc67ce27eb19049012fef22fc4b7d894b62a705 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 25 Aug 2026 20:55:17 +0900 Subject: [PATCH 37/52] =?UTF-8?q?fix:=20=EC=9A=B4=EB=8F=99=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=9D=98=20gam?= =?UTF-8?q?eBoardId=EB=A1=9C=20=EA=B2=8C=EC=9E=84=ED=8C=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/exercise/exercises.ts | 3 +++ src/pages/group/MyGroupDetail/MyExerciseDetail.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/exercise/exercises.ts b/src/api/exercise/exercises.ts index 37cb5231..c977b9ba 100644 --- a/src/api/exercise/exercises.ts +++ b/src/api/exercise/exercises.ts @@ -58,6 +58,7 @@ export interface MemberProps { // 최종 변환 타입 export interface ExerciseDetailResponse { partyId: number; + gameBoardId: number; notice: string; placeName: string; placeAddress: string; @@ -74,6 +75,7 @@ export interface ExerciseDetailResponse { interface RawExerciseResponse { isManager: boolean; isWithdrawn?: boolean; + gameBoardId: number; info: { notice: string; buildingName: string; @@ -174,6 +176,7 @@ export const getExerciseDetail = async ( return { partyId: exerciseId, + gameBoardId: raw.gameBoardId, notice: raw.info.notice, placeName: raw.info.buildingName, placeAddress: raw.info.location, diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index 496337f7..45851c2a 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -182,7 +182,7 @@ export const MyExerciseDetail = () => {
{activeTab === "game" && ( - + )} {activeTab === "gameComplete" && } {activeTab === "detail" && ( From 15635fa124f1693781d244622c72dccf537d36d3 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 25 Aug 2026 21:22:41 +0900 Subject: [PATCH 38/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20isManager=EA=B0=80=20false=EB=A9=B4=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=88=A8=EA=B8=B0?= =?UTF-8?q?=EA=B3=A0=20=ED=95=84=ED=84=B0=20=EC=A0=81=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=20=EC=B4=88=EB=A1=9D=EC=83=89=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 182 +++++++++--------- .../group/MyGroupDetail/MyExerciseDetail.tsx | 5 +- 2 files changed, 100 insertions(+), 87 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 7a4220b2..5a9e047e 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import clsx from "clsx"; import AddWhite from "@/assets/icons/add_white.svg"; -import Filter from "@/assets/icons/filter.svg"; import Sparkle from "@/assets/icons/sparkle_filled.svg"; import Dismiss from "@/assets/icons/dismiss.svg"; +import FilterBtn from "@/components/common/DynamicBtn/FilterBtn"; import { getGameBoard, type GameBoardResponse } from "@/api/game/board"; import { createGameBoardMember, @@ -35,9 +35,10 @@ import { interface GameBoardTabProps { gameBoardId: number; + isManager: boolean; } -export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { +export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const [isLoading, setIsLoading] = useState(true); const [selectedIds, setSelectedIds] = useState([]); const [members, setMembers] = useState([]); @@ -62,6 +63,11 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => { const gameWs = useGameWs({ gameBoardId }); + const isFilterActive = + filters.levels.length > 0 || + (filters.gender !== null && filters.gender !== "전체") || + filters.shuttle !== null; + const applyBoard = (board: GameBoardResponse) => { const { courts: nextCourts, waitingGroups: nextWaitingGroups } = toBoardViewModel(board); @@ -351,33 +357,35 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => {
게임 코트 -
- - -
+ + + + + +
+ )}
@@ -432,30 +440,30 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => {
명단 - + {isManager && ( + + )}
전체 {members.length} - + 필터 +
{members.map(member => ( toggleSelect(member.id)} onEditInfo={() => setEditingMemberId(member.id)} @@ -466,56 +474,58 @@ export const GameBoardTab = ({ gameBoardId }: GameBoardTabProps) => {
{/* 하단 선택 바 */} -
- {selectedMembers.length > 0 && ( -
- {selectedMembers.map((m, i) => ( + {isManager && ( +
+ {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((m, i) => ( + + ))} +
+ )} +
+
+ {selectedIds.length} + 선택됨 +
+ + {selectedIds.length > 0 ? ( - ))} -
- )} -
-
- {selectedIds.length} - 선택됨 + ) : ( + + )}
- - {selectedIds.length > 0 ? ( - - ) : ( - - )}
-
+ )} {isWebViewOpen && ( {
{activeTab === "game" && ( - + )} {activeTab === "gameComplete" && } {activeTab === "detail" && ( From 11f4b6dc5fd8e428689755a72c093fedb7425417 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 25 Aug 2026 21:30:19 +0900 Subject: [PATCH 39/52] =?UTF-8?q?feat:=20=EB=AA=85=EB=8B=A8=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=B1=83=EC=A7=80=20=EB=85=B8=EC=B6=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/MyGroupDetail/GameBoard/GameBoardTab.tsx | 6 +++--- .../group/MyGroupDetail/GameBoard/GameMemberCard.tsx | 8 ++++---- .../group/MyGroupDetail/GameBoard/gameBoardAdapter.ts | 11 ++++++++--- .../MyGroupDetail/GameBoard/mockGameBoardData.ts | 2 +- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 5a9e047e..88245b9c 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -202,7 +202,7 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const handleToggleParticipation = async (id: number) => { const member = members.find(m => m.id === id); if (!member) return; - const nextParticipating = member.tags.includes("미참여"); + const nextParticipating = member.tags.includes("미참"); try { await updateGameBoardMemberParticipation( gameBoardId, @@ -215,10 +215,10 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { return nextParticipating ? { ...m, - tags: m.tags.filter(t => t !== "미참여"), + tags: m.tags.filter(t => t !== "미참"), selectable: true, } - : { ...m, tags: ["미참여"], selectable: false }; + : { ...m, tags: ["미참"], selectable: false }; }), ); setSelectedIds(prev => prev.filter(v => v !== id)); diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx index 1beabcea..11b9e08a 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -16,9 +16,9 @@ import ExerciseFilled from "@/assets/icons/Exercise_filled.svg"; import type { GameMember, MemberTag } from "./mockGameBoardData"; const TAG_STYLE: Record = { - 운동: "bg-gr-100 text-gr-700", - 대기: "bg-[#fff4d2] text-[#d96303]", - 미참여: "bg-rd-500 text-white", + 운동: "bg-[#fff4d2] text-[#d96303]", + 대기: "bg-[#e1eefe] text-bl-500", + 미참: "bg-gy-100 text-gy-700", }; const LONG_PRESS_MS = 600; @@ -43,7 +43,7 @@ export const GameMemberCard = ({ }: GameMemberCardProps) => { const { name, gender, ageGroup, group, playCount, tags, imgUrl, selectable } = member; - const isWithdrawn = tags.includes("미참여"); + const isWithdrawn = tags.includes("미참"); const [isMenuOpen, setIsMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts index 536f4f77..09c79a5b 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts @@ -53,10 +53,15 @@ const GENDER_KO_TO_EN: Record = { 여성: "FEMALE", }; +// 노출 조건 (판정 우선순위): 경기중 > 대기열 포함 > 운동 불참 > 뱃지 없음. 한 번에 하나만 노출한다. export const toGameMember = (m: GameBoardMember): GameMember => { - const tags = !m.participating - ? (["미참여"] as const) - : ([...(m.inGame ? ["운동" as const] : []), ...(m.waiting ? ["대기" as const] : [])]); + const tags: GameMember["tags"] = m.inGame + ? ["운동"] + : m.waiting + ? ["대기"] + : !m.participating + ? ["미참"] + : []; return { id: m.gameBoardMemberId, diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts index 49acd4c2..efd404d1 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -22,7 +22,7 @@ export interface WaitingGroup { memberIds: number[]; } -export type MemberTag = "운동" | "대기" | "미참여"; +export type MemberTag = "운동" | "대기" | "미참"; export interface GameMember { id: number; From 46d834063333508dbf578d4f12f68f3574d63d52 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 25 Aug 2026 21:46:19 +0900 Subject: [PATCH 40/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EC=B9=B4=EB=93=9C=20=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=EB=A1=9C=20=EC=BD=94=ED=8A=B8=20=EB=B0=B0=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EA=B2=BD=EA=B8=B0=EC=A4=91=20=EC=BD=94?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 40 ++ package.json | 1 + .../MyGroupDetail/GameBoard/CourtCard.tsx | 167 ++++- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 617 +++++++++++------- .../GameBoard/GameBoardWebView.tsx | 111 ++-- 5 files changed, 639 insertions(+), 297 deletions(-) diff --git a/package-lock.json b/package-lock.json index 179996fb..6b667424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cockple", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@headlessui/react": "^2.2.4", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.84.1", @@ -1613,6 +1614,45 @@ "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", diff --git a/package.json b/package.json index f6e6b70c..f711dc51 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@headlessui/react": "^2.2.4", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.84.1", diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx index d626fa1d..02ab83e0 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -8,11 +8,16 @@ import { } from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; +import { useDraggable, useDroppable } from "@dnd-kit/core"; import Pen from "@/assets/icons/pen.svg"; import Reject from "@/assets/icons/reject.svg"; import type { GamePlayer } from "./mockGameBoardData"; -const PlayerBadge = ({ name, group, color }: GamePlayer) => ( +export const courtDroppableId = (courtId: number) => `court-${courtId}`; +export const waitingDraggableId = (waitingGroupId: number) => + `waiting-${waitingGroupId}`; + +export const PlayerBadge = ({ name, group, color }: GamePlayer) => (
( ); interface CourtCardProps { + courtId: number; label: string; timer?: string; players: GamePlayer[] | null; onComplete?: () => void; + onReturnToWaiting?: () => void; + onCancelGame?: () => void; } export const CourtCard = ({ + courtId, label, timer, players, onComplete, + onReturnToWaiting, + onCancelGame, }: CourtCardProps) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const cardRef = useRef(null); + const menuRef = useRef(null); + const longPressTimer = useRef | null>(null); + const suppressNextClick = useRef(false); + const { setNodeRef: setDropRef, isOver } = useDroppable({ + id: courtDroppableId(courtId), + }); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + if (cardRef.current?.contains(e.target as Node)) { + suppressNextClick.current = true; + } + setIsMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMenuOpen]); + + const openMenuAt = (x: number, y: number) => { + setMenuPosition(clampMenuPosition(x, y, MENU_WIDTH, MENU_HEIGHT)); + setIsMenuOpen(true); + }; + + const clearLongPressTimer = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + + const handleContextMenu = (e: ReactMouseEvent) => { + e.preventDefault(); + openMenuAt(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: ReactTouchEvent) => { + const touch = e.touches[0]; + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => { + openMenuAt(touch.clientX, touch.clientY); + }, LONG_PRESS_MS); + }; + if (!players) { return ( -
+
{label}
@@ -51,7 +117,27 @@ export const CourtCard = ({ } return ( -
+
{ + cardRef.current = node; + setDropRef(node); + }} + className={clsx( + "flex w-[12.5rem] shrink-0 cursor-pointer flex-col gap-2 rounded-2xl bg-white p-2 shadow-ds100 transition-colors", + isOver && "ring-2 ring-gr-500", + )} + onClick={() => { + if (suppressNextClick.current) { + suppressNextClick.current = false; + return; + } + onReturnToWaiting?.(); + }} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={clearLongPressTimer} + onTouchMove={clearLongPressTimer} + >
{label} @@ -73,11 +159,45 @@ export const CourtCard = ({ ))}
+ + {isMenuOpen && + createPortal( +
e.stopPropagation()} + > + +
+ +
, + document.body, + )}
); }; interface WaitingCardProps { + waitingGroupId: number; label: string; players: GamePlayer[]; courts: { id: number; label: string }[]; @@ -88,6 +208,7 @@ interface WaitingCardProps { const LONG_PRESS_MS = 600; const MENU_WIDTH = 149; +const MENU_HEIGHT = 84; const MENU_EDGE_MARGIN = 8; const clampMenuPosition = ( @@ -107,6 +228,7 @@ const clampMenuPosition = ( }); export const WaitingCard = ({ + waitingGroupId, label, players, courts, @@ -114,6 +236,12 @@ export const WaitingCard = ({ onChange, onReject, }: WaitingCardProps) => { + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef: setDragRef, + isDragging, + } = useDraggable({ id: waitingDraggableId(waitingGroupId) }); const [isMenuOpen, setIsMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [clickPoint, setClickPoint] = useState<{ x: number; y: number } | null>( @@ -185,12 +313,20 @@ export const WaitingCard = ({ return (
{ + cardRef.current = node; + setDragRef(node); + }} + className={clsx( + "flex w-[12.5rem] shrink-0 touch-pan-x flex-col gap-2 rounded-2xl bg-white p-2 shadow-ds100", + isDragging && "opacity-40", + )} onContextMenu={handleContextMenu} onTouchStart={handleTouchStart} onTouchEnd={clearLongPressTimer} onTouchMove={clearLongPressTimer} + {...dragAttributes} + {...dragListeners} >
{label} @@ -243,6 +379,27 @@ export const WaitingCard = ({
))} +
+ +
, document.body, )} diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 88245b9c..0343e431 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -1,5 +1,14 @@ import { useEffect, useState } from "react"; import clsx from "clsx"; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; import AddWhite from "@/assets/icons/add_white.svg"; import Sparkle from "@/assets/icons/sparkle_filled.svg"; import Dismiss from "@/assets/icons/dismiss.svg"; @@ -14,13 +23,29 @@ import { } from "@/api/game/members"; import { updateCourts } from "@/api/game/courts"; import { useGameWs } from "@/hooks/useGameWs"; -import { CourtCard, WaitingCard } from "./CourtCard"; +import { + CourtCard, + WaitingCard, + PlayerBadge, + courtDroppableId, + waitingDraggableId, +} from "./CourtCard"; import { GameMemberCard } from "./GameMemberCard"; -import { type CourtGroup, type GameMember, type WaitingGroup } from "./mockGameBoardData"; +import { + type CourtGroup, + type GameMember, + type WaitingGroup, +} from "./mockGameBoardData"; import { GameAddPlayerModal } from "./GameAddPlayerModal"; -import { GameEditPlayerModal, type EditedGamePlayer } from "./GameEditPlayerModal"; +import { + GameEditPlayerModal, + type EditedGamePlayer, +} from "./GameEditPlayerModal"; import { GameBoardWebView } from "./GameBoardWebView"; -import { CourtManageBottomSheet, type CourtManageItem } from "./CourtManageBottomSheet"; +import { + CourtManageBottomSheet, + type CourtManageItem, +} from "./CourtManageBottomSheet"; import { GameFilterPage } from "./GameFilterPage"; import { GameEndModal } from "./GameEndModal"; import { GameDuplicateCheckModal } from "./GameDuplicateCheckModal"; @@ -33,6 +58,8 @@ import { type GameBoardMemberFilters, } from "./gameBoardAdapter"; +const MAX_SELECTED_MEMBERS = 4; + interface GameBoardTabProps { gameBoardId: number; isManager: boolean; @@ -60,6 +87,13 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { gender: "전체", shuttle: null, }); + const [activeDragGroup, setActiveDragGroup] = useState( + null, + ); + + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); const gameWs = useGameWs({ gameBoardId }); @@ -78,7 +112,9 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { } }; - const applyMembersResponse = (res: { gameBoardMembers: GameBoardMember[] }) => { + const applyMembersResponse = (res: { + gameBoardMembers: GameBoardMember[]; + }) => { setMembers(res.gameBoardMembers.map(toGameMember)); }; @@ -144,14 +180,15 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { }, [gameWs.lastMessage]); const toggleSelect = (id: number) => { - setSelectedIds(prev => - prev.includes(id) ? prev.filter(v => v !== id) : [...prev, id], - ); + setSelectedIds(prev => { + if (prev.includes(id)) return prev.filter(v => v !== id); + if (prev.length >= MAX_SELECTED_MEMBERS) return prev; + return [...prev, id]; + }); }; const selectedMembers = members.filter(m => selectedIds.includes(m.id)); - const editingMember = - members.find(m => m.id === editingMemberId) ?? null; + const editingMember = members.find(m => m.id === editingMemberId) ?? null; const handleSaveMemberEdit = async (updated: EditedGamePlayer) => { if (editingMemberId === null) return; @@ -283,6 +320,46 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { } }; + // 경기중인 코트에 대기열 매치를 드래그해 놓았을 때: 기존 매치를 대기로 보내고 그 자리를 새 매치가 차지한다. + const handleSwapCourtMatch = async ( + waitingGroupId: number, + courtId: number, + ) => { + const group = waitingGroups.find(g => g.id === waitingGroupId); + const court = courts.find(c => c.id === courtId); + if (!group || !court?.gameId) return; + try { + await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId }); + await gameWs.startGame({ gameBoardId, gameId: group.gameId, courtId }); + } catch (err) { + console.error("[GAME] 코트 교체 실패", err); + alert("코트 교체에 실패했어요."); + } + }; + + const handleDragStart = (event: DragStartEvent) => { + const group = waitingGroups.find( + g => waitingDraggableId(g.id) === event.active.id, + ); + setActiveDragGroup(group ?? null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragGroup(null); + const { active, over } = event; + if (!over) return; + const group = waitingGroups.find( + g => waitingDraggableId(g.id) === active.id, + ); + const court = courts.find(c => courtDroppableId(c.id) === over.id); + if (!group || !court) return; + if (court.players) { + handleSwapCourtMatch(group.id, court.id); + } else { + handleMoveToCourt(group.id, court.id); + } + }; + const handleCompleteCourt = async (courtId: number) => { const court = courts.find(c => c.id === courtId); if (!court?.gameId) return; @@ -296,6 +373,34 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { } }; + const handleReturnToWaiting = async (courtId: number) => { + const court = courts.find(c => c.id === courtId); + if (!court?.gameId) return; + try { + await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId }); + } catch (err) { + console.error("[GAME] MOVE_TO_WAITING 실패", err); + alert("대기로 되돌리기에 실패했어요."); + } + }; + + const handleCancelCourtGame = async (courtId: number) => { + const court = courts.find(c => c.id === courtId); + if (!court?.gameId) return; + try { + const res = await gameWs.deleteGame({ + gameBoardId, + gameId: court.gameId, + restore: true, + }); + const restoredIds = res.data?.players.map(p => p.gameBoardMemberId); + setSelectedIds(restoredIds?.length ? restoredIds : []); + } catch (err) { + console.error("[GAME] 경기 취소 실패", err); + alert("경기 취소에 실패했어요."); + } + }; + const handleAutoMatch = async () => { try { const matchedIds = await autoMatchMembers( @@ -352,257 +457,289 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { } return ( -
- {/* 게임 코트 */} -
-
- 게임 코트 - {isManager && ( -
- - -
- )} -
-
-
-
- {courts.map(court => ( - setCompletingCourtId(court.id)} - /> - ))} -
-
-
-
- - {/* 대기 */} -
-
- 대기 -
- {waitingGroups.length === 0 ? ( -
- 대기중인 팀이 없어요 + + + + + +
+ )}
- ) : ( -
+
- {waitingGroups.map(group => ( - - handleMoveToCourt(group.id, courtId) - } - onChange={() => handleChangeWaitingGroup(group)} - onReject={() => handleRemoveWaitingGroup(group.id)} + {courts.map(court => ( + setCompletingCourtId(court.id)} + onReturnToWaiting={() => handleReturnToWaiting(court.id)} + onCancelGame={() => handleCancelCourtGame(court.id)} /> ))}
- )} -
- - {/* 명단 */} -
-
- 명단 - {isManager && ( - - )} -
-
- 전체 {members.length} - setIsFilterOpen(true)} - > - 필터 - -
-
- {members.map(member => ( - toggleSelect(member.id)} - onEditInfo={() => setEditingMemberId(member.id)} - onToggleParticipation={() => handleToggleParticipation(member.id)} - /> - ))}
-
- {/* 하단 선택 바 */} - {isManager && ( -
- {selectedMembers.length > 0 && ( -
- {selectedMembers.map((m, i) => ( - - ))} + {/* 대기 */} +
+
+ 대기 +
+ {waitingGroups.length === 0 ? ( +
+ + 대기중인 팀이 없어요 +
- )} -
-
- {selectedIds.length} - 선택됨 + ) : ( +
+
+
+ {waitingGroups.map(group => ( + + handleMoveToCourt(group.id, courtId) + } + onChange={() => handleChangeWaitingGroup(group)} + onReject={() => handleRemoveWaitingGroup(group.id)} + /> + ))} +
+
- - {selectedIds.length > 0 ? ( + )} +
+ + {/* 명단 */} +
+
+ 명단 + {isManager && ( - ) : ( + )} +
+
+ + 전체 {members.length} + + setIsFilterOpen(true)} + > + 필터 + +
+
+ {members.map(member => ( + toggleSelect(member.id)} + onEditInfo={() => setEditingMemberId(member.id)} + onToggleParticipation={() => + handleToggleParticipation(member.id) + } + /> + ))} +
+
+ + {/* 하단 선택 바 */} + {isManager && ( +
+ {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((m, i) => ( + + ))} +
+ )} +
+
+ + {selectedIds.length} + + 선택됨 +
- )} + {selectedIds.length > 0 ? ( + + ) : ( + + )} +
-
- )} - - {isWebViewOpen && ( - setIsDuplicateCheckOpen(true)} - onAutoMatch={handleAutoMatch} - members={members} - selectedIds={selectedIds} - toggleSelect={toggleSelect} - onToggleParticipation={handleToggleParticipation} - onEditMember={setEditingMemberId} - onAddPlayer={() => setIsAddPlayerOpen(true)} - onManageCourts={() => setCourtManageVariant("overlay")} - onOpenFilter={() => setIsFilterOpen(true)} - onClose={() => setIsWebViewOpen(false)} - /> - )} - - {isAddPlayerOpen && ( - setIsAddPlayerOpen(false)} - onSubmit={handleAddPlayer} - /> - )} - - {editingMember && ( - setEditingMemberId(null)} - onSubmit={handleSaveMemberEdit} - /> - )} - - {courtManageVariant && ( - ({ courtId: c.id, courtName: c.label }))} - onClose={() => setCourtManageVariant(null)} - onSave={handleSaveCourts} - /> - )} - - {isFilterOpen && ( - setIsFilterOpen(false)} - /> - )} - - {completingCourtId !== null && ( - setCompletingCourtId(null)} - onConfirm={() => handleCompleteCourt(completingCourtId)} - /> - )} - - {isDuplicateCheckOpen && ( - setIsDuplicateCheckOpen(false)} - onConfirm={() => { - handleAddToWaitingQueue(); - setIsDuplicateCheckOpen(false); - }} - /> - )} -
+ )} + + {isWebViewOpen && ( + setIsDuplicateCheckOpen(true)} + onAutoMatch={handleAutoMatch} + members={members} + selectedIds={selectedIds} + toggleSelect={toggleSelect} + onToggleParticipation={handleToggleParticipation} + onEditMember={setEditingMemberId} + onAddPlayer={() => setIsAddPlayerOpen(true)} + onManageCourts={() => setCourtManageVariant("overlay")} + onOpenFilter={() => setIsFilterOpen(true)} + onClose={() => setIsWebViewOpen(false)} + /> + )} + + {isAddPlayerOpen && ( + setIsAddPlayerOpen(false)} + onSubmit={handleAddPlayer} + /> + )} + + {editingMember && ( + setEditingMemberId(null)} + onSubmit={handleSaveMemberEdit} + /> + )} + + {courtManageVariant && ( + ({ courtId: c.id, courtName: c.label }))} + onClose={() => setCourtManageVariant(null)} + onSave={handleSaveCourts} + /> + )} + + {isFilterOpen && ( + setIsFilterOpen(false)} + /> + )} + + {completingCourtId !== null && ( + setCompletingCourtId(null)} + onConfirm={() => handleCompleteCourt(completingCourtId)} + /> + )} + + {isDuplicateCheckOpen && ( + setIsDuplicateCheckOpen(false)} + onConfirm={() => { + handleAddToWaitingQueue(); + setIsDuplicateCheckOpen(false); + }} + /> + )} +
+ + {activeDragGroup ? ( +
+ + {activeDragGroup.label} + +
+ {activeDragGroup.players.map(p => ( + + ))} +
+
+ ) : null} +
+ ); }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx index 882e7852..5489a75c 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import clsx from "clsx"; +import { DndContext } from "@dnd-kit/core"; import AddWhite from "@/assets/icons/add_white.svg"; import Sparkle from "@/assets/icons/sparkle_filled.svg"; import Dismiss from "@/assets/icons/dismiss.svg"; @@ -72,66 +73,72 @@ export const GameBoardWebView = ({
- {/* 게임 코트 */} -
-
- 게임 코트 - -
-
-
-
- {courts.map(court => ( - setCompletingCourtId(court.id)} - /> - ))} -
-
-
-
- - {/* 대기 */} -
-
- 대기 -
- {waitingGroups.length === 0 ? ( -
- - 대기중인 팀이 없어요 - + + {/* 게임 코트 */} +
+
+ 게임 코트 +
- ) : ( -
+
- {waitingGroups.map(group => ( - onMoveToCourt(group.id, courtId)} - onChange={() => onChangeWaitingGroup(group)} - onReject={() => onRemoveWaitingGroup(group.id)} + {courts.map(court => ( + setCompletingCourtId(court.id)} /> ))}
- )} -
+
+ + {/* 대기 */} +
+
+ 대기 +
+ {waitingGroups.length === 0 ? ( +
+ + 대기중인 팀이 없어요 + +
+ ) : ( +
+
+
+ {waitingGroups.map(group => ( + + onMoveToCourt(group.id, courtId) + } + onChange={() => onChangeWaitingGroup(group)} + onReject={() => onRemoveWaitingGroup(group.id)} + /> + ))} +
+
+
+ )} +
+
{/* 명단 */}
From bcc06849fd6459094bf6c2b8ed41858ceb25918b Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 25 Aug 2026 22:34:21 +0900 Subject: [PATCH 41/52] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B2=B4=ED=81=AC=20=ED=99=94=EB=A9=B4=20=EB=8C=80?= =?UTF-8?q?=EC=A0=84=20=EC=A1=B0=ED=95=A9=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=94=BC=EA=B7=B8=EB=A7=88=20=EB=94=94=EC=9E=90=EC=9D=B8?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameBoard/GameDuplicateCheckModal.tsx | 126 +++++++++++++++--- 1 file changed, 107 insertions(+), 19 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx index 3b860f08..a59ab4d9 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx @@ -4,12 +4,37 @@ import GR400_L from "@/components/common/Btn_Static/Text/GR400_L"; import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; import { getGameDuplicateCheck } from "@/api/game/duplicateCheck"; -import { getMatchupKey, type GameMember, type MatchupInfo, type MatchupType } from "./mockGameBoardData"; +import { + getMatchupKey, + type GameMember, + type MatchupInfo, + type MatchupType, +} from "./mockGameBoardData"; -const LEGEND_ITEMS: { type: MatchupType; label: string; dotClass: string; textClass: string }[] = [ - { type: "recent", label: "직전 경기", dotClass: "bg-rd-500", textClass: "text-rd-500" }, - { type: "first", label: "첫 경기", dotClass: "bg-gy-500", textClass: "text-gy-700" }, - { type: "previous", label: "이전 경기", dotClass: "bg-gr-700", textClass: "text-gr-700" }, +const LEGEND_ITEMS: { + type: MatchupType; + label: string; + dotClass: string; + textClass: string; +}[] = [ + { + type: "recent", + label: "직전 경기", + dotClass: "bg-rd-500", + textClass: "text-rd-500", + }, + { + type: "first", + label: "첫 경기", + dotClass: "bg-gy-500", + textClass: "text-gy-700", + }, + { + type: "previous", + label: "이전 경기", + dotClass: "bg-gr-700", + textClass: "text-gr-700", + }, ]; const MATCHUP_BADGE_STYLE: Record = { @@ -18,6 +43,12 @@ const MATCHUP_BADGE_STYLE: Record = { first: "shadow-[0_0_8px_rgba(18,18,18,0.16)]", }; +const MATCHUP_LINE_COLOR: Record = { + recent: "#F62D2D", + previous: "#1ABB65", + first: "#E4E7EA", +}; + interface MatchBadgeProps { className?: string; info: { count: number; type: MatchupType }; @@ -49,7 +80,10 @@ const DiamondMatchup = ({ }) => { const [topLeft, bottomLeft, topRight, bottomRight] = members; const infoFor = (a: GameMember, b: GameMember) => - matchups.get(getMatchupKey(a.id, b.id)) ?? { count: 0, type: "first" as const }; + matchups.get(getMatchupKey(a.id, b.id)) ?? { + count: 0, + type: "first" as const, + }; return (
@@ -58,12 +92,54 @@ const DiamondMatchup = ({ viewBox="0 0 160 160" fill="none" > - - - - - - + + + + + +
@@ -136,7 +212,7 @@ const PairListMatchup = ({ } return ( -
+
{pairs.map(([a, b]) => { const info = matchups.get(getMatchupKey(a.id, b.id)) ?? { count: 0, @@ -145,15 +221,27 @@ const PairListMatchup = ({ return (
-
+
+ + {a.name} + - {a.name}
+
-
- {b.name} +
+
+ + {b.name} +
@@ -219,7 +307,7 @@ export const GameDuplicateCheckModal = ({ 게임 중복 체크
-
+
이 조합, 얼마나 겹쳤을까요? 선수들이 이전에 서로 경기한 횟수에요. From c37cd700d8557de83a1e3cc0bed4841907df9350 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 25 Aug 2026 23:00:36 +0900 Subject: [PATCH 42/52] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=84=A0=EC=88=98=20=EB=B1=83=EC=A7=80=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=EA=B3=BC=20=EB=8C=80=EA=B8=B0/=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=83=89=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/MyGroupDetail/GameBoard/CourtCard.tsx | 8 +++++--- .../MyGroupDetail/GameBoard/GameMemberCard.tsx | 4 ++-- .../MyGroupDetail/GameBoard/gameBoardAdapter.ts | 15 +++++++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx index 02ab83e0..3f08cdda 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -20,12 +20,14 @@ export const waitingDraggableId = (waitingGroupId: number) => export const PlayerBadge = ({ name, group, color }: GamePlayer) => (
- {name} - {group} + + {name} + + {group}
); diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx index 11b9e08a..658cd378 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -16,8 +16,8 @@ import ExerciseFilled from "@/assets/icons/Exercise_filled.svg"; import type { GameMember, MemberTag } from "./mockGameBoardData"; const TAG_STYLE: Record = { - 운동: "bg-[#fff4d2] text-[#d96303]", - 대기: "bg-[#e1eefe] text-bl-500", + 운동: "bg-gr-100 text-gr-700", + 대기: "bg-[#fff4d2] text-[#d96303]", 미참: "bg-gy-100 text-gy-700", }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts index 09c79a5b..76eb47fb 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts @@ -9,10 +9,21 @@ import type { GameBoardMember, GetGameBoardMembersParams, } from "@/api/game/members"; -import type { CourtGroup, GameMember, GamePlayer, WaitingGroup } from "./mockGameBoardData"; +import type { + CourtGroup, + GameMember, + GamePlayer, + WaitingGroup, +} from "./mockGameBoardData"; + +// 서버가 타임존 표시 없이 UTC 시각을 내려줄 때, Date가 이를 로컬 시간으로 오인해 +// 9시간(한국 UTC+9)만큼 어긋나는 것을 막기 위해 명시적으로 UTC로 해석한다. +const parseServerDate = (value: string) => + new Date(/[Zz]|[+-]\d{2}:\d{2}$/.test(value) ? value : `${value}Z`); export const formatElapsed = (startedAt: string) => { - const ms = Date.now() - new Date(startedAt).getTime(); + console.log("[formatElapsed] raw startedAt:", startedAt, "now:", new Date().toISOString()); + const ms = Date.now() - parseServerDate(startedAt).getTime(); const totalSec = Math.max(0, Math.floor(ms / 1000)); const mm = String(Math.floor(totalSec / 60)).padStart(2, "0"); const ss = String(totalSec % 60).padStart(2, "0"); From 8df5e8905708b21f0e36b58ea6ee88b439753d61 Mon Sep 17 00:00:00 2001 From: qowldud Date: Tue, 25 Aug 2026 23:31:24 +0900 Subject: [PATCH 43/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=9E=9C=EB=8D=A4=20=EB=A7=A4=EC=B9=AD=EC=9D=84=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20API=20=EC=97=B0=EB=8F=99=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클라이언트에서 급수/중복이력 계산해 추천하던 로직을 서버의 POST /games/random-match 호출로 대체 --- src/api/game/randomMatch.ts | 19 ++ .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 18 +- .../MyGroupDetail/GameBoard/gameAutoMatch.ts | 175 ------------------ 3 files changed, 26 insertions(+), 186 deletions(-) create mode 100644 src/api/game/randomMatch.ts delete mode 100644 src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts diff --git a/src/api/game/randomMatch.ts b/src/api/game/randomMatch.ts new file mode 100644 index 00000000..c1e6a12c --- /dev/null +++ b/src/api/game/randomMatch.ts @@ -0,0 +1,19 @@ +// 게임 랜덤 매칭 추천 (REST, POST /api/game-boards/{gameBoardId}/games/random-match) +// 명단에서 바로 게임 가능한 선수 4명을 서버가 추천. 결과는 저장되지 않으며, +// 확정 시 기존 웹소켓 CREATE_GAME을 호출해야 함. +// 응답 필드명은 스웨거 스키마 충돌(다른 DTO와 이름이 겹침)로 미확정 -> 실제 응답 확인되면 보정 필요. +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export interface RandomMatchResponse { + gameBoardMemberIds: number[]; // 팀 구분 없이 오름차순, 매치 타입은 노출되지 않음 +} + +export const postRandomMatch = async ( + gameBoardId: number, +): Promise => { + const response = await api.post>( + `/api/game-boards/${gameBoardId}/games/random-match`, + ); + return response.data.data; +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 0343e431..06092a9e 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import axios from "axios"; import clsx from "clsx"; import { DndContext, @@ -22,6 +23,7 @@ import { type GameBoardMember, } from "@/api/game/members"; import { updateCourts } from "@/api/game/courts"; +import { postRandomMatch } from "@/api/game/randomMatch"; import { useGameWs } from "@/hooks/useGameWs"; import { CourtCard, @@ -49,7 +51,6 @@ import { import { GameFilterPage } from "./GameFilterPage"; import { GameEndModal } from "./GameEndModal"; import { GameDuplicateCheckModal } from "./GameDuplicateCheckModal"; -import { autoMatchMembers } from "./gameAutoMatch"; import { formatElapsed, toBoardViewModel, @@ -403,19 +404,14 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { const handleAutoMatch = async () => { try { - const matchedIds = await autoMatchMembers( - gameBoardId, - members, - courts, - waitingGroups, - ); - if (!matchedIds) { + const { gameBoardMemberIds } = await postRandomMatch(gameBoardId); + setSelectedIds(gameBoardMemberIds); + } catch (err) { + console.error("[GAME] RANDOM_MATCH 실패", err); + if (axios.isAxiosError(err) && err.response?.status === 400) { alert("자동 매칭할 인원이 부족해요. (대기 가능 인원 최소 4명 필요)"); return; } - setSelectedIds(matchedIds); - } catch (err) { - console.error("[GAME] AUTO_MATCH 실패", err); alert("자동 매칭에 실패했어요."); } }; diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts b/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts deleted file mode 100644 index df904220..00000000 --- a/src/pages/group/MyGroupDetail/GameBoard/gameAutoMatch.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { getGameDuplicateCheck } from "@/api/game/duplicateCheck"; -import { - getMatchupKey, - type CourtGroup, - type GameMember, - type WaitingGroup, -} from "./mockGameBoardData"; - -// 급수별 기본 능력치 (남자 기준). 여자는 동일 급수에서 -1 적용. -const GRADE_SCORE: Record = { - 자강: 10, - 준자강: 9, - A조: 8, - B조: 7, - C조: 6, - D조: 5, - 초심: 4, - 왕초심: 3, -}; - -// 남자에 한해서만 적용되는 나이대 보정치 (여자는 나이 반영 안 함) -const AGE_BONUS_MALE: Record = { - "20대": 0.6, - "30대": 0.4, - "40대": 0.2, - "50대": 0, -}; - -const getSkillScore = (member: GameMember) => { - const base = GRADE_SCORE[member.group] ?? 5; - if (member.gender === "FEMALE") return base - 1; - return base + (AGE_BONUS_MALE[member.ageGroup] ?? 0.2); -}; - -const combinations = (arr: T[], k: number): T[][] => { - const results: T[][] = []; - const helper = (start: number, combo: T[]) => { - if (combo.length === k) { - results.push([...combo]); - return; - } - for (let i = start; i < arr.length; i++) { - combo.push(arr[i]); - helper(i + 1, combo); - combo.pop(); - } - }; - helper(0, []); - return results; -}; - -// 4명을 2:2로 나눌 수 있는 3가지 조합 중, 팀간 능력치 합 차이가 가장 적은 값을 반환 -const bestSplitSkillDiff = (four: GameMember[]) => { - const [a, b, c, d] = four; - const splits: [GameMember, GameMember][][] = [ - [ - [a, b], - [c, d], - ], - [ - [a, c], - [b, d], - ], - [ - [a, d], - [b, c], - ], - ]; - let minDiff = Infinity; - splits.forEach(([t1, t2]) => { - const s1 = getSkillScore(t1[0]) + getSkillScore(t1[1]); - const s2 = getSkillScore(t2[0]) + getSkillScore(t2[1]); - minDiff = Math.min(minDiff, Math.abs(s1 - s2)); - }); - return minDiff; -}; - -// 4명 사이 6가지 조합 전체에 대해 과거 함께 경기한 누적 횟수 합산 (중복 페널티) -const repeatPenaltyForQuartet = ( - four: GameMember[], - matchCounts: Map, -) => { - let penalty = 0; - for (let i = 0; i < 4; i++) { - for (let j = i + 1; j < 4; j++) { - const key = getMatchupKey(four[i].id, four[j].id); - penalty += matchCounts.get(key) ?? 0; - } - } - return penalty; -}; - -// 코트 타이머("MM:SS" 경과 표시)를 분 단위로 환산 -const parseElapsedMinutes = (timer?: string) => { - if (!timer) return 0; - const [min, sec] = timer.split(":").map(Number); - if (Number.isNaN(min) || Number.isNaN(sec)) return 0; - return min + sec / 60; -}; - -// 대기/명단 인원 중 4명을 자동으로 뽑아 회원 id 배열을 반환. 매칭 가능한 인원이 없으면 null. -export const autoMatchMembers = async ( - gameBoardId: number, - members: GameMember[], - courts: CourtGroup[], - waitingGroups: WaitingGroup[], -): Promise => { - const queuedIds = new Set(waitingGroups.flatMap(g => g.memberIds)); - const playingCourtOf = new Map(); - courts.forEach(court => { - court.players?.forEach(p => playingCourtOf.set(p.id, court)); - }); - - const availableMembers = members.filter(m => { - if (!m.selectable) return false; - if (queuedIds.has(m.id)) return false; - const court = playingCourtOf.get(m.id); - if (court) return parseElapsedMinutes(court.timer) >= 10; // 운동중이어도 10분 넘었으면 다음 매치 후보로 포함 - return true; // 코트/대기열 어디에도 없으면 포함 - }); - - if (availableMembers.length < 4) return null; - - // 0단계: 혼복/남복/여복 중 어떤 타입으로 매치를 만들지 랜덤 결정 (인원이 부족한 타입은 후보에서 제외) - const menPool = availableMembers.filter(m => m.gender === "MALE"); - const womenPool = availableMembers.filter(m => m.gender === "FEMALE"); - const typeOptions: ("혼복" | "남복" | "여복")[] = ["혼복"]; - if (menPool.length >= 4) typeOptions.push("남복"); - if (womenPool.length >= 4) typeOptions.push("여복"); - const matchType = typeOptions[Math.floor(Math.random() * typeOptions.length)]; - const genderPool = - matchType === "남복" - ? menPool - : matchType === "여복" - ? womenPool - : availableMembers; - - const sortedPool = [...genderPool].sort((a, b) => a.playCount - b.playCount); - const minGames = sortedPool[0].playCount; - - // 1단계: 운동 횟수가 적은 사람 위주로 후보군 구성 (4명이 안 모이면 다음 순번까지 단계적으로 확장) - let tier = 0; - let candidatePool: GameMember[] = []; - while (candidatePool.length < 4 && tier <= 5) { - candidatePool = sortedPool.filter(m => m.playCount <= minGames + tier); - tier++; - } - candidatePool = candidatePool.slice(0, 12); // 성능을 위해 최대 12명까지만 후보로 사용 - - // 후보군 전체 쌍에 대한 과거 대전 횟수를 한 번에 조회 - const duplicateCheck = await getGameDuplicateCheck( - gameBoardId, - candidatePool.map(m => m.id), - ); - const matchCounts = new Map(); - duplicateCheck.pairs.forEach(p => { - matchCounts.set(getMatchupKey(p.memberIdA, p.memberIdB), p.count); - }); - - // 2단계: 후보군 내 모든 4인 조합에 대해 급수 밸런스 + 중복 이력 + 게임수 공정성 점수 계산 - let best: GameMember[] | null = null; - let bestScore = Infinity; - combinations(candidatePool, 4).forEach(four => { - const skillDiff = bestSplitSkillDiff(four); - const repeatPenalty = repeatPenaltyForQuartet(four, matchCounts); - const gamesSum = four.reduce((sum, m) => sum + m.playCount, 0); - const score = skillDiff * 100 + repeatPenalty * 15 + gamesSum; - if (score < bestScore) { - bestScore = score; - best = four; - } - }); - - return best ? (best as GameMember[]).map(m => m.id) : null; -}; From 2f1e82659a3bfbf544f4f945f5d1f2902cc0ee82 Mon Sep 17 00:00:00 2001 From: qowldud Date: Thu, 27 Aug 2026 17:54:14 +0900 Subject: [PATCH 44/52] =?UTF-8?q?ci:=20develop=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=EB=8F=84=20=EB=AF=B8=EB=9F=AC=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=B0=ED=8F=AC=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69a5afa4..3b1c46c4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: git push into another repo to deploy to vercel on: push: - branches: [main] + branches: [main, develop] jobs: build: @@ -25,6 +25,6 @@ jobs: destination-repository-name: Cockple_FE user-email: ${{ secrets.EMAIL }} commit-message: ${{ github.event.commits[0].message }} - target-branch: main + target-branch: ${{ github.ref_name }} - name: Test get variable exported by push-to-another-repository run: echo $DESTINATION_CLONED_DIRECTORY From da30c7e82e1c1267b7d56106387ef5cecf4a46c0 Mon Sep 17 00:00:00 2001 From: qowldud Date: Thu, 27 Aug 2026 22:27:43 +0900 Subject: [PATCH 45/52] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9C=A0=EB=B0=9C=ED=95=98=EB=8A=94=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=84=A0=EC=96=B8=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?(TS6133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameFinishedTab, GameManagerPage에서 사용하지 않는 import·변수를 제거해 noUnusedLocals로 인한 프로덕션 빌드(npm run build) 실패를 해결. Co-Authored-By: Claude Sonnet 5 --- src/pages/group/MyGroupDetail/GameFinishedTab.tsx | 2 +- src/pages/group/MyGroupDetail/GameManagerPage.tsx | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx index e64d5a94..72406026 100644 --- a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx +++ b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef } from "react"; +import React, { useState, useMemo } from "react"; import { useGetCompletedGames } from "../../../api/game/game"; export interface PlayerInfo { diff --git a/src/pages/group/MyGroupDetail/GameManagerPage.tsx b/src/pages/group/MyGroupDetail/GameManagerPage.tsx index e59161c2..f0ebd2b3 100644 --- a/src/pages/group/MyGroupDetail/GameManagerPage.tsx +++ b/src/pages/group/MyGroupDetail/GameManagerPage.tsx @@ -1,25 +1,21 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { PageHeader } from "../../../components/common/system/header/PageHeader"; import { Member, type MemberProps } from "../../../components/common/contentcard/Member"; -import { getExerciseDetail } from "../../../api/exercise/exercises"; import { changeGameHost, useGetGameHostCandidates } from "../../../api/game/game"; -import useUserStore from "../../../store/useUserStore"; import { useQueryClient } from "@tanstack/react-query"; import DismissIcon from "../../../assets/icons/dismiss.svg?react"; import Search from "../../../assets/icons/search.svg?react"; import Grad_GR400_L from "../../../components/common/Btn_Static/Text/Grad_GR400_L"; -import GY800_S from "../../../components/common/Btn_Static/Text/GY800_S"; export const GameManagerPage = () => { const navigate = useNavigate(); const { exerciseId } = useParams<{ exerciseId: string }>(); const exerciseIdNumber = Number(exerciseId); - const { user } = useUserStore(); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); - const { data, isLoading } = useGetGameHostCandidates(exerciseIdNumber); + const { data } = useGetGameHostCandidates(exerciseIdNumber); const totalCount = data?.totalCount || 0; const members: MemberProps[] = data?.participants.map(p => ({ From 7b1c41c23d6e53d5384b03a4c29b9847ac6d803a Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 29 Aug 2026 16:49:47 +0900 Subject: [PATCH 46/52] =?UTF-8?q?feat:=20=EC=BD=94=ED=8A=B8,=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EC=97=B4=EC=97=90=EC=84=9C=20=EA=B8=89=EC=88=98?= =?UTF-8?q?=EC=97=86=EC=9D=8C=20->=20=EC=97=86=EC=9D=8C=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx index 3f08cdda..fc522b5e 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -17,6 +17,10 @@ export const courtDroppableId = (courtId: number) => `court-${courtId}`; export const waitingDraggableId = (waitingGroupId: number) => `waiting-${waitingGroupId}`; +// 대기/코트 뱃지에서는 "급수 없음"을 "없음"으로만 표기한다. +const formatBadgeGroup = (group: string) => + group.replace(/^급수\s*없음$/, "없음"); + export const PlayerBadge = ({ name, group, color }: GamePlayer) => (
( {name} - {group} + + {formatBadgeGroup(group)} +
); From 815659b1dc26d64439d2a89cdb6e2866cead0411 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 29 Aug 2026 16:59:23 +0900 Subject: [PATCH 47/52] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20ui=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 7 +- .../GameBoard/GameBoardTabSkeleton.tsx | 113 ++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 src/pages/group/MyGroupDetail/GameBoard/GameBoardTabSkeleton.tsx diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 06092a9e..191f8912 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -44,6 +44,7 @@ import { type EditedGamePlayer, } from "./GameEditPlayerModal"; import { GameBoardWebView } from "./GameBoardWebView"; +import { GameBoardTabSkeleton } from "./GameBoardTabSkeleton"; import { CourtManageBottomSheet, type CourtManageItem, @@ -445,11 +446,7 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { }; if (isLoading) { - return ( -
- 불러오는 중이에요... -
- ); + return ; } return ( diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTabSkeleton.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTabSkeleton.tsx new file mode 100644 index 00000000..0b7d1389 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTabSkeleton.tsx @@ -0,0 +1,113 @@ +// 게임판(게임 코트 / 대기 / 명단) 로딩 스켈레톤 UI + +const SkeletonBox = ({ className = "" }: { className?: string }) => ( +
+); + +// 코트/대기 카드 안에 들어가는 선수 뱃지 자리 +const PlayerBadgeSkeleton = () => ( + +); + +// 코트 카드 한 장 (경기 중 상태 기준: 헤더 + 선수 4명) +const CourtCardSkeleton = () => ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+); + +// 대기 카드 한 장 +const WaitingCardSkeleton = () => ( +
+
+ +
+ + +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+); + +// 명단 멤버 카드 한 장 +const MemberCardSkeleton = () => ( +
+
+ + +
+
+ + +
+
+ + + +
+
+); + +export const GameBoardTabSkeleton = () => ( +
+ {/* 게임 코트 */} +
+
+ +
+ + +
+
+
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+ + {/* 대기 */} +
+ +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+ + {/* 명단 */} +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+); From ae4ebb18e83513edc17c1b9a06ce95d3c39ce05f Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 29 Aug 2026 17:02:17 +0900 Subject: [PATCH 48/52] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B2=8C=EC=9E=84=ED=8C=90=20pc=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=95=88=EB=B3=B4=EC=9D=B4=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 191f8912..79f33706 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -465,7 +465,7 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { +
+ +
+ + 매칭 실패 + + 매칭에 실패했습니다. 명단을 확인해주세요. + +
+ + +
+
+); From e753500d367d37ee7dad2aae74d67c97f3b5d492 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 29 Aug 2026 21:17:56 +0900 Subject: [PATCH 50/52] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=94=94=EB=B2=84=EA=B7=B8=20=EC=BD=98=EC=86=94=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/chat/rawWs.ts | 9 +++------ src/api/exercise/InviteGuestApi.ts | 2 +- src/api/member/getMyLocation.ts | 2 +- src/api/member/my.ts | 9 ++------- src/api/member/onboarding.ts | 6 ++---- src/api/party/getJoinParty.ts | 2 +- src/api/realtime/connection.ts | 5 ----- src/components/common/contentcard/ContentCardL.tsx | 2 +- src/components/common/system/header/MainHeader.tsx | 2 +- src/components/home/MyExerciseCalendar.tsx | 2 +- src/firebase.ts | 5 ----- src/hooks/useFcmToken.ts | 10 +--------- src/hooks/useRawWsConnect.ts | 9 --------- src/layout/GroupLayout.tsx | 2 +- src/main.tsx | 3 --- src/pages/alarm/AlertPage.tsx | 6 ------ src/pages/group/GroupCalendarPage.tsx | 1 - .../group/MyGroupDetail/GameBoard/gameBoardAdapter.ts | 1 - src/pages/group/groupMaking/GroupMember.tsx | 5 +---- src/pages/home/EditLocationPage.tsx | 2 +- src/pages/login/KakaoLogin.tsx | 3 +-- 21 files changed, 18 insertions(+), 70 deletions(-) diff --git a/src/api/chat/rawWs.ts b/src/api/chat/rawWs.ts index 572f7bb1..d8a2d1f8 100644 --- a/src/api/chat/rawWs.ts +++ b/src/api/chat/rawWs.ts @@ -244,15 +244,13 @@ const sendChat = (action: string, payload: Record) => { export const subscribeChatList = (roomIds: number[]) => { if (!roomIds.length) return; - const ok = sendChat("SUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); - console.log("[WS→] SUBSCRIBE_CHAT_LIST", roomIds, ok ? "OK" : "DEFER"); + sendChat("SUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); }; export const subscribeRoom = (roomId: number) => { if (currentRooms.has(roomId)) return; // 중복 방지 currentRooms.add(roomId); - const ok = sendChat("SUBSCRIBE", { chatRoomId: roomId }); - console.log("[WS→] SUBSCRIBE", roomId, ok ? "OK" : "DEFER"); + sendChat("SUBSCRIBE", { chatRoomId: roomId }); }; export const subscribeMany = (roomIds: number[]) => { @@ -261,8 +259,7 @@ export const subscribeMany = (roomIds: number[]) => { export const unsubscribeChatList = (roomIds: number[]) => { if (!roomIds.length) return; - const ok = sendChat("UNSUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); - console.log("[WS→] UNSUBSCRIBE_CHAT_LIST", roomIds, ok ? "OK" : "DEFER"); + sendChat("UNSUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); }; export const unsubscribeRoom = (roomId: number) => { diff --git a/src/api/exercise/InviteGuestApi.ts b/src/api/exercise/InviteGuestApi.ts index 69a107a8..cc48739c 100644 --- a/src/api/exercise/InviteGuestApi.ts +++ b/src/api/exercise/InviteGuestApi.ts @@ -74,7 +74,7 @@ export const useDeleteInviteForm = (exerciseId: number) => { }); }, onError: err => { - console.log(err); + console.error(err); if (axiosLib.isAxiosError(err)) { if (err.response?.data?.code === "EXERCISE304") { alert(err.response.data.message); diff --git a/src/api/member/getMyLocation.ts b/src/api/member/getMyLocation.ts index d8827228..d6c3f84a 100644 --- a/src/api/member/getMyLocation.ts +++ b/src/api/member/getMyLocation.ts @@ -12,6 +12,6 @@ export const getMyLocation = async () => { const res = await api.get("/api/my/location"); return res.data.data; } catch (err) { - console.log("회원 위치 조회 API 실패:", err); + console.error("회원 위치 조회 API 실패:", err); } }; diff --git a/src/api/member/my.ts b/src/api/member/my.ts index 3ecdd483..26b68e01 100644 --- a/src/api/member/my.ts +++ b/src/api/member/my.ts @@ -106,10 +106,7 @@ export const postMyProfileLocation = async (payload: AddLocationPayload) => { export const deleteAddress = async (memberAddrId: number) => { try { const res = await api.delete(`/api/my/profile/locations/${memberAddrId}`); - if (res.data.success) { - console.log("주소 삭제 성공", res.data.message); - // 삭제 성공 시 상태 갱신이나 UI 업데이트 수행 - } else { + if (!res.data.success) { console.error("주소 삭제 실패", res.data.message); } } catch (err) { @@ -124,9 +121,7 @@ export const setMainAddress = async (memberAddrId: number) => { `/api/my/profile/locations/${memberAddrId}`, {}, ); - if (res.data.success) { - console.log("대표 주소 변경 성공", res.data.message); - } else { + if (!res.data.success) { console.error("대표 주소 변경 실패", res.data.message); } } catch (err) { diff --git a/src/api/member/onboarding.ts b/src/api/member/onboarding.ts index 0808fe1a..39825c85 100644 --- a/src/api/member/onboarding.ts +++ b/src/api/member/onboarding.ts @@ -31,8 +31,7 @@ export const usePostOnboarding = () => { const navigate = useNavigate(); return useMutation({ mutationFn: (body: OnBoardingRequest) => postOnboarding(body), - onSuccess: data => { - console.log(data); + onSuccess: () => { navigate("/onboarding/confirm/start"); }, onError: err => { @@ -63,8 +62,7 @@ export const usePostKeywords = () => { partyId: number; keywords: string[]; }) => postKeywords(keywords, partyId), - onSuccess: (data, v) => { - console.log(data); + onSuccess: (_data, v) => { navigate(`/group/making/member/${v.partyId}`); }, onError: err => { diff --git a/src/api/party/getJoinParty.ts b/src/api/party/getJoinParty.ts index 021af9b8..19f716e8 100644 --- a/src/api/party/getJoinParty.ts +++ b/src/api/party/getJoinParty.ts @@ -5,6 +5,6 @@ export const getJoinParty = async (partyId: number) => { const { data } = await api.post(`/api/parties/${partyId}/join-requests`); return data; } catch (err) { - console.log(err); + console.error(err); } }; diff --git a/src/api/realtime/connection.ts b/src/api/realtime/connection.ts index 15fded8c..e03cac0d 100644 --- a/src/api/realtime/connection.ts +++ b/src/api/realtime/connection.ts @@ -105,7 +105,6 @@ export const connectRealtimeWs = async ({ origin, }: { origin?: string } = {}) => { if (!hasToken()) { - console.info("[Realtime WS] skipped: no accessToken"); return null; } @@ -142,7 +141,6 @@ export const connectRealtimeWs = async ({ sock.onmessage = (e: MessageEvent) => { try { const parsed: ResponseEnvelope = JSON.parse(e.data); - console.log("[Realtime WS←]", parsed.domain, parsed.type, parsed); if (parsed.requestId && pending.has(parsed.requestId)) { const entry = pending.get(parsed.requestId)!; @@ -187,7 +185,6 @@ export const connectRealtimeWs = async ({ connectPromise = null; if (isManualClose) { - console.log("[Realtime WS] Manual disconnect. No reconnect."); return; } @@ -260,7 +257,6 @@ export const sendRealtimeRequest = < timer, }); - console.log("[Realtime WS→]", domain, action, envelope); ws!.send(JSON.stringify(envelope)); }); }; @@ -282,7 +278,6 @@ export const sendRealtimeFireAndForget = ( requestId: genRequestId(), payload, }; - console.log("[Realtime WS→]", domain, action, envelope); ws.send(JSON.stringify(envelope)); return true; }; diff --git a/src/components/common/contentcard/ContentCardL.tsx b/src/components/common/contentcard/ContentCardL.tsx index 8f4cf516..06195108 100644 --- a/src/components/common/contentcard/ContentCardL.tsx +++ b/src/components/common/contentcard/ContentCardL.tsx @@ -138,7 +138,7 @@ export const ContentCardL = ({ await queryClient.invalidateQueries({ queryKey: ["partyCalendar"] }); await queryClient.invalidateQueries({ queryKey: ["partyDetail"] }); } catch (err) { - console.log("운동 신청, 취소 오류: ", err); + console.error("운동 신청, 취소 오류: ", err); setIsStarted(originalIsJoined); } finally { setIsLoading(false); diff --git a/src/components/common/system/header/MainHeader.tsx b/src/components/common/system/header/MainHeader.tsx index 6a41c7b7..ad689f73 100644 --- a/src/components/common/system/header/MainHeader.tsx +++ b/src/components/common/system/header/MainHeader.tsx @@ -37,7 +37,7 @@ export const MainHeader = ({ setHasNotification(data.data.existNewNotification); } catch (err) { - console.log("알람 존재여부 api 오류: ", err); + console.error("알람 존재여부 api 오류: ", err); } }; diff --git a/src/components/home/MyExerciseCalendar.tsx b/src/components/home/MyExerciseCalendar.tsx index 23fff96d..2903b95b 100644 --- a/src/components/home/MyExerciseCalendar.tsx +++ b/src/components/home/MyExerciseCalendar.tsx @@ -195,7 +195,7 @@ export const MyExerciseCalendar = ({ setCount }: MyExerciseCalendarProps) => { setIsInitialLoading(false); } } catch (err) { - console.log(err); + console.error(err); setIsInitialLoading(false); } finally { inFlightRef.current = false; diff --git a/src/firebase.ts b/src/firebase.ts index 84864166..17531714 100644 --- a/src/firebase.ts +++ b/src/firebase.ts @@ -29,17 +29,13 @@ export async function requestFcmToken(): Promise { return null; } - console.log("[FCM] 현재 권한 상태:", Notification.permission); - if (Notification.permission === "denied") { console.warn("[FCM] 알림 권한이 차단(denied) 상태입니다."); return null; } if (Notification.permission === "default") { - console.log("[FCM] 권한 요청 팝업 호출"); const permission = await Notification.requestPermission(); - console.log("[FCM] 권한 요청 결과:", permission); if (permission !== "granted") { return null; } @@ -55,7 +51,6 @@ export async function requestFcmToken(): Promise { }); if (token) { - console.log("[FCM] 토큰 발급 성공"); await sendTokenToServer(token); } else { console.warn("[FCM] 토큰 발급 실패(빈 토큰)"); diff --git a/src/hooks/useFcmToken.ts b/src/hooks/useFcmToken.ts index 05fba94a..c59dc35d 100644 --- a/src/hooks/useFcmToken.ts +++ b/src/hooks/useFcmToken.ts @@ -12,11 +12,7 @@ export function useFcmToken() { return; } - console.log("[FCM] useFcmToken mounted. permission:", Notification.permission); - - const unsubscribe = onMessage(messaging, (payload) => { - console.log("[FCM] 포그라운드 알림 수신:", payload); - }); + const unsubscribe = onMessage(messaging, () => {}); const cleanupUserGestureListeners = () => { document.removeEventListener("click", handleFirstGesture); @@ -25,7 +21,6 @@ export function useFcmToken() { }; const handleFirstGesture = () => { - console.log("[FCM] 사용자 제스처 감지 -> 토큰 등록 시도"); cleanupUserGestureListeners(); registerToken(); }; @@ -38,12 +33,9 @@ export function useFcmToken() { document.addEventListener("click", handleFirstGesture); document.addEventListener("touchstart", handleFirstGesture); document.addEventListener("keydown", handleFirstGesture); - } else { - console.info("[FCM] 알림 권한이 차단되어 있습니다. 브라우저 설정에서 허용이 필요합니다."); } return () => { - console.log("[FCM] useFcmToken cleanup"); cleanupUserGestureListeners(); unsubscribe(); }; diff --git a/src/hooks/useRawWsConnect.ts b/src/hooks/useRawWsConnect.ts index 695900ca..3ad9260e 100644 --- a/src/hooks/useRawWsConnect.ts +++ b/src/hooks/useRawWsConnect.ts @@ -78,15 +78,6 @@ export const useRawWsConnect = (opts: { unreadCount: m.newUnreadCount ?? 0, }); } - - // 해제 ACK 로깅 - if ( - (msg.type === "UNSUBSCRIBE" || msg.type === "SUBSCRIBE") && - "message" in msg && - "chatRoomId" in msg - ) { - console.log(`[WS] ${msg.type} ACK #${msg.chatRoomId}: ${msg.message}`); - } }); return () => { diff --git a/src/layout/GroupLayout.tsx b/src/layout/GroupLayout.tsx index f6d19d31..f7ce4785 100644 --- a/src/layout/GroupLayout.tsx +++ b/src/layout/GroupLayout.tsx @@ -147,7 +147,7 @@ export const GroupLayout = () => { }, }); } catch (err) { - console.log(err); + console.error(err); } }; diff --git a/src/main.tsx b/src/main.tsx index bcdd3fa7..ae52ba30 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,9 +22,6 @@ if ("serviceWorker" in navigator) { .finally(() => { navigator.serviceWorker .register("/firebase-messaging-sw.js") - .then(() => { - console.log("[FCM] firebase-messaging-sw 등록 완료"); - }) .catch((err) => { console.error("Service Worker 등록 실패:", err); }); diff --git a/src/pages/alarm/AlertPage.tsx b/src/pages/alarm/AlertPage.tsx index 235a3eed..2a0eec21 100644 --- a/src/pages/alarm/AlertPage.tsx +++ b/src/pages/alarm/AlertPage.tsx @@ -82,8 +82,6 @@ export const AlertPage = () => { }; const handleDetail = (partyId: number, data?: ResponseAlertDto["data"]) => { - console.log("모임 페이지로 이동", partyId); - if (data?.exerciseDate && data?.exerciseId) { navigate(`/group/${partyId}`, { state: { @@ -141,10 +139,6 @@ export const AlertPage = () => { await api.patch(`/api/parties/invitations/${invitationId}`, { action: "APPROVE", }); - } else if (partyId && !invitationId) { - console.log("모임 있지만 invitationId 없음"); - } else { - console.log("모임도 없고 invitationId도 없음"); } }, onSuccess: () => { diff --git a/src/pages/group/GroupCalendarPage.tsx b/src/pages/group/GroupCalendarPage.tsx index b54cdab4..b8ba8ae7 100644 --- a/src/pages/group/GroupCalendarPage.tsx +++ b/src/pages/group/GroupCalendarPage.tsx @@ -84,7 +84,6 @@ export const GroupCalendarPage = () => { currentCount={exercise.currentParticipants} totalCount={exercise.maxCapacity} like={exercise.isBookmarked} - onToggleFavorite={id => console.log(`Toggled favorite: ${id}`)} />
)) diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts index 76eb47fb..903a9d04 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts @@ -22,7 +22,6 @@ const parseServerDate = (value: string) => new Date(/[Zz]|[+-]\d{2}:\d{2}$/.test(value) ? value : `${value}Z`); export const formatElapsed = (startedAt: string) => { - console.log("[formatElapsed] raw startedAt:", startedAt, "now:", new Date().toISOString()); const ms = Date.now() - parseServerDate(startedAt).getTime(); const totalSec = Math.max(0, Math.floor(ms / 1000)); const mm = String(Math.floor(totalSec / 60)).padStart(2, "0"); diff --git a/src/pages/group/groupMaking/GroupMember.tsx b/src/pages/group/groupMaking/GroupMember.tsx index 4ab4205d..f7b053f7 100644 --- a/src/pages/group/groupMaking/GroupMember.tsx +++ b/src/pages/group/groupMaking/GroupMember.tsx @@ -33,7 +33,7 @@ export const GroupMember = () => { //디바운스 const debouncedSearch = useDebounce(search, 200); - const { data: page, isLoading } = useMemberInfinite({ + const { data: page } = useMemberInfinite({ levelSearch: debouncedSearch, page: 0, size: 10, @@ -89,9 +89,6 @@ export const GroupMember = () => { })), [members], ); - if (!isLoading) { - console.log(page); - } const filteredMembers = useMemo(() => { const filterText = debouncedSearch.trim().toLowerCase(); if (!filterText) return memberList; diff --git a/src/pages/home/EditLocationPage.tsx b/src/pages/home/EditLocationPage.tsx index 10ac9dd5..ae1e0ef3 100644 --- a/src/pages/home/EditLocationPage.tsx +++ b/src/pages/home/EditLocationPage.tsx @@ -48,7 +48,7 @@ export const EditLocationPage = () => { setSelectedId(mainAddr.addrId); } } catch (err) { - console.log("등록된 위치 불러오기 오류: ", err); + console.error("등록된 위치 불러오기 오류: ", err); } }; diff --git a/src/pages/login/KakaoLogin.tsx b/src/pages/login/KakaoLogin.tsx index a27381e0..0874093b 100644 --- a/src/pages/login/KakaoLogin.tsx +++ b/src/pages/login/KakaoLogin.tsx @@ -34,7 +34,6 @@ export default function KakaoLogin() { }; setUser(newUserData); localStorage.setItem("accessToken", data.accessToken); - console.log(data); if (data.isNewMember && data.needsOnboarding) { //onboarding으로 //미사용자 @@ -45,7 +44,7 @@ export default function KakaoLogin() { } }, onError: error => { - console.log(error); + console.error(error); }, }); return
; From deaa200b95b9403cf0d5350553b26214da79da40 Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 29 Aug 2026 21:52:56 +0900 Subject: [PATCH 51/52] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=AA=85=EB=8B=A8=20=EC=84=B1=EB=B3=84=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=20=EB=B0=8F=20=EB=8C=80=EC=A7=84=ED=91=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=95=84=EB=AA=AC=EB=93=9C=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/game/members.ts | 2 +- .../MyGroupDetail/GameBoard/GameBoardTab.tsx | 14 ++++----- .../GameBoard/GameDuplicateCheckModal.tsx | 30 +++++++++++++------ .../GameBoard/gameBoardAdapter.ts | 19 ++++++++++++ 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/api/game/members.ts b/src/api/game/members.ts index 08aedf3d..11017fb6 100644 --- a/src/api/game/members.ts +++ b/src/api/game/members.ts @@ -47,7 +47,7 @@ export const getGameBoardMembers = async ( export interface GameBoardMemberPayload { name: string; - gender: "MALE" | "FEMALE"; + gender: "남성" | "여성"; // 서버는 한글 표시값만 허용 level: string; // 한글 급수 라벨 (예: "D조", "급수없음") ageGroup?: string; // 한글 나이대 라벨 (예: "30대") } diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx index 171fb421..5ce64273 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -55,6 +55,7 @@ import { GameDuplicateCheckModal } from "./GameDuplicateCheckModal"; import { formatElapsed, toBoardViewModel, + toGameBoardMemberPayload, toGameBoardMembersParams, toGameMember, type GameBoardMemberFilters, @@ -197,12 +198,11 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { if (editingMemberId === null) return; const memberId = editingMemberId; try { - await updateGameBoardMember(gameBoardId, memberId, { - name: updated.name, - gender: updated.gender, - level: updated.level, - ageGroup: updated.ageGroup || undefined, - }); + await updateGameBoardMember( + gameBoardId, + memberId, + toGameBoardMemberPayload(updated), + ); setMembers(prev => prev.map(m => m.id === memberId @@ -230,7 +230,7 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { ageGroup: string; }) => { try { - await createGameBoardMember(gameBoardId, player); + await createGameBoardMember(gameBoardId, toGameBoardMemberPayload(player)); refreshMembers(); setIsAddPlayerOpen(false); } catch (err) { diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx index a59ab4d9..19af93a9 100644 --- a/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx +++ b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx @@ -87,8 +87,10 @@ const DiamondMatchup = ({ return (
+ {/* overflow-visible: 외곽 4개 선이 viewBox 경계(0/160)에 걸쳐 있어 기본 clip 시 + stroke의 절반이 잘려 대각선보다 얇게 보이므로, 잘리지 않도록 노출한다. */} @@ -142,21 +144,31 @@ const DiamondMatchup = ({ /> -
- {topLeft.name} + {/* 노드는 아바타 폭(w-10)으로 고정해 그리드 꼭짓점(x=92/252)에 정확히 중앙 정렬한다. + 이름은 whitespace-nowrap으로 양옆 대칭 오버플로우시켜 아바타 위치에 영향을 주지 않게 한다. */} +
+ + {topLeft.name} +
-
+
- {bottomLeft.name} + + {bottomLeft.name} +
-
- {topRight.name} +
+ + {topRight.name} +
-
+
- {bottomRight.name} + + {bottomRight.name} +
= { 여성: "FEMALE", }; +const GENDER_EN_TO_KO: Record<"MALE" | "FEMALE", "남성" | "여성"> = { + MALE: "남성", + FEMALE: "여성", +}; + +// 명단 추가/수정 payload: 서버는 gender를 한글("남성"/"여성")로만 받는다. +export const toGameBoardMemberPayload = (player: { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup?: string; +}): GameBoardMemberPayload => ({ + name: player.name, + gender: GENDER_EN_TO_KO[player.gender], + level: player.level, + ageGroup: player.ageGroup || undefined, +}); + // 노출 조건 (판정 우선순위): 경기중 > 대기열 포함 > 운동 불참 > 뱃지 없음. 한 번에 하나만 노출한다. export const toGameMember = (m: GameBoardMember): GameMember => { const tags: GameMember["tags"] = m.inGame From a67b127862da58ed41eddcb37bb3fd4a269f005e Mon Sep 17 00:00:00 2001 From: qowldud Date: Sat, 29 Aug 2026 22:13:50 +0900 Subject: [PATCH 52/52] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=98=EC=86=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 3 ++ src/api/chat/rawWs.ts | 16 ++++------ src/api/realtime/connection.ts | 20 +++++-------- src/hooks/useGameWs.ts | 8 ++--- src/hooks/useSeedChatUnread.ts | 53 ++++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 28 deletions(-) create mode 100644 src/hooks/useSeedChatUnread.ts diff --git a/src/App.tsx b/src/App.tsx index eec94cb1..4fc44806 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { CreateExercise } from "./pages/group/CreateExercise"; import KakaoLogin from "./pages/login/KakaoLogin"; import OnboardingLayout from "./pages/onboarding/onBoardingLayout"; import { useRawWsConnect } from "./hooks/useRawWsConnect"; +import { useSeedChatUnread } from "./hooks/useSeedChatUnread"; import { resolveMemberId } from "./utils/auth"; import { NoNavbarLayout } from "./layout/NoPtLayout"; @@ -289,6 +290,8 @@ function App() { const memberId = resolveMemberId() ?? 0; useRawWsConnect({ memberId, origin: import.meta.env.VITE_WS_ORIGIN }); + // [임시] 채팅 탭 진입 전에도 안읽음 뱃지가 뜨도록 앱 진입 시 1회 seed + useSeedChatUnread(); useEffect(() => { // 스플래시 화면이 한 번도 표시되지 않은 경우에만 실행 if (!hasShownSplash) { diff --git a/src/api/chat/rawWs.ts b/src/api/chat/rawWs.ts index d8a2d1f8..c512aab4 100644 --- a/src/api/chat/rawWs.ts +++ b/src/api/chat/rawWs.ts @@ -193,8 +193,10 @@ const toIncomingMessage = (env: ResponseEnvelope): IncomingMessage | null => { hasDirectUnread: data.hasDirectUnread as boolean, timestamp, }; + // 목록 구독/해제 ack — 클라이언트가 따로 처리할 게 없어 조용히 무시 + case "SUBSCRIBE_CHAT_LIST": + case "UNSUBSCRIBE_CHAT_LIST": default: - console.warn("[Chat WS] 알 수 없는 CHAT 응답 type:", env.type, env); return null; } }; @@ -236,11 +238,8 @@ export const disconnectRawWs = () => disconnectRealtimeWs(); export const rawWsState = () => realtimeWsState(); // 0/1/2/3 export const isRawWsOpen = () => isRealtimeWsOpen(); -const sendChat = (action: string, payload: Record) => { - const ok = sendRealtimeFireAndForget("CHAT", action, payload); - if (!ok) console.warn("[Chat WS] not open. drop:", action, payload); - return ok; -}; +const sendChat = (action: string, payload: Record) => + sendRealtimeFireAndForget("CHAT", action, payload); export const subscribeChatList = (roomIds: number[]) => { if (!roomIds.length) return; @@ -265,10 +264,7 @@ export const unsubscribeChatList = (roomIds: number[]) => { export const unsubscribeRoom = (roomId: number) => { if (!currentRooms.has(roomId)) return; currentRooms.delete(roomId); - const ok = sendChat("UNSUBSCRIBE", { chatRoomId: roomId }); - if (!ok) { - console.warn("[WS] UNSUBSCRIBE send failed (socket closed)"); - } + sendChat("UNSUBSCRIBE", { chatRoomId: roomId }); }; export const unsubscribeAll = () => { diff --git a/src/api/realtime/connection.ts b/src/api/realtime/connection.ts index e03cac0d..2cf3860c 100644 --- a/src/api/realtime/connection.ts +++ b/src/api/realtime/connection.ts @@ -132,8 +132,8 @@ export const connectRealtimeWs = async ({ openListeners.forEach(fn => { try { fn(); - } catch (err) { - console.warn("realtime ws open listener err", err); + } catch { + // 개별 리스너 오류가 다른 리스너로 전파되지 않도록 무시 } }); }; @@ -158,27 +158,24 @@ export const connectRealtimeWs = async ({ listeners.forEach(fn => { try { fn(parsed); - } catch (err) { - console.warn("realtime ws listener err", err); + } catch { + // 개별 리스너 오류가 다른 리스너로 전파되지 않도록 무시 } }); } catch { - console.warn("[Realtime WS] Non-JSON message:", e.data); + // JSON이 아닌 메시지는 무시 } }; - sock.onerror = (ev: Event) => { - console.warn("[Realtime WS error]", ev); - }; + sock.onerror = () => {}; sock.onclose = (ev: CloseEvent) => { - console.warn("[Realtime WS close]", ev.code, ev.reason); rejectAllPending("REALTIME_WS_CLOSED"); closeListeners.forEach(fn => { try { fn(ev); - } catch (err) { - console.warn("realtime ws close listener err", err); + } catch { + // 개별 리스너 오류가 다른 리스너로 전파되지 않도록 무시 } }); ws = null; @@ -268,7 +265,6 @@ export const sendRealtimeFireAndForget = ( payload: Record, ) => { if (!ws || ws.readyState !== WebSocket.OPEN) { - console.warn("[Realtime WS] not open. drop:", domain, action, payload); return false; } const envelope: RequestEnvelope = { diff --git a/src/hooks/useGameWs.ts b/src/hooks/useGameWs.ts index bccbf21b..a83ca512 100644 --- a/src/hooks/useGameWs.ts +++ b/src/hooks/useGameWs.ts @@ -67,14 +67,10 @@ export const useGameWs = (opts: { gameBoardId?: number; origin?: string } = {}) useEffect(() => { if (!isOpen || !opts.gameBoardId) return; - subscribeGameBoard(opts.gameBoardId).catch(err => - console.warn("[GAME WS] subscribe failed", err), - ); + subscribeGameBoard(opts.gameBoardId).catch(() => {}); return () => { - unsubscribeGameBoard(opts.gameBoardId!).catch(err => - console.warn("[GAME WS] unsubscribe failed", err), - ); + unsubscribeGameBoard(opts.gameBoardId!).catch(() => {}); }; }, [isOpen, opts.gameBoardId]); diff --git a/src/hooks/useSeedChatUnread.ts b/src/hooks/useSeedChatUnread.ts new file mode 100644 index 00000000..fd16a827 --- /dev/null +++ b/src/hooks/useSeedChatUnread.ts @@ -0,0 +1,53 @@ +import { useEffect } from "react"; +import { + getGroupChatRooms, + getPersonalChatRooms, +} from "../api/chat/chatList"; +import { useChatWsStore } from "../store/useChatWsStore"; +import useUserStore from "../store/useUserStore"; +import { resolveMemberId } from "../utils/auth"; + +// [임시 조치] 하단 네비 채팅 안읽음 뱃지는 +// 1) UNREAD_STATUS_UPDATE 웹소켓 push (접속 이후 상태가 "변할 때"만 옴) +// 2) 채팅 탭(ChatPage)에서만 채워지던 useChatWsStore.meta +// 두 가지에만 의존한다. 그래서 채팅 탭을 한 번도 안 들어가면, 앱을 켜기 전에 쌓여 있던 +// 안읽음이 메인 화면 뱃지에 반영되지 않는다 (채팅 탭 진입 후에야 뜸). +// +// 근본 해결은 서버가 소켓 접속/구독 직후 현재 안읽음 상태를 1회 push하는 것. +// 그 전까지의 임시 방편으로, 앱 진입 시(로그인 상태) 채팅방 목록을 한 번 조회해 +// store를 seed 한다. 서버 push가 적용되면 이 훅은 제거 가능. +let seededFor: number | null = null; + +export const useSeedChatUnread = () => { + const hydrateFromAPI = useChatWsStore(s => s.hydrateFromAPI); + const token = + useUserStore(s => s.user?.accessToken) ?? + localStorage.getItem("accessToken") ?? + ""; + const memberId = resolveMemberId() ?? 0; + + useEffect(() => { + if (!token || !memberId || seededFor === memberId) return; + seededFor = memberId; + + (async () => { + try { + const [groupRes, personalRes] = await Promise.all([ + getGroupChatRooms(), + getPersonalChatRooms(), + ]); + hydrateFromAPI( + [...groupRes.content, ...personalRes.content].map(r => ({ + chatRoomId: r.chatRoomId, + lastMessage: r.lastMessage?.content ?? null, + timestamp: r.lastMessage?.timestamp ?? null, + unreadCount: r.unreadCount ?? 0, + })), + ); + } catch (err) { + seededFor = null; // 실패 시 다음 기회에 재시도 + console.error("[unread] 초기 안읽음 상태 seed 실패", err); + } + })(); + }, [token, memberId, hydrateFromAPI]); +};