Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ✓ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ public ResponseEntity<Performance> 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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +45,11 @@ ORDER BY COUNT(pl.performance) DESC
""")
List<GraphEdgeResponse> findCoPerformers(@Param("musicianId") Long musicianId);

// 특정 공연에 출연하는 뮤지션 목록 조회 (공연 수정 폼에서 현재 라인업 표시용)
// pl.musician만 SELECT — 라인업 행이 아니라 그 안의 Musician 엔티티를 바로 반환
@Query("SELECT pl.musician FROM PerformanceLineup pl WHERE pl.performance.id = :performanceId")
List<Musician> findMusiciansByPerformanceId(@Param("performanceId") Long performanceId);

// 특정 공연의 라인업을 전부 삭제하는 메서드 (공연 수정 시 라인업 교체용)
//
// 메서드 이름 규칙(쿼리 메서드)으로 Spring Data JPA가 쿼리를 자동 생성함:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ public List<Performance> findByMusicianId(Long musicianId) {
return performanceRepository.findByMusicianId(musicianId);
}

// 특정 공연의 라인업(출연 뮤지션 목록) 조회
public List<Musician> 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에 저장하므로 읽기 전용 해제
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) }),
Expand Down Expand Up @@ -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: 수정할 공연 번호
Expand Down
129 changes: 114 additions & 15 deletions frontend/src/pages/admin/ScreenAdminConcerts.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand All @@ -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);
Expand All @@ -55,22 +101,38 @@ 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'))
.finally(() => setSaving(false));
};

// 공연 취소 — PUT /api/admin/performances/{id} (cancelled: true로 마킹)
// musicianIds를 아예 안 보내면(생략) 백엔드가 기존 라인업을 그대로 유지함
const cancel = (p) => {
const body = {
venueId: p.venue?.id,
Expand Down Expand Up @@ -127,7 +189,7 @@ export default function ScreenAdminConcerts({ navigate, onToast }) {
<div className="pad">
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h1 className="h2 serif" style={{ margin: 0 }}>공연 관리 ({performances.length})</h1>
<button className="btn primary sm" onClick={() => { setAdding(true); setTab('upcoming'); }}>
<button className="btn primary sm" onClick={() => { setEditing(null); setForm(EMPTY_FORM); setAdding(true); setTab('upcoming'); }}>
<Icon name="plus" size={14} /> 공연 등록
</button>
</div>
Expand Down Expand Up @@ -167,11 +229,44 @@ export default function ScreenAdminConcerts({ navigate, onToast }) {
<label className="label">출처 링크</label>
<input type="url" placeholder="인스타 포스트 URL 등" value={form.sourceUrl} onChange={(e) => setField('sourceUrl', e.target.value)} />
</div>
{/* 라인업(출연 뮤지션) 선택 — 드롭다운에서 고르면 아래에 칩으로 쌓임 */}
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label className="label">라인업 (출연 뮤지션)</label>
{/* value=""로 고정 — 선택 즉시 목록에 추가하고 드롭다운은 placeholder로 되돌림 */}
<select value="" onChange={(e) => addMusician(e.target.value)}>
<option value="">뮤지션 선택해서 추가</option>
{musicians
.filter((m) => !form.musicianIds.includes(m.id)) // 이미 추가한 뮤지션은 목록에서 숨김
.map((m) => <option key={m.id} value={m.id}>{m.stageName}{m.position ? ` — ${m.position}` : ''}</option>)}
</select>
{/* 선택된 뮤지션 칩 목록 — x 클릭으로 제거 */}
{form.musicianIds.length > 0 && (
<div className="row" style={{ gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
{form.musicianIds.map((mid) => {
const m = musicians.find((x) => x.id === mid); // id로 뮤지션 정보 찾기 (이름 표시용)
return (
<span key={mid} className="pill light sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{m ? m.stageName : `#${mid}`}
<button
type="button"
onClick={() => removeMusician(mid)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'inline-flex' }}
aria-label="라인업에서 제거"
>
<Icon name="x" size={11} />
</button>
</span>
);
})}
</div>
)}
</div>
</div>
<div className="row" style={{ justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<button className="btn ghost sm" onClick={() => { setAdding(false); setForm(EMPTY_FORM); }}>취소</button>
<button className="btn ghost sm" onClick={closeForm}>취소</button>
{/* editing 여부에 따라 버튼 문구를 등록/수정으로 전환 */}
<button className="btn primary sm" disabled={!canSave || saving} onClick={save}>
{saving ? '등록 중...' : '등록'}
{saving ? (editing ? '수정 중...' : '등록 중...') : (editing ? '수정' : '등록')}
</button>
</div>
</div>
Expand Down Expand Up @@ -232,6 +327,10 @@ export default function ScreenAdminConcerts({ navigate, onToast }) {
<button className="btn ghost sm" onClick={() => navigate('concert-detail', { concertId: p.id })}>
<Icon name="external" size={13} />
</button>
{/* 공연 편집 버튼 — 기존 정보와 현재 라인업이 채워진 수정 폼을 엶 */}
<button className="btn ghost sm" title="공연 수정" onClick={() => startEdit(p)}>
<Icon name="edit" size={13} />
</button>
{/* 취소된 공연: 복구 버튼 표시 */}
{p.cancelled && (
<button className="btn ghost sm" title="공연 복구" onClick={() => restore(p)}>
Expand Down
Loading