diff --git a/frontend/src/components/pc/admin/common/ConfirmDialog.jsx b/frontend/src/components/pc/admin/common/ConfirmDialog.jsx index 1b4627a..06fff6c 100644 --- a/frontend/src/components/pc/admin/common/ConfirmDialog.jsx +++ b/frontend/src/components/pc/admin/common/ConfirmDialog.jsx @@ -14,6 +14,7 @@ * - loadingText: 로딩 중 텍스트 (기본: "삭제 중...") * - variant: 버튼 색상 (기본: "danger", "primary" 가능) */ +import { createPortal } from 'react-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { AlertTriangle, Trash2 } from 'lucide-react'; @@ -46,7 +47,7 @@ function ConfirmDialog({ primary: 'text-primary', }; - return ( + return createPortal( {isOpen && ( )} - + , + document.body ); } diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx index d487912..9fa2d2b 100644 --- a/frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/concert/ConcertInfoSection.jsx @@ -31,7 +31,7 @@ function ConcertInfoSection({ return (
-

콘서트 정보

+

공연 정보

{/* 공연명 */} diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx index 25ff031..efa1fa1 100644 --- a/frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/concert/ScheduleSection.jsx @@ -142,17 +142,7 @@ function ScheduleSection({ rounds, setRounds }) { />
-
-

공연 일정

- -
+

공연 일정

@@ -254,6 +244,15 @@ function ScheduleSection({ rounds, setRounds }) {
+ +

시간과 장소는 선택사항입니다. 미정인 경우 비워두세요.

diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/SetlistSection.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/SetlistSection.jsx new file mode 100644 index 0000000..a4cd5c5 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/form/concert/SetlistSection.jsx @@ -0,0 +1,323 @@ +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; diff --git a/frontend/src/pages/pc/admin/schedules/form/concert/SongSearchDialog.jsx b/frontend/src/pages/pc/admin/schedules/form/concert/SongSearchDialog.jsx new file mode 100644 index 0000000..c1dd048 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/form/concert/SongSearchDialog.jsx @@ -0,0 +1,251 @@ +import { useState, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { X, Search, Music, Check, Disc3 } from "lucide-react"; + +/** + * 곡 검색 다이얼로그 + * - 앨범 목록에서 곡을 검색/선택 + * - 다중 선택 지원 + * + * @param {Object} props + * @param {boolean} props.isOpen + * @param {Function} props.onClose + * @param {Function} props.onSelect - 선택된 곡 배열 반환 [{ songName, albumName }] + * @param {Array} props.albums - getAlbums() 결과 + */ +function SongSearchDialog({ isOpen, onClose, onSelect, albums }) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTracks, setSelectedTracks] = useState([]); + + // 전체 트랙 목록 (앨범 정보 포함) + const allTracks = useMemo(() => { + if (!albums || albums.length === 0) return []; + return albums.flatMap((album) => + (album.tracks || []).map((track) => ({ + id: `${album.id}-${track.id}`, + songName: track.title, + albumName: album.title, + albumCover: album.cover_thumb_url, + isTitleTrack: track.is_title_track, + trackNumber: track.track_number, + })) + ); + }, [albums]); + + // 검색 필터링 + const filteredTracks = useMemo(() => { + if (!searchQuery.trim()) return allTracks; + const query = searchQuery.toLowerCase(); + return allTracks.filter( + (track) => + track.songName.toLowerCase().includes(query) || + track.albumName.toLowerCase().includes(query) + ); + }, [allTracks, searchQuery]); + + // 앨범별 그룹핑 + const groupedTracks = useMemo(() => { + const groups = {}; + filteredTracks.forEach((track) => { + if (!groups[track.albumName]) { + groups[track.albumName] = { + albumName: track.albumName, + albumCover: track.albumCover, + tracks: [], + }; + } + groups[track.albumName].tracks.push(track); + }); + return Object.values(groups); + }, [filteredTracks]); + + // 트랙 선택 토글 + const toggleTrack = (track) => { + setSelectedTracks((prev) => { + const exists = prev.find((t) => t.id === track.id); + if (exists) { + return prev.filter((t) => t.id !== track.id); + } + return [...prev, track]; + }); + }; + + const isSelected = (trackId) => selectedTracks.some((t) => t.id === trackId); + + // 확인 + const handleConfirm = () => { + onSelect( + selectedTracks.map((t) => ({ + songName: t.songName, + albumName: t.albumName, + })) + ); + handleClose(); + }; + + // 닫기 + const handleClose = () => { + setSearchQuery(""); + setSelectedTracks([]); + onClose(); + }; + + return createPortal( + + {isOpen && ( + + e.stopPropagation()} + > + {/* 헤더 */} +
+

곡 검색

+ +
+ + {/* 검색 입력 */} +
+ + setSearchQuery(e.target.value)} + placeholder="곡명 또는 앨범명으로 검색" + className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + autoFocus + /> +
+ + {/* 선택 카운트 */} + {selectedTracks.length > 0 && ( +
+ {selectedTracks.length}곡 선택됨 +
+ )} + + {/* 결과 목록 */} +
+ {groupedTracks.length > 0 ? ( +
+ {groupedTracks.map((group) => ( +
+ {/* 앨범 헤더 */} +
+ {group.albumCover ? ( + {group.albumName} + ) : ( +
+ +
+ )} + + {group.albumName} + +
+ + {/* 트랙 목록 */} +
+ {group.tracks.map((track) => ( + + ))} +
+
+ ))} +
+ ) : ( +
+ +

+ {searchQuery + ? "검색 결과가 없습니다" + : "등록된 곡이 없습니다"} +

+
+ )} +
+ + {/* 하단 버튼 */} +
+ + +
+
+
+ )} +
, + document.body + ); +} + +export default SongSearchDialog; 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 2fd196b..1a9ff86 100644 --- a/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/concert/index.jsx @@ -8,9 +8,11 @@ import Toast from "@/components/common/Toast"; import { useToast } from "@/hooks/common"; import { useAdminAuth } from "@/hooks/pc/admin"; import { getMembers } from "@/api/public/members"; +import { getAlbums } from "@/api/public/albums"; import ConcertInfoSection from "./ConcertInfoSection"; import ScheduleSection from "./ScheduleSection"; +import SetlistSection from "./SetlistSection"; import MerchandiseSection from "./MerchandiseSection"; /** @@ -30,6 +32,14 @@ function ConcertForm() { }); const members = membersData.filter((m) => !m.is_former); + // 앨범 목록 조회 (곡 검색용) + const { data: albumsData = [] } = useQuery({ + queryKey: ["albums"], + queryFn: getAlbums, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + // 콘서트 정보 const [title, setTitle] = useState(""); const [posterFile, setPosterFile] = useState(null); @@ -41,6 +51,11 @@ function ConcertForm() { { id: 1, date: "", time: "", venue: null }, ]); + // 세트리스트 + const [setlist, setSetlist] = useState([ + { id: 1, songName: "", albumName: "", memberIds: [] }, + ]); + // 굿즈 이미지 const [merchandiseItems, setMerchandiseItems] = useState([]); @@ -123,6 +138,15 @@ function ConcertForm() { setItems={setMerchandiseItems} /> + {/* 세트리스트 */} + + {/* 버튼 */}