diff --git a/README.md b/README.md index ef9bafb..c6f857f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ | GET | `/api/performances/{id}` | 공연 단건 조회 | 불필요 | ✓ | | GET | `/api/performances/search?type=title&keyword=` | 공연명 검색 | 불필요 | ✓ | | GET | `/api/performances/search?type=genre&keyword=` | 장르 검색 | 불필요 | - | +| GET | `/api/performances/{id}/lineup` | 공연 라인업(출연 뮤지션) 조회 | 불필요 | ✓ | | GET | `/api/performances/musician/{id}` | 특정 뮤지션의 공연 목록 | 불필요 | ✓ | | POST | `/api/performances` | 공연 이력 추가 | 필요 (MUSICIAN) | ✓ | | POST | `/api/admin/performances` | 공연 등록 (라인업 포함 가능) | 필요 (ADMIN) | ✓ | diff --git a/backend/src/main/java/com/dazz/again/domain/performance/PerformanceController.java b/backend/src/main/java/com/dazz/again/domain/performance/PerformanceController.java index f1aaeac..9f9a771 100644 --- a/backend/src/main/java/com/dazz/again/domain/performance/PerformanceController.java +++ b/backend/src/main/java/com/dazz/again/domain/performance/PerformanceController.java @@ -86,6 +86,21 @@ public ResponseEntity findById( } } + @Operation(summary = "공연 라인업 조회", description = "특정 공연에 출연하는 뮤지션 목록을 반환합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @ApiResponse(responseCode = "404", description = "해당 id의 공연 없음") + @GetMapping("/{id}/lineup") + public ResponseEntity findLineup( + @Parameter(description = "공연 id", example = "1") + @PathVariable Long id + ) { + try { + return ResponseEntity.ok(performanceService.findLineup(id)); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); // 존재하지 않는 공연 id면 404 반환 + } + } + @Operation(summary = "뮤지션별 공연 목록 조회", description = "특정 뮤지션이 출연한 공연 목록을 공연시작시간 오름차순으로 반환합니다.") @ApiResponse(responseCode = "200", description = "조회 성공") @GetMapping("/musician/{musicianId}") diff --git a/backend/src/main/java/com/dazz/again/domain/performance/PerformanceLineupRepository.java b/backend/src/main/java/com/dazz/again/domain/performance/PerformanceLineupRepository.java index 917e7e0..eef457d 100644 --- a/backend/src/main/java/com/dazz/again/domain/performance/PerformanceLineupRepository.java +++ b/backend/src/main/java/com/dazz/again/domain/performance/PerformanceLineupRepository.java @@ -2,6 +2,7 @@ package com.dazz.again.domain.performance; import com.dazz.again.domain.musician.GraphEdgeResponse; +import com.dazz.again.domain.musician.Musician; // 라인업 조회 시 뮤지션 엔티티 반환용 import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -44,6 +45,11 @@ ORDER BY COUNT(pl.performance) DESC """) List findCoPerformers(@Param("musicianId") Long musicianId); + // 특정 공연에 출연하는 뮤지션 목록 조회 (공연 수정 폼에서 현재 라인업 표시용) + // pl.musician만 SELECT — 라인업 행이 아니라 그 안의 Musician 엔티티를 바로 반환 + @Query("SELECT pl.musician FROM PerformanceLineup pl WHERE pl.performance.id = :performanceId") + List findMusiciansByPerformanceId(@Param("performanceId") Long performanceId); + // 특정 공연의 라인업을 전부 삭제하는 메서드 (공연 수정 시 라인업 교체용) // // 메서드 이름 규칙(쿼리 메서드)으로 Spring Data JPA가 쿼리를 자동 생성함: diff --git a/backend/src/main/java/com/dazz/again/domain/performance/PerformanceService.java b/backend/src/main/java/com/dazz/again/domain/performance/PerformanceService.java index 4724782..9c6f3f9 100644 --- a/backend/src/main/java/com/dazz/again/domain/performance/PerformanceService.java +++ b/backend/src/main/java/com/dazz/again/domain/performance/PerformanceService.java @@ -51,6 +51,15 @@ public List findByMusicianId(Long musicianId) { return performanceRepository.findByMusicianId(musicianId); } + // 특정 공연의 라인업(출연 뮤지션 목록) 조회 + public List findLineup(Long performanceId) { + // 공연 존재 여부 먼저 확인 — 없는 공연 id면 예외를 던져 Controller가 404로 처리 + if (!performanceRepository.existsById(performanceId)) { + throw new NoSuchElementException("존재하지 않는 공연입니다. id=" + performanceId); + } + return performanceLineupRepository.findMusiciansByPerformanceId(performanceId); + } + // MUSICIAN 전용 — 본인 공연 이력 추가 // 1) userId로 내 뮤지션 프로필 조회 → 2) 공연 저장 → 3) 라인업에 본인 등록 @Transactional // DB에 저장하므로 읽기 전용 해제 diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 9694ea0..50d9535 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -69,6 +69,9 @@ export const api = { // musicianId: 특정 뮤지션이 출연한 공연 목록 조회 getMusicianPerformances: (musicianId) => request(`/api/performances/musician/${musicianId}`), + // id: 공연 번호 — 해당 공연에 출연하는 뮤지션(라인업) 목록 조회 (수정 폼에서 현재 라인업 표시용) + getPerformanceLineup: (id) => request(`/api/performances/${id}/lineup`), + // MUSICIAN 역할만 호출 가능 — 본인이 출연한 공연을 직접 추가 // body: { venueId, startTime, title, genre, setInfo, setList, sourceUrl } 형태 createPerformance: (body) => request('/api/performances', { method: 'POST', body: JSON.stringify(body) }), @@ -102,7 +105,8 @@ export const api = { updateVenue: (id, body) => request(`/api/admin/venues/${id}`, { method: 'PUT', body: JSON.stringify(body) }), // ── 어드민 - 공연 등록/수정 ────────────────────────────────────────────── - // body: { venueId, startTime, title, genre, setInfo, setList, cancelled, sourceUrl } + // body: { venueId, startTime, title, genre, setInfo, setList, cancelled, sourceUrl, musicianIds } + // musicianIds: 출연 뮤지션 id 배열 (선택) — 보내면 라인업 등록/교체, 아예 안 보내면(생략) 라인업 유지 createAdminPerformance: (body) => request('/api/admin/performances', { method: 'POST', body: JSON.stringify(body) }), // id: 수정할 공연 번호 diff --git a/frontend/src/pages/admin/ScreenAdminConcerts.jsx b/frontend/src/pages/admin/ScreenAdminConcerts.jsx index 9b3517d..eb898a1 100644 --- a/frontend/src/pages/admin/ScreenAdminConcerts.jsx +++ b/frontend/src/pages/admin/ScreenAdminConcerts.jsx @@ -16,8 +16,8 @@ function isUpcoming(p) { return new Date(p.startTime) > new Date(); } -// 빈 등록 폼 초기값 -const EMPTY_FORM = { venueId: '', date: '', time: '20:00', title: '', genre: '', setInfo: '', sourceUrl: '' }; +// 빈 등록 폼 초기값 — musicianIds: 선택한 출연 뮤지션 id 배열 (라인업) +const EMPTY_FORM = { venueId: '', date: '', time: '20:00', title: '', genre: '', setInfo: '', sourceUrl: '', musicianIds: [] }; export default function ScreenAdminConcerts({ navigate, onToast }) { const [performances, setPerformances] = useState([]); @@ -27,25 +27,71 @@ export default function ScreenAdminConcerts({ navigate, onToast }) { // venues: 공연장 드롭다운에 표시할 목록 const [venues, setVenues] = useState([]); - // adding: true이면 등록 폼 행 표시 + // musicians: 라인업 드롭다운에 표시할 전체 뮤지션 목록 + const [musicians, setMusicians] = useState([]); + + // adding: true이면 등록/수정 폼 행 표시 const [adding, setAdding] = useState(false); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); + // editing: 수정 모드일 때 수정 대상 공연 객체 (null이면 신규 등록 모드) + // 폼에 없는 필드(setList, cancelled)를 수정 요청에 그대로 보존하기 위해 객체 통째로 저장 + const [editing, setEditing] = useState(null); + // query: 공연명 검색어 (실시간 필터링) const [query, setQuery] = useState(''); useEffect(() => { - // 공연 목록과 공연장 목록을 병렬로 로드 - Promise.all([api.getPerformances(), api.getVenues()]) - .then(([perfs, vs]) => { setPerformances(perfs); setVenues(vs); }) + // 공연 목록·공연장 목록·뮤지션 목록을 병렬로 로드 + Promise.all([api.getPerformances(), api.getVenues(), api.getMusicians()]) + .then(([perfs, vs, ms]) => { setPerformances(perfs); setVenues(vs); setMusicians(ms); }) .catch(() => onToast && onToast('목록 조회 실패', 'ink')) .finally(() => setLoading(false)); }, []); const setField = (k, v) => setForm((p) => ({ ...p, [k]: v })); - // 공연 등록 — POST /api/admin/performances + // 라인업에 뮤지션 추가 — 드롭다운에서 선택 시 호출 (중복 선택 방지) + const addMusician = (id) => { + if (!id) return; + const numId = Number(id); + setForm((p) => p.musicianIds.includes(numId) ? p : { ...p, musicianIds: [...p.musicianIds, numId] }); + }; + + // 라인업에서 뮤지션 제거 — 선택된 칩의 x 버튼 클릭 시 호출 + const removeMusician = (id) => { + setForm((p) => ({ ...p, musicianIds: p.musicianIds.filter((mid) => mid !== id) })); + }; + + // 수정 모드 시작 — 편집 버튼 클릭 시 기존 공연 정보로 폼을 채우고 폼을 엶 + const startEdit = (p) => { + setEditing(p); + setForm({ + venueId: String(p.venue?.id || ''), // select의 value는 문자열이므로 변환 + date: p.startTime ? p.startTime.slice(0, 10) : '', // "2026-05-18T20:00:00" → "2026-05-18" + time: p.startTime ? p.startTime.slice(11, 16) : '20:00', // → "20:00" + title: p.title || '', + genre: p.genre || '', + setInfo: p.setInfo || '', + sourceUrl: p.sourceUrl || '', + musicianIds: [], // 일단 빈 배열로 폼을 열고, 아래에서 현재 라인업을 불러와 채움 + }); + setAdding(true); + // 현재 라인업 조회 — GET /api/performances/{id}/lineup + api.getPerformanceLineup(p.id) + .then((ms) => setForm((f) => ({ ...f, musicianIds: ms.map((m) => m.id) }))) + .catch(() => onToast && onToast('라인업 조회 실패', 'ink')); + }; + + // 폼 닫기 — 등록/수정 모드 공통 (취소 버튼, 저장 성공 시 사용) + const closeForm = () => { + setAdding(false); + setEditing(null); + setForm(EMPTY_FORM); + }; + + // 공연 저장 — editing이 있으면 수정(PUT), 없으면 신규 등록(POST) const save = () => { if (!form.venueId || !form.date || !form.title) return; setSaving(true); @@ -55,15 +101,30 @@ export default function ScreenAdminConcerts({ navigate, onToast }) { title: form.title, genre: form.genre || null, setInfo: form.setInfo || null, - setList: null, + setList: editing ? (editing.setList || null) : null, // 폼에 없는 필드 — 수정 시 기존 값 보존 sourceUrl: form.sourceUrl || null, - cancelled: false, + cancelled: editing ? editing.cancelled : false, // 수정 시 기존 취소 상태 유지 + musicianIds: form.musicianIds, // 출연 뮤지션 id 배열 — 등록 시 라인업 저장, 수정 시 이 목록으로 교체 }; - api.createAdminPerformance(body) // POST /api/admin/performances + + // 수정 모드: PUT /api/admin/performances/{id} + if (editing) { + api.updateAdminPerformance(editing.id, body) + .then((updated) => { + setPerformances((prev) => prev.map((x) => x.id === updated.id ? updated : x)); // 목록에서 해당 공연만 교체 + closeForm(); + onToast && onToast('공연이 수정됐습니다'); + }) + .catch(() => onToast && onToast('공연 수정 실패', 'ink')) + .finally(() => setSaving(false)); + return; + } + + // 등록 모드: POST /api/admin/performances + api.createAdminPerformance(body) .then((created) => { setPerformances((prev) => [created, ...prev]); // 목록 맨 앞에 추가 - setAdding(false); - setForm(EMPTY_FORM); + closeForm(); onToast && onToast('공연이 등록됐습니다'); }) .catch(() => onToast && onToast('공연 등록 실패', 'ink')) @@ -71,6 +132,7 @@ export default function ScreenAdminConcerts({ navigate, onToast }) { }; // 공연 취소 — PUT /api/admin/performances/{id} (cancelled: true로 마킹) + // musicianIds를 아예 안 보내면(생략) 백엔드가 기존 라인업을 그대로 유지함 const cancel = (p) => { const body = { venueId: p.venue?.id, @@ -127,7 +189,7 @@ export default function ScreenAdminConcerts({ navigate, onToast }) {

공연 관리 ({performances.length})

-
@@ -167,11 +229,44 @@ export default function ScreenAdminConcerts({ navigate, onToast }) { setField('sourceUrl', e.target.value)} />
+ {/* 라인업(출연 뮤지션) 선택 — 드롭다운에서 고르면 아래에 칩으로 쌓임 */} +
+ + {/* value=""로 고정 — 선택 즉시 목록에 추가하고 드롭다운은 placeholder로 되돌림 */} + + {/* 선택된 뮤지션 칩 목록 — x 클릭으로 제거 */} + {form.musicianIds.length > 0 && ( +
+ {form.musicianIds.map((mid) => { + const m = musicians.find((x) => x.id === mid); // id로 뮤지션 정보 찾기 (이름 표시용) + return ( + + {m ? m.stageName : `#${mid}`} + + + ); + })} +
+ )} +
- + + {/* editing 여부에 따라 버튼 문구를 등록/수정으로 전환 */}
@@ -232,6 +327,10 @@ export default function ScreenAdminConcerts({ navigate, onToast }) { + {/* 공연 편집 버튼 — 기존 정보와 현재 라인업이 채워진 수정 폼을 엶 */} + {/* 취소된 공연: 복구 버튼 표시 */} {p.cancelled && (