feat(concert): 회차별 세트리스트 입력 지원

- 프론트엔드: 단일 setlist → 회차별 setlists (탭 UI로 전환)
- 회차 추가 시 이전 회차의 세트리스트 자동 복사
- '다른 회차에서 복사' 기능 추가
- 백엔드: 각 concert_id별로 독립적인 세트리스트 저장
- 하위호환: 기존 setlist 필드도 지원 (단일 배열 → 첫 회차에 적용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-31 18:03:08 +09:00
parent 812478bc37
commit 8ccf18e8b1
3 changed files with 464 additions and 357 deletions

View file

@ -26,7 +26,7 @@ export default async function concertRoutes(fastify) {
let title = ''; let title = '';
let memberIds = []; let memberIds = [];
let rounds = []; let rounds = [];
let setlist = []; let setlists = []; // 회차별 세트리스트 (배열의 배열)
let posterBuffer = null; let posterBuffer = null;
const merchandiseBuffers = []; const merchandiseBuffers = [];
@ -43,7 +43,8 @@ export default async function concertRoutes(fastify) {
if (part.fieldname === 'title') title = part.value; if (part.fieldname === 'title') title = part.value;
else if (part.fieldname === 'memberIds') memberIds = JSON.parse(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 === '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 기준으로 저장) // 4. 회차별 세트리스트 저장
const primaryConcertId = concertIds[0]; 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++) { for (let i = 0; i < roundSetlist.length; i++) {
const song = setlist[i]; const song = roundSetlist[i];
if (!song.songName || !song.songName.trim()) continue; if (!song.songName || !song.songName.trim()) continue;
const [setlistResult] = await conn.query( const [setlistResult] = await conn.query(
'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)', 'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)',
[primaryConcertId, i + 1, song.songName.trim(), song.albumName?.trim() || null] [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]
); );
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]
);
}
} }
} }

View file

@ -1,323 +1,386 @@
import { useState, useRef } from "react"; import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Plus, Trash2, Users, Search } from "lucide-react"; import { Plus, Trash2, Users, Search, Copy } from "lucide-react";
import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog"; import ConfirmDialog from "@/components/pc/admin/common/ConfirmDialog";
import SongSearchDialog from "./SongSearchDialog"; import SongSearchDialog from "./SongSearchDialog";
/** /**
* 세트리스트 섹션 * 세트리스트 섹션 (회차별 )
* - 추가/삭제 * - 회차별로 독립적인 세트리스트
* - 곡명, 앨범명, 참여 멤버 * - 다른 회차에서 복사 기능
* - 순서 자동 부여 */
*/ function SetlistSection({ rounds, setlists, setSetlists, members, selectedMemberIds, albums }) {
function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, albums }) { const containerRef = useRef(null);
const containerRef = useRef(null); const [activeRoundId, setActiveRoundId] = useState(rounds[0]?.id || 1);
const [nextId, setNextId] = useState(() => {
const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0); //
return maxId + 1; const setlist = setlists[activeRoundId] || [];
});
//
// useEffect(() => {
const [deleteConfirm, setDeleteConfirm] = useState({ if (!rounds.find((r) => r.id === activeRoundId) && rounds.length > 0) {
isOpen: false, setActiveRoundId(rounds[0].id);
songId: null, }
songName: null, }, [rounds, activeRoundId]);
});
// ID
// const getNextId = () => {
const [songSearchOpen, setSongSearchOpen] = useState(false); return Object.values(setlists).flat().reduce((max, s) => Math.max(max, s.id || 0), 0) + 1;
};
//
const addSong = () => { //
const newSong = { const updateCurrentSetlist = (updater) => {
id: nextId, setSetlists((prev) => ({
songName: "", ...prev,
albumName: "", [activeRoundId]: typeof updater === 'function' ? updater(prev[activeRoundId] || []) : updater,
memberIds: [...selectedMemberIds], }));
}; };
setSetlist((prev) => [...prev, newSong]);
setNextId(nextId + 1); //
const [deleteConfirm, setDeleteConfirm] = useState({
setTimeout(() => { isOpen: false,
if (containerRef.current) { songId: null,
const lastChild = containerRef.current.lastElementChild; songName: null,
if (lastChild) { });
lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
} //
} const [songSearchOpen, setSongSearchOpen] = useState(false);
}, 100);
}; //
const [copyFrom, setCopyFrom] = useState(null);
//
const addSongsFromSearch = (songs) => { //
let id = nextId; const addSong = () => {
const newSongs = songs.map((song) => ({ const newSong = {
id: id++, id: getNextId(),
songName: song.songName, songName: "",
albumName: song.albumName, albumName: "",
memberIds: [...selectedMemberIds], memberIds: [...selectedMemberIds],
})); };
setSetlist((prev) => [...prev, ...newSongs]); updateCurrentSetlist((prev) => [...prev, newSong]);
setNextId(id);
setTimeout(() => {
setTimeout(() => { if (containerRef.current) {
if (containerRef.current) { const lastChild = containerRef.current.lastElementChild;
const lastChild = containerRef.current.lastElementChild; if (lastChild) {
if (lastChild) { lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
lastChild.scrollIntoView({ behavior: "smooth", block: "center" }); }
} }
} }, 100);
}, 100); };
};
//
// const addSongsFromSearch = (songs) => {
const handleRemoveSong = (id) => { let id = getNextId();
if (setlist.length <= 1) return; const newSongs = songs.map((song) => ({
id: id++,
const song = setlist.find((s) => s.id === id); songName: song.songName,
albumName: song.albumName,
if (song && (song.songName || song.albumName)) { memberIds: [...selectedMemberIds],
setDeleteConfirm({ }));
isOpen: true, updateCurrentSetlist((prev) => [...prev, ...newSongs]);
songId: id,
songName: song.songName || "제목 없음", setTimeout(() => {
}); if (containerRef.current) {
} else { const lastChild = containerRef.current.lastElementChild;
removeSong(id); if (lastChild) {
} lastChild.scrollIntoView({ behavior: "smooth", block: "center" });
}; }
}
// }, 100);
const removeSong = (id) => { };
setSetlist((prev) => prev.filter((s) => s.id !== id));
}; //
const copyFromRound = (sourceRoundId) => {
// const source = setlists[sourceRoundId] || [];
const handleConfirmDelete = () => { let id = getNextId();
if (deleteConfirm.songId !== null) { const copied = source.map((s) => ({
removeSong(deleteConfirm.songId); ...s,
} id: id++,
setDeleteConfirm({ isOpen: false, songId: null, songName: null }); memberIds: [...s.memberIds],
}; }));
updateCurrentSetlist(copied);
// setCopyFrom(null);
const updateSong = (id, field, value) => { };
setSetlist((prev) =>
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)) //
); const handleRemoveSong = (id) => {
}; if (setlist.length <= 1) return;
const song = setlist.find((s) => s.id === id);
// if (song && (song.songName || song.albumName)) {
const toggleSongMember = (songId, memberId) => { setDeleteConfirm({ isOpen: true, songId: id, songName: song.songName || "제목 없음" });
setSetlist((prev) => } else {
prev.map((s) => { removeSong(id);
if (s.id !== songId) return s; }
const has = s.memberIds.includes(memberId); };
return {
...s, //
memberIds: has const removeSong = (id) => {
? s.memberIds.filter((id) => id !== memberId) updateCurrentSetlist((prev) => prev.filter((s) => s.id !== id));
: [...s.memberIds, memberId], };
};
}) //
); const handleConfirmDelete = () => {
}; if (deleteConfirm.songId !== null) {
removeSong(deleteConfirm.songId);
// / }
const toggleAllSongMembers = (songId) => { setDeleteConfirm({ isOpen: false, songId: null, songName: null });
setSetlist((prev) => };
prev.map((s) => {
if (s.id !== songId) return s; //
const allSelected = members.every((m) => s.memberIds.includes(m.id)); const updateSong = (id, field, value) => {
return { updateCurrentSetlist((prev) =>
...s, prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
memberIds: allSelected ? [] : members.map((m) => m.id), );
}; };
})
); //
}; const toggleSongMember = (songId, memberId) => {
updateCurrentSetlist((prev) =>
return ( prev.map((s) => {
<> if (s.id !== songId) return s;
<ConfirmDialog const has = s.memberIds.includes(memberId);
isOpen={deleteConfirm.isOpen} return {
onClose={() => ...s,
setDeleteConfirm({ isOpen: false, songId: null, songName: null }) memberIds: has
} ? s.memberIds.filter((id) => id !== memberId)
onConfirm={handleConfirmDelete} : [...s.memberIds, memberId],
title="곡 삭제" };
message={ })
<p> );
<span className="font-medium">{deleteConfirm.songName}</span> };
() 삭제하시겠습니까?
</p> // /
} const toggleAllSongMembers = (songId) => {
confirmText="삭제" updateCurrentSetlist((prev) =>
cancelText="취소" prev.map((s) => {
/> if (s.id !== songId) return s;
const allSelected = members.every((m) => s.memberIds.includes(m.id));
<SongSearchDialog return {
isOpen={songSearchOpen} ...s,
onClose={() => setSongSearchOpen(false)} memberIds: allSelected ? [] : members.map((m) => m.id),
onSelect={addSongsFromSearch} };
albums={albums} })
/> );
};
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6">세트리스트</h2> //
const activeRoundIndex = rounds.findIndex((r) => r.id === activeRoundId);
<div ref={containerRef} className="flex flex-col gap-4">
<AnimatePresence initial={false}> return (
{setlist.map((song, index) => ( <>
<motion.div <ConfirmDialog
key={song.id} isOpen={deleteConfirm.isOpen}
initial={{ opacity: 0, scale: 0.98, y: -8 }} onClose={() => setDeleteConfirm({ isOpen: false, songId: null, songName: null })}
animate={{ opacity: 1, scale: 1, y: 0 }} onConfirm={handleConfirmDelete}
exit={{ opacity: 0, scale: 0.98, y: -8 }} title="곡 삭제"
transition={{ duration: 0.15, ease: "easeOut" }} message={
> <p>
<div className="p-4 bg-gray-50 rounded-xl space-y-3"> <span className="font-medium">{deleteConfirm.songName}</span>
{/* 헤더 */} () 삭제하시겠습니까?
<div className="flex items-center justify-between"> </p>
<span className="text-sm font-medium text-gray-700"> }
{index + 1} confirmText="삭제"
</span> cancelText="취소"
{setlist.length > 1 && ( />
<button
type="button" <SongSearchDialog
onClick={() => handleRemoveSong(song.id)} isOpen={songSearchOpen}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" onClose={() => setSongSearchOpen(false)}
> onSelect={addSongsFromSearch}
<Trash2 size={16} /> albums={albums}
</button> />
)}
</div> <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
{/* 곡명 & 앨범명 */} <h2 className="text-lg font-bold text-gray-900">세트리스트</h2>
<div className="grid grid-cols-2 gap-3"> {/* 다른 회차에서 복사 */}
<div> {rounds.length > 1 && (
<label className="block text-xs text-gray-500 mb-1"> <div className="relative">
곡명 * <button
</label> type="button"
<input onClick={() => setCopyFrom(copyFrom ? null : true)}
type="text" className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
value={song.songName} >
onChange={(e) => <Copy size={12} />
updateSong(song.id, "songName", e.target.value) 다른 회차에서 복사
} </button>
placeholder="예: Feel Good" {copyFrom && (
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" <div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10 py-1 min-w-[120px]">
/> {rounds
</div> .filter((r) => r.id !== activeRoundId)
<div> .map((r, i) => {
<label className="block text-xs text-gray-500 mb-1"> const roundIdx = rounds.indexOf(r);
앨범명 (선택) return (
</label> <button
<input key={r.id}
type="text" type="button"
value={song.albumName} onClick={() => copyFromRound(r.id)}
onChange={(e) => className="w-full px-4 py-2 text-sm text-left hover:bg-gray-50 transition-colors"
updateSong(song.id, "albumName", e.target.value) >
} {roundIdx + 1}회차
placeholder="예: Unlock My World" </button>
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" );
/> })}
</div> </div>
</div> )}
</div>
{/* 참여 멤버 */} )}
<div> </div>
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<Users size={14} /> {/* 회차 탭 (2개 이상일 때만) */}
참여 멤버 {rounds.length > 1 && (
</label> <div className="flex gap-1 mb-4 p-1 bg-gray-100 rounded-lg">
<div className="flex flex-wrap gap-2"> {rounds.map((round, index) => (
{/* 전체 선택 버튼 */} <button
<button key={round.id}
type="button" type="button"
onClick={() => toggleAllSongMembers(song.id)} onClick={() => setActiveRoundId(round.id)}
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${ className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
members.every((m) => activeRoundId === round.id
song.memberIds.includes(m.id) ? "bg-white text-primary shadow-sm"
) : "text-gray-500 hover:text-gray-700"
? "border-primary bg-primary text-white" }`}
: "border-gray-200 text-gray-500 hover:border-gray-300" >
}`} {index + 1}회차
> </button>
{members.every((m) => ))}
song.memberIds.includes(m.id) </div>
) )}
? "전체 해제"
: "전체 선택"} <div ref={containerRef} className="flex flex-col gap-4">
</button> <AnimatePresence initial={false}>
{setlist.map((song, index) => (
{members.map((member) => { <motion.div
const isSelected = song.memberIds.includes(member.id); key={song.id}
return ( initial={{ opacity: 0, scale: 0.98, y: -8 }}
<button animate={{ opacity: 1, scale: 1, y: 0 }}
key={member.id} exit={{ opacity: 0, scale: 0.98, y: -8 }}
type="button" transition={{ duration: 0.15, ease: "easeOut" }}
onClick={() => >
toggleSongMember(song.id, member.id) <div className="p-4 bg-gray-50 rounded-xl space-y-3">
} {/* 헤더 */}
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${ <div className="flex items-center justify-between">
isSelected <span className="text-sm font-medium text-gray-700">
? "border-primary" {index + 1}
: "border-gray-200" </span>
}`} {setlist.length > 1 && (
> <button
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0"> type="button"
{member.image_url ? ( onClick={() => handleRemoveSong(song.id)}
<img className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
src={member.image_url} >
alt={member.name} <Trash2 size={16} />
className="w-full h-full object-cover" </button>
/> )}
) : ( </div>
<div className="w-full h-full bg-gray-300" />
)} {/* 곡명 & 앨범명 */}
</div> <div className="grid grid-cols-2 gap-3">
<span className="text-sm text-gray-700"> <div>
{member.name} <label className="block text-xs text-gray-500 mb-1">
</span> 곡명 *
</button> </label>
); <input
})} type="text"
</div> value={song.songName}
</div> onChange={(e) => updateSong(song.id, "songName", e.target.value)}
</div> placeholder="예: Feel Good"
</motion.div> 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"
))} />
</AnimatePresence> </div>
</div> <div>
<label className="block text-xs text-gray-500 mb-1">
<div className="flex gap-2 mt-4"> 앨범명 (선택)
<button </label>
type="button" <input
onClick={() => setSongSearchOpen(true)} type="text"
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors" value={song.albumName}
> onChange={(e) => updateSong(song.id, "albumName", e.target.value)}
<Search size={14} /> 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"
</button> />
<button </div>
type="button" </div>
onClick={addSong}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-gray-100 rounded-lg text-sm text-gray-600 hover:bg-gray-200 transition-colors" {/* 참여 멤버 */}
> <div>
<Plus size={14} /> <label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
직접 입력 <Users size={14} />
</button> 참여 멤버
</div> </label>
<div className="flex flex-wrap gap-2">
<p className="text-xs text-gray-400 mt-3"> <button
추가 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 type="button"
개별 조정하세요. onClick={() => toggleAllSongMembers(song.id)}
</p> className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
</div> members.every((m) => song.memberIds.includes(m.id))
</> ? "border-primary bg-primary text-white"
); : "border-gray-200 text-gray-500 hover:border-gray-300"
} }`}
>
export default SetlistSection; {members.every((m) => song.memberIds.includes(m.id))
? "전체 해제"
: "전체 선택"}
</button>
{members.map((member) => {
const isSelected = song.memberIds.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleSongMember(song.id, member.id)}
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
isSelected ? "border-primary" : "border-gray-200"
}`}
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
{member.image_url ? (
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-300" />
)}
</div>
<span className="text-sm text-gray-700">{member.name}</span>
</button>
);
})}
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<div className="flex gap-2 mt-4">
<button
type="button"
onClick={() => setSongSearchOpen(true)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-primary/10 rounded-lg text-sm text-primary hover:bg-primary/20 transition-colors"
>
<Search size={14} />
검색
</button>
<button
type="button"
onClick={addSong}
className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-gray-100 rounded-lg text-sm text-gray-600 hover:bg-gray-200 transition-colors"
>
<Plus size={14} />
직접 입력
</button>
</div>
<p className="text-xs text-gray-400 mt-3">
{rounds.length > 1
? "회차별로 세트리스트를 다르게 입력할 수 있습니다. '다른 회차에서 복사'로 빠르게 시작하세요."
: "곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 개별 조정하세요."}
</p>
</div>
</>
);
}
export default SetlistSection;

View file

@ -48,14 +48,49 @@ function ConcertForm() {
const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedMemberIds, setSelectedMemberIds] = useState([]);
// () // ()
const [rounds, setRounds] = useState([ const [rounds, setRoundsRaw] = useState([
{ id: 1, date: "", time: "", venue: null }, { id: 1, date: "", time: "", venue: null },
]); ]);
// //
const [setlist, setSetlist] = useState([ const setRounds = (updater) => {
{ id: 1, songName: "", albumName: "", memberIds: [] }, 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([]); const [merchandiseItems, setMerchandiseItems] = useState([]);
@ -140,14 +175,18 @@ function ConcertForm() {
})); }));
formData.append("rounds", JSON.stringify(roundsData)); formData.append("rounds", JSON.stringify(roundsData));
// // (rounds )
const validSetlist = setlist.filter((s) => s.songName?.trim()); const setlistsData = validRounds.map((r) => {
const setlistData = validSetlist.map((s) => ({ const roundSetlist = setlists[r.id] || [];
songName: s.songName.trim(), return roundSetlist
albumName: s.albumName?.trim() || null, .filter((s) => s.songName?.trim())
memberIds: s.memberIds || [], .map((s) => ({
})); songName: s.songName.trim(),
formData.append("setlist", JSON.stringify(setlistData)); albumName: s.albumName?.trim() || null,
memberIds: s.memberIds || [],
}));
});
formData.append("setlists", JSON.stringify(setlistsData));
// 굿 // 굿
merchandiseItems.forEach((item) => { merchandiseItems.forEach((item) => {
@ -203,8 +242,9 @@ function ConcertForm() {
{/* 세트리스트 */} {/* 세트리스트 */}
<SetlistSection <SetlistSection
setlist={setlist} rounds={rounds}
setSetlist={setSetlist} setlists={setlists}
setSetlists={setSetlists}
members={members} members={members}
selectedMemberIds={selectedMemberIds} selectedMemberIds={selectedMemberIds}
albums={albumsData} albums={albumsData}