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:
parent
812478bc37
commit
8ccf18e8b1
3 changed files with 464 additions and 357 deletions
|
|
@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,41 @@
|
||||||
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({ setlist, setSetlist, members, selectedMemberIds, albums }) {
|
function SetlistSection({ rounds, setlists, setSetlists, members, selectedMemberIds, albums }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [nextId, setNextId] = useState(() => {
|
const [activeRoundId, setActiveRoundId] = useState(rounds[0]?.id || 1);
|
||||||
const maxId = setlist.reduce((max, s) => Math.max(max, s.id || 0), 0);
|
|
||||||
return maxId + 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({
|
const [deleteConfirm, setDeleteConfirm] = useState({
|
||||||
|
|
@ -28,16 +47,18 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
// 곡 검색 다이얼로그
|
// 곡 검색 다이얼로그
|
||||||
const [songSearchOpen, setSongSearchOpen] = useState(false);
|
const [songSearchOpen, setSongSearchOpen] = useState(false);
|
||||||
|
|
||||||
|
// 복사 소스 선택
|
||||||
|
const [copyFrom, setCopyFrom] = useState(null);
|
||||||
|
|
||||||
// 직접 입력 곡 추가
|
// 직접 입력 곡 추가
|
||||||
const addSong = () => {
|
const addSong = () => {
|
||||||
const newSong = {
|
const newSong = {
|
||||||
id: nextId,
|
id: getNextId(),
|
||||||
songName: "",
|
songName: "",
|
||||||
albumName: "",
|
albumName: "",
|
||||||
memberIds: [...selectedMemberIds],
|
memberIds: [...selectedMemberIds],
|
||||||
};
|
};
|
||||||
setSetlist((prev) => [...prev, newSong]);
|
updateCurrentSetlist((prev) => [...prev, newSong]);
|
||||||
setNextId(nextId + 1);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
|
|
@ -51,15 +72,14 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
|
|
||||||
// 검색에서 선택한 곡 추가
|
// 검색에서 선택한 곡 추가
|
||||||
const addSongsFromSearch = (songs) => {
|
const addSongsFromSearch = (songs) => {
|
||||||
let id = nextId;
|
let id = getNextId();
|
||||||
const newSongs = songs.map((song) => ({
|
const newSongs = songs.map((song) => ({
|
||||||
id: id++,
|
id: id++,
|
||||||
songName: song.songName,
|
songName: song.songName,
|
||||||
albumName: song.albumName,
|
albumName: song.albumName,
|
||||||
memberIds: [...selectedMemberIds],
|
memberIds: [...selectedMemberIds],
|
||||||
}));
|
}));
|
||||||
setSetlist((prev) => [...prev, ...newSongs]);
|
updateCurrentSetlist((prev) => [...prev, ...newSongs]);
|
||||||
setNextId(id);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
|
|
@ -71,18 +91,25 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
}, 100);
|
}, 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) => {
|
const handleRemoveSong = (id) => {
|
||||||
if (setlist.length <= 1) return;
|
if (setlist.length <= 1) return;
|
||||||
|
|
||||||
const song = setlist.find((s) => s.id === id);
|
const song = setlist.find((s) => s.id === id);
|
||||||
|
|
||||||
if (song && (song.songName || song.albumName)) {
|
if (song && (song.songName || song.albumName)) {
|
||||||
setDeleteConfirm({
|
setDeleteConfirm({ isOpen: true, songId: id, songName: song.songName || "제목 없음" });
|
||||||
isOpen: true,
|
|
||||||
songId: id,
|
|
||||||
songName: song.songName || "제목 없음",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
removeSong(id);
|
removeSong(id);
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +117,7 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
|
|
||||||
// 곡 삭제 실행
|
// 곡 삭제 실행
|
||||||
const removeSong = (id) => {
|
const removeSong = (id) => {
|
||||||
setSetlist((prev) => prev.filter((s) => s.id !== id));
|
updateCurrentSetlist((prev) => prev.filter((s) => s.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 삭제 확인
|
// 삭제 확인
|
||||||
|
|
@ -103,14 +130,14 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
|
|
||||||
// 곡 필드 업데이트
|
// 곡 필드 업데이트
|
||||||
const updateSong = (id, field, value) => {
|
const updateSong = (id, field, value) => {
|
||||||
setSetlist((prev) =>
|
updateCurrentSetlist((prev) =>
|
||||||
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 곡별 멤버 토글
|
// 곡별 멤버 토글
|
||||||
const toggleSongMember = (songId, memberId) => {
|
const toggleSongMember = (songId, memberId) => {
|
||||||
setSetlist((prev) =>
|
updateCurrentSetlist((prev) =>
|
||||||
prev.map((s) => {
|
prev.map((s) => {
|
||||||
if (s.id !== songId) return s;
|
if (s.id !== songId) return s;
|
||||||
const has = s.memberIds.includes(memberId);
|
const has = s.memberIds.includes(memberId);
|
||||||
|
|
@ -126,7 +153,7 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
|
|
||||||
// 곡별 멤버 전체 선택/해제
|
// 곡별 멤버 전체 선택/해제
|
||||||
const toggleAllSongMembers = (songId) => {
|
const toggleAllSongMembers = (songId) => {
|
||||||
setSetlist((prev) =>
|
updateCurrentSetlist((prev) =>
|
||||||
prev.map((s) => {
|
prev.map((s) => {
|
||||||
if (s.id !== songId) return s;
|
if (s.id !== songId) return s;
|
||||||
const allSelected = members.every((m) => s.memberIds.includes(m.id));
|
const allSelected = members.every((m) => s.memberIds.includes(m.id));
|
||||||
|
|
@ -138,13 +165,14 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 현재 활성 회차의 인덱스
|
||||||
|
const activeRoundIndex = rounds.findIndex((r) => r.id === activeRoundId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
onClose={() =>
|
onClose={() => setDeleteConfirm({ isOpen: false, songId: null, songName: null })}
|
||||||
setDeleteConfirm({ isOpen: false, songId: null, songName: null })
|
|
||||||
}
|
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
title="곡 삭제"
|
title="곡 삭제"
|
||||||
message={
|
message={
|
||||||
|
|
@ -165,7 +193,61 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
<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>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">세트리스트</h2>
|
||||||
|
{/* 다른 회차에서 복사 */}
|
||||||
|
{rounds.length > 1 && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCopyFrom(copyFrom ? null : true)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
다른 회차에서 복사
|
||||||
|
</button>
|
||||||
|
{copyFrom && (
|
||||||
|
<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
|
||||||
|
.filter((r) => r.id !== activeRoundId)
|
||||||
|
.map((r, i) => {
|
||||||
|
const roundIdx = rounds.indexOf(r);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyFromRound(r.id)}
|
||||||
|
className="w-full px-4 py-2 text-sm text-left hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{roundIdx + 1}회차
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회차 탭 (2개 이상일 때만) */}
|
||||||
|
{rounds.length > 1 && (
|
||||||
|
<div className="flex gap-1 mb-4 p-1 bg-gray-100 rounded-lg">
|
||||||
|
{rounds.map((round, index) => (
|
||||||
|
<button
|
||||||
|
key={round.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveRoundId(round.id)}
|
||||||
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
activeRoundId === round.id
|
||||||
|
? "bg-white text-primary shadow-sm"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}회차
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={containerRef} className="flex flex-col gap-4">
|
<div ref={containerRef} className="flex flex-col gap-4">
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
|
|
@ -203,9 +285,7 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={song.songName}
|
value={song.songName}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateSong(song.id, "songName", e.target.value)}
|
||||||
updateSong(song.id, "songName", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="예: Feel Good"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -217,9 +297,7 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={song.albumName}
|
value={song.albumName}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateSong(song.id, "albumName", e.target.value)}
|
||||||
updateSong(song.id, "albumName", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="예: Unlock My World"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -233,54 +311,38 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
참여 멤버
|
참여 멤버
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{/* 전체 선택 버튼 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleAllSongMembers(song.id)}
|
onClick={() => toggleAllSongMembers(song.id)}
|
||||||
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
||||||
members.every((m) =>
|
members.every((m) => song.memberIds.includes(m.id))
|
||||||
song.memberIds.includes(m.id)
|
|
||||||
)
|
|
||||||
? "border-primary bg-primary text-white"
|
? "border-primary bg-primary text-white"
|
||||||
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{members.every((m) =>
|
{members.every((m) => song.memberIds.includes(m.id))
|
||||||
song.memberIds.includes(m.id)
|
|
||||||
)
|
|
||||||
? "전체 해제"
|
? "전체 해제"
|
||||||
: "전체 선택"}
|
: "전체 선택"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
const isSelected = song.memberIds.includes(member.id);
|
const isSelected = song.memberIds.includes(member.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={member.id}
|
key={member.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => toggleSongMember(song.id, member.id)}
|
||||||
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 ${
|
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
||||||
isSelected
|
isSelected ? "border-primary" : "border-gray-200"
|
||||||
? "border-primary"
|
|
||||||
: "border-gray-200"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
||||||
{member.image_url ? (
|
{member.image_url ? (
|
||||||
<img
|
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
|
||||||
src={member.image_url}
|
|
||||||
alt={member.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-gray-300" />
|
<div className="w-full h-full bg-gray-300" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">{member.name}</span>
|
||||||
{member.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -312,8 +374,9 @@ function SetlistSection({ setlist, setSetlist, members, selectedMemberIds, album
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 mt-3">
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은
|
{rounds.length > 1
|
||||||
개별 조정하세요.
|
? "회차별로 세트리스트를 다르게 입력할 수 있습니다. '다른 회차에서 복사'로 빠르게 시작하세요."
|
||||||
|
: "곡 추가 시 콘서트 참여 멤버가 자동으로 선택됩니다. 솔로/유닛 곡은 개별 조정하세요."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue