From 8ccf18e8b1e39e37b5b88ecaf4249138d71d965f Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 31 Mar 2026 18:03:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(concert):=20=ED=9A=8C=EC=B0=A8=EB=B3=84=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프론트엔드: 단일 setlist → 회차별 setlists (탭 UI로 전환) - 회차 추가 시 이전 회차의 세트리스트 자동 복사 - '다른 회차에서 복사' 기능 추가 - 백엔드: 각 concert_id별로 독립적인 세트리스트 저장 - 하위호환: 기존 setlist 필드도 지원 (단일 배열 → 첫 회차에 적용) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/routes/admin/concert.js | 42 +- .../schedules/form/concert/SetlistSection.jsx | 709 ++++++++++-------- .../pc/admin/schedules/form/concert/index.jsx | 70 +- 3 files changed, 464 insertions(+), 357 deletions(-) diff --git a/backend/src/routes/admin/concert.js b/backend/src/routes/admin/concert.js index afcc22e..f72dabe 100644 --- a/backend/src/routes/admin/concert.js +++ b/backend/src/routes/admin/concert.js @@ -26,7 +26,7 @@ export default async function concertRoutes(fastify) { let title = ''; let memberIds = []; let rounds = []; - let setlist = []; + let setlists = []; // 회차별 세트리스트 (배열의 배열) let posterBuffer = null; const merchandiseBuffers = []; @@ -43,7 +43,8 @@ export default async function concertRoutes(fastify) { if (part.fieldname === 'title') title = part.value; else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value); else if (part.fieldname === 'rounds') rounds = JSON.parse(part.value); - else if (part.fieldname === 'setlist') setlist = JSON.parse(part.value); + else if (part.fieldname === 'setlists') setlists = JSON.parse(part.value); + else if (part.fieldname === 'setlist') setlists = [JSON.parse(part.value)]; // 하위호환 } } @@ -125,26 +126,29 @@ export default async function concertRoutes(fastify) { } } - // 4. 세트리스트 (첫 번째 concert_id 기준으로 저장) - const primaryConcertId = concertIds[0]; + // 4. 회차별 세트리스트 저장 + for (let roundIdx = 0; roundIdx < concertIds.length; roundIdx++) { + const concertId = concertIds[roundIdx]; + const roundSetlist = setlists[roundIdx] || setlists[0] || []; - for (let i = 0; i < setlist.length; i++) { - const song = setlist[i]; - if (!song.songName || !song.songName.trim()) continue; + for (let i = 0; i < roundSetlist.length; i++) { + const song = roundSetlist[i]; + if (!song.songName || !song.songName.trim()) continue; - const [setlistResult] = await conn.query( - 'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)', - [primaryConcertId, i + 1, song.songName.trim(), song.albumName?.trim() || null] - ); - const setlistId = setlistResult.insertId; - - // 곡별 멤버 - if (song.memberIds && song.memberIds.length > 0) { - const memberValues = song.memberIds.map(memberId => [setlistId, memberId]); - await conn.query( - 'INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?', - [memberValues] + const [setlistResult] = await conn.query( + 'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)', + [concertId, i + 1, song.songName.trim(), song.albumName?.trim() || null] ); + const setlistId = setlistResult.insertId; + + // 곡별 멤버 + if (song.memberIds && song.memberIds.length > 0) { + const memberValues = song.memberIds.map(memberId => [setlistId, memberId]); + await conn.query( + 'INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?', + [memberValues] + ); + } } } diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/SetlistSection.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/SetlistSection.jsx index a4cd5c5..32be8bf 100644 --- a/frontend/src/pages/pc/admin/schedules/form/concert/SetlistSection.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/concert/SetlistSection.jsx @@ -1,323 +1,386 @@ -import { useState, useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Plus, Trash2, Users, Search } from "lucide-react"; - -import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog"; -import SongSearchDialog from "./SongSearchDialog"; - -/** - * 세트리스트 섹션 - * - 곡 추가/삭제 - * - 곡명, 앨범명, 참여 멤버 - * - 순서 자동 부여 - */ -function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, albums }) { - const containerRef = useRef(null); - const [nextId, setNextId] = useState(() => { - const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0); - return maxId + 1; - }); - - // 삭제 확인 다이얼로그 - const [deleteConfirm, setDeleteConfirm] = useState({ - isOpen: false, - songId: null, - songName: null, - }); - - // 곡 검색 다이얼로그 - const [songSearchOpen, setSongSearchOpen] = useState(false); - - // 직접 입력 곡 추가 - const addSong = () => { - const newSong = { - id: nextId, - songName: "", - albumName: "", - memberIds: [...selectedMemberIds], - }; - setSetlist((prev) => [...prev, newSong]); - setNextId(nextId + 1); - - setTimeout(() => { - if (containerRef.current) { - const lastChild = containerRef.current.lastElementChild; - if (lastChild) { - lastChild.scrollIntoView({ behavior: "smooth", block: "center" }); - } - } - }, 100); - }; - - // 검색에서 선택한 곡 추가 - const addSongsFromSearch = (songs) => { - let id = nextId; - const newSongs = songs.map((song) => ({ - id: id++, - songName: song.songName, - albumName: song.albumName, - memberIds: [...selectedMemberIds], - })); - setSetlist((prev) => [...prev, ...newSongs]); - setNextId(id); - - setTimeout(() => { - if (containerRef.current) { - const lastChild = containerRef.current.lastElementChild; - if (lastChild) { - lastChild.scrollIntoView({ behavior: "smooth", block: "center" }); - } - } - }, 100); - }; - - // 곡 삭제 시도 - const handleRemoveSong = (id) => { - if (setlist.length <= 1) return; - - const song = setlist.find((s) => s.id === id); - - if (song && (song.songName || song.albumName)) { - setDeleteConfirm({ - isOpen: true, - songId: id, - songName: song.songName || "제목 없음", - }); - } else { - removeSong(id); - } - }; - - // 곡 삭제 실행 - const removeSong = (id) => { - setSetlist((prev) => prev.filter((s) => s.id !== id)); - }; - - // 삭제 확인 - const handleConfirmDelete = () => { - if (deleteConfirm.songId !== null) { - removeSong(deleteConfirm.songId); - } - setDeleteConfirm({ isOpen: false, songId: null, songName: null }); - }; - - // 곡 필드 업데이트 - const updateSong = (id, field, value) => { - setSetlist((prev) => - prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)) - ); - }; - - // 곡별 멤버 토글 - const toggleSongMember = (songId, memberId) => { - setSetlist((prev) => - prev.map((s) => { - if (s.id !== songId) return s; - const has = s.memberIds.includes(memberId); - return { - ...s, - memberIds: has - ? s.memberIds.filter((id) => id !== memberId) - : [...s.memberIds, memberId], - }; - }) - ); - }; - - // 곡별 멤버 전체 선택/해제 - const toggleAllSongMembers = (songId) => { - setSetlist((prev) => - prev.map((s) => { - if (s.id !== songId) return s; - const allSelected = members.every((m) => s.memberIds.includes(m.id)); - return { - ...s, - memberIds: allSelected ? [] : members.map((m) => m.id), - }; - }) - ); - }; - - return ( - <> - - setDeleteConfirm({ isOpen: false, songId: null, songName: null }) - } - onConfirm={handleConfirmDelete} - title="곡 삭제" - message={ -

- {deleteConfirm.songName} - 을(를) 삭제하시겠습니까? -

- } - confirmText="삭제" - cancelText="취소" - /> - - setSongSearchOpen(false)} - onSelect={addSongsFromSearch} - albums={albums} - /> - -
-

세트리스트

- -
- - {setlist.map((song, index) => ( - -
- {/* 헤더 */} -
- - {index + 1}번 곡 - - {setlist.length > 1 && ( - - )} -
- - {/* 곡명 & 앨범명 */} -
-
- - - updateSong(song.id, "songName", e.target.value) - } - placeholder="예: Feel Good" - className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - /> -
-
- - - updateSong(song.id, "albumName", e.target.value) - } - placeholder="예: Unlock My World" - className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - /> -
-
- - {/* 참여 멤버 */} -
- -
- {/* 전체 선택 버튼 */} - - - {members.map((member) => { - const isSelected = song.memberIds.includes(member.id); - return ( - - ); - })} -
-
-
-
- ))} -
-
- -
- - -
- -

- 곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 - 개별 조정하세요. -

-
- - ); -} - -export default SetlistSection; +import { useState, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Plus, Trash2, Users, Search, Copy } from "lucide-react"; + +import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog"; +import SongSearchDialog from "./SongSearchDialog"; + +/** + * 세트리스트 섹션 (회차별 탭) + * - 회차별로 독립적인 세트리스트 + * - 다른 회차에서 복사 기능 + */ +function SetlistSection({ rounds, setlists, setSetlists, members, selectedMemberIds, albums }) { + const containerRef = useRef(null); + const [activeRoundId, setActiveRoundId] = useState(rounds[0]?.id || 1); + + // 현재 활성 회차의 세트리스트 + const setlist = setlists[activeRoundId] || []; + + // 활성 회차가 삭제되면 첫 번째 회차로 전환 + useEffect(() => { + if (!rounds.find((r) => r.id === activeRoundId) && rounds.length > 0) { + setActiveRoundId(rounds[0].id); + } + }, [rounds, activeRoundId]); + + // 다음 ID 계산 + const getNextId = () => { + return Object.values(setlists).flat().reduce((max, s) => Math.max(max, s.id || 0), 0) + 1; + }; + + // 현재 회차의 세트리스트 업데이트 + const updateCurrentSetlist = (updater) => { + setSetlists((prev) => ({ + ...prev, + [activeRoundId]: typeof updater === 'function' ? updater(prev[activeRoundId] || []) : updater, + })); + }; + + // 삭제 확인 다이얼로그 + const [deleteConfirm, setDeleteConfirm] = useState({ + isOpen: false, + songId: null, + songName: null, + }); + + // 곡 검색 다이얼로그 + const [songSearchOpen, setSongSearchOpen] = useState(false); + + // 복사 소스 선택 + const [copyFrom, setCopyFrom] = useState(null); + + // 직접 입력 곡 추가 + const addSong = () => { + const newSong = { + id: getNextId(), + songName: "", + albumName: "", + memberIds: [...selectedMemberIds], + }; + updateCurrentSetlist((prev) => [...prev, newSong]); + + setTimeout(() => { + if (containerRef.current) { + const lastChild = containerRef.current.lastElementChild; + if (lastChild) { + lastChild.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + }, 100); + }; + + // 검색에서 선택한 곡 추가 + const addSongsFromSearch = (songs) => { + let id = getNextId(); + const newSongs = songs.map((song) => ({ + id: id++, + songName: song.songName, + albumName: song.albumName, + memberIds: [...selectedMemberIds], + })); + updateCurrentSetlist((prev) => [...prev, ...newSongs]); + + setTimeout(() => { + if (containerRef.current) { + const lastChild = containerRef.current.lastElementChild; + if (lastChild) { + lastChild.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + }, 100); + }; + + // 다른 회차에서 복사 + const copyFromRound = (sourceRoundId) => { + const source = setlists[sourceRoundId] || []; + let id = getNextId(); + const copied = source.map((s) => ({ + ...s, + id: id++, + memberIds: [...s.memberIds], + })); + updateCurrentSetlist(copied); + setCopyFrom(null); + }; + + // 곡 삭제 시도 + const handleRemoveSong = (id) => { + if (setlist.length <= 1) return; + const song = setlist.find((s) => s.id === id); + if (song && (song.songName || song.albumName)) { + setDeleteConfirm({ isOpen: true, songId: id, songName: song.songName || "제목 없음" }); + } else { + removeSong(id); + } + }; + + // 곡 삭제 실행 + const removeSong = (id) => { + updateCurrentSetlist((prev) => prev.filter((s) => s.id !== id)); + }; + + // 삭제 확인 + const handleConfirmDelete = () => { + if (deleteConfirm.songId !== null) { + removeSong(deleteConfirm.songId); + } + setDeleteConfirm({ isOpen: false, songId: null, songName: null }); + }; + + // 곡 필드 업데이트 + const updateSong = (id, field, value) => { + updateCurrentSetlist((prev) => + prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)) + ); + }; + + // 곡별 멤버 토글 + const toggleSongMember = (songId, memberId) => { + updateCurrentSetlist((prev) => + prev.map((s) => { + if (s.id !== songId) return s; + const has = s.memberIds.includes(memberId); + return { + ...s, + memberIds: has + ? s.memberIds.filter((id) => id !== memberId) + : [...s.memberIds, memberId], + }; + }) + ); + }; + + // 곡별 멤버 전체 선택/해제 + const toggleAllSongMembers = (songId) => { + updateCurrentSetlist((prev) => + prev.map((s) => { + if (s.id !== songId) return s; + const allSelected = members.every((m) => s.memberIds.includes(m.id)); + return { + ...s, + memberIds: allSelected ? [] : members.map((m) => m.id), + }; + }) + ); + }; + + // 현재 활성 회차의 인덱스 + const activeRoundIndex = rounds.findIndex((r) => r.id === activeRoundId); + + return ( + <> + setDeleteConfirm({ isOpen: false, songId: null, songName: null })} + onConfirm={handleConfirmDelete} + title="곡 삭제" + message={ +

+ {deleteConfirm.songName} + 을(를) 삭제하시겠습니까? +

+ } + confirmText="삭제" + cancelText="취소" + /> + + setSongSearchOpen(false)} + onSelect={addSongsFromSearch} + albums={albums} + /> + +
+
+

세트리스트

+ {/* 다른 회차에서 복사 */} + {rounds.length > 1 && ( +
+ + {copyFrom && ( +
+ {rounds + .filter((r) => r.id !== activeRoundId) + .map((r, i) => { + const roundIdx = rounds.indexOf(r); + return ( + + ); + })} +
+ )} +
+ )} +
+ + {/* 회차 탭 (2개 이상일 때만) */} + {rounds.length > 1 && ( +
+ {rounds.map((round, index) => ( + + ))} +
+ )} + +
+ + {setlist.map((song, index) => ( + +
+ {/* 헤더 */} +
+ + {index + 1}번 곡 + + {setlist.length > 1 && ( + + )} +
+ + {/* 곡명 & 앨범명 */} +
+
+ + updateSong(song.id, "songName", e.target.value)} + placeholder="예: Feel Good" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+
+ + updateSong(song.id, "albumName", e.target.value)} + placeholder="예: Unlock My World" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+
+ + {/* 참여 멤버 */} +
+ +
+ + {members.map((member) => { + const isSelected = song.memberIds.includes(member.id); + return ( + + ); + })} +
+
+
+
+ ))} +
+
+ +
+ + +
+ +

+ {rounds.length > 1 + ? "회차별로 세트리스트를 다르게 입력할 수 있습니다. '다른 회차에서 복사'로 빠르게 시작하세요." + : "곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 개별 조정하세요."} +

+
+ + ); +} + +export default SetlistSection; diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx index 06fbf41..d82b360 100644 --- a/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx @@ -48,14 +48,49 @@ function ConcertForm() { const [selectedMemberIds, setSelectedMemberIds] = useState([]); // 공연 일정 (다회차) - const [rounds, setRounds] = useState([ + const [rounds, setRoundsRaw] = useState([ { id: 1, date: "", time: "", venue: null }, ]); - // 세트리스트 - const [setlist, setSetlist] = useState([ - { id: 1, songName: "", albumName: "", memberIds: [] }, - ]); + // 회차 변경 시 세트리스트 동기화 + const setRounds = (updater) => { + setRoundsRaw((prev) => { + const newRounds = typeof updater === 'function' ? updater(prev) : updater; + + setSetlists((prevSetlists) => { + const updated = { ...prevSetlists }; + // 새로 추가된 회차에 세트리스트 복사 + for (const round of newRounds) { + if (!updated[round.id]) { + // 마지막 회차의 세트리스트를 복사 (deep copy) + const lastRound = prev[prev.length - 1]; + const source = prevSetlists[lastRound?.id] || [{ id: 1, songName: "", albumName: "", memberIds: [] }]; + let maxId = Object.values(updated).flat().reduce((max, s) => Math.max(max, s.id || 0), 0); + updated[round.id] = source.map((s) => ({ + ...s, + id: ++maxId, + memberIds: [...s.memberIds], + })); + } + } + // 삭제된 회차의 세트리스트 제거 + const roundIds = new Set(newRounds.map((r) => r.id)); + for (const key of Object.keys(updated)) { + if (!roundIds.has(Number(key))) { + delete updated[key]; + } + } + return updated; + }); + + return newRounds; + }); + }; + + // 회차별 세트리스트 (key: round id) + const [setlists, setSetlists] = useState({ + 1: [{ id: 1, songName: "", albumName: "", memberIds: [] }], + }); // 굿즈 이미지 const [merchandiseItems, setMerchandiseItems] = useState([]); @@ -140,14 +175,18 @@ function ConcertForm() { })); formData.append("rounds", JSON.stringify(roundsData)); - // 세트리스트 - const validSetlist = setlist.filter((s) => s.songName?.trim()); - const setlistData = validSetlist.map((s) => ({ - songName: s.songName.trim(), - albumName: s.albumName?.trim() || null, - memberIds: s.memberIds || [], - })); - formData.append("setlist", JSON.stringify(setlistData)); + // 회차별 세트리스트 (rounds 순서에 맞춰 배열로 전송) + const setlistsData = validRounds.map((r) => { + const roundSetlist = setlists[r.id] || []; + return roundSetlist + .filter((s) => s.songName?.trim()) + .map((s) => ({ + songName: s.songName.trim(), + albumName: s.albumName?.trim() || null, + memberIds: s.memberIds || [], + })); + }); + formData.append("setlists", JSON.stringify(setlistsData)); // 굿즈 이미지 merchandiseItems.forEach((item) => { @@ -203,8 +242,9 @@ function ConcertForm() { {/* 세트리스트 */}