diff --git a/backend/src/routes/admin/concert.js b/backend/src/routes/admin/concert.js index f72dabe..1f4ac5b 100644 --- a/backend/src/routes/admin/concert.js +++ b/backend/src/routes/admin/concert.js @@ -13,6 +13,344 @@ const CONCERT_CATEGORY_ID = CATEGORY_IDS.CONCERT; export default async function concertRoutes(fastify) { const { db, meilisearch } = fastify; + /** + * GET /api/admin/concert/schedule/:seriesId + * 콘서트 시리즈 상세 조회 (수정 폼용) + */ + fastify.get('/schedule/:seriesId', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { seriesId } = request.params; + + try { + // 시리즈 기본 정보 + const [seriesRows] = await db.query(` + SELECT cs.id, cs.title, cs.poster_id, + i.original_url as poster_original, i.medium_url as poster_medium, i.thumb_url as poster_thumb + FROM concert_series cs + LEFT JOIN images i ON cs.poster_id = i.id + WHERE cs.id = ? + `, [seriesId]); + + if (seriesRows.length === 0) { + return reply.code(404).send({ error: '콘서트를 찾을 수 없습니다.' }); + } + + const series = seriesRows[0]; + + // 회차 정보 (schedules + schedule_concert + venue) + const [roundRows] = await db.query(` + SELECT s.id as schedule_id, sc.id as concert_id, s.date, s.time, + cv.id as venue_id, cv.name as venue_name, cv.country as venue_country, + cv.address as venue_address, cv.lat as venue_lat, cv.lng as venue_lng + FROM schedule_concert sc + JOIN schedules s ON sc.schedule_id = s.id + LEFT JOIN concert_venues cv ON sc.venue_id = cv.id + WHERE sc.series_id = ? + ORDER BY s.date ASC, s.time ASC + `, [seriesId]); + + // 멤버 (첫 회차 기준) + let memberIds = []; + if (roundRows.length > 0) { + const [memberRows] = await db.query( + 'SELECT member_id FROM schedule_members WHERE schedule_id = ?', + [roundRows[0].schedule_id] + ); + memberIds = memberRows.map(r => r.member_id); + } + + // 회차별 세트리스트 + const rounds = []; + const setlists = {}; + + for (let i = 0; i < roundRows.length; i++) { + const r = roundRows[i]; + const roundId = i + 1; + + rounds.push({ + id: roundId, + scheduleId: r.schedule_id, + concertId: r.concert_id, + date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date?.split('T')[0] || '', + time: r.time ? r.time.substring(0, 5) : '', + venue: r.venue_id ? { + id: r.venue_id, + name: r.venue_name, + country: r.venue_country, + address: r.venue_address, + lat: r.venue_lat, + lng: r.venue_lng, + } : null, + }); + + // 세트리스트 + const [setlistRows] = await db.query(` + SELECT csl.id, csl.order_num, csl.song_name, csl.album_name + FROM concert_setlists csl + WHERE csl.concert_id = ? + ORDER BY csl.order_num ASC + `, [r.concert_id]); + + const songs = []; + for (const song of setlistRows) { + const [songMembers] = await db.query( + 'SELECT member_id FROM concert_setlist_members WHERE setlist_id = ?', + [song.id] + ); + songs.push({ + id: song.id, + songName: song.song_name, + albumName: song.album_name || '', + memberIds: songMembers.map(m => m.member_id), + }); + } + + setlists[roundId] = songs.length > 0 ? songs : [{ id: 1, songName: '', albumName: '', memberIds: [] }]; + } + + // 굿즈 이미지 + const [mdRows] = await db.query(` + SELECT csm.id, csm.sort_order, i.original_url, i.medium_url, i.thumb_url + FROM concert_series_md csm + JOIN images i ON csm.image_id = i.id + WHERE csm.series_id = ? + ORDER BY csm.sort_order ASC + `, [seriesId]); + + return { + id: series.id, + title: series.title, + posterUrl: series.poster_medium || series.poster_original || null, + memberIds, + rounds, + setlists, + merchandise: mdRows.map(m => ({ + id: m.id, + originalUrl: m.original_url, + mediumUrl: m.medium_url, + thumbUrl: m.thumb_url, + })), + }; + } catch (err) { + fastify.log.error(`콘서트 조회 오류: ${err.message}`); + return serverError(reply, err.message); + } + }); + + /** + * PUT /api/admin/concert/schedule/:seriesId + * 콘서트 일정 수정 (multipart/form-data) + */ + fastify.put('/schedule/:seriesId', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { seriesId } = request.params; + const parts = request.parts(); + + let title = ''; + let memberIds = []; + let rounds = []; + let setlists = []; + let keepMerchandiseIds = []; + let posterBuffer = null; + const merchandiseBuffers = []; + + for await (const part of parts) { + if (part.type === 'file') { + const buffer = await part.toBuffer(); + if (part.fieldname === 'poster') { + posterBuffer = buffer; + } else if (part.fieldname === 'merchandise') { + merchandiseBuffers.push(buffer); + } + } else { + if (part.fieldname === 'title') title = 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 === 'setlists') setlists = JSON.parse(part.value); + else if (part.fieldname === 'setlist') setlists = [JSON.parse(part.value)]; + else if (part.fieldname === 'keepMerchandiseIds') keepMerchandiseIds = JSON.parse(part.value); + } + } + + if (!title?.trim()) { + return badRequest(reply, '공연명은 필수입니다.'); + } + if (!rounds || rounds.length === 0) { + return badRequest(reply, '최소 1개 이상의 공연 일정이 필요합니다.'); + } + + try { + const result = await withTransaction(db, async (conn) => { + // 1. 시리즈 업데이트 + await conn.query('UPDATE concert_series SET title = ? WHERE id = ?', [title.trim(), seriesId]); + + // 2. 포스터 업데이트 + if (posterBuffer) { + const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertPoster(seriesId, posterBuffer); + const [existing] = await conn.query('SELECT poster_id FROM concert_series WHERE id = ?', [seriesId]); + if (existing[0]?.poster_id) { + await conn.query( + 'UPDATE images SET original_url = ?, medium_url = ?, thumb_url = ? WHERE id = ?', + [originalUrl, mediumUrl, thumbUrl, existing[0].poster_id] + ); + } else { + const [imgResult] = await conn.query( + 'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', + [originalUrl, mediumUrl, thumbUrl] + ); + await conn.query('UPDATE concert_series SET poster_id = ? WHERE id = ?', [imgResult.insertId, seriesId]); + } + } + + // 3. 기존 회차 관련 데이터 삭제 + const [existingConcerts] = await conn.query( + 'SELECT sc.id as concert_id, sc.schedule_id FROM schedule_concert sc WHERE sc.series_id = ?', + [seriesId] + ); + + if (existingConcerts.length > 0) { + const concertIds = existingConcerts.map(c => c.concert_id); + const scheduleIds = existingConcerts.map(c => c.schedule_id); + + // 세트리스트 멤버 삭제 + const [setlistRows] = await conn.query( + 'SELECT id FROM concert_setlists WHERE concert_id IN (?)', [concertIds] + ); + if (setlistRows.length > 0) { + await conn.query('DELETE FROM concert_setlist_members WHERE setlist_id IN (?)', [setlistRows.map(s => s.id)]); + } + await conn.query('DELETE FROM concert_setlists WHERE concert_id IN (?)', [concertIds]); + await conn.query('DELETE FROM schedule_members WHERE schedule_id IN (?)', [scheduleIds]); + await conn.query('DELETE FROM schedule_concert WHERE series_id = ?', [seriesId]); + await conn.query('DELETE FROM schedules WHERE id IN (?)', [scheduleIds]); + } + + // 4. 회차 재생성 + const newScheduleIds = []; + const newConcertIds = []; + + for (const round of rounds) { + let venueId = null; + if (round.venueId) { + venueId = round.venueId; + } else if (round.venueName) { + const [venueResult] = await conn.query( + 'INSERT INTO concert_venues (name, country, address, lat, lng) VALUES (?, ?, ?, ?, ?)', + [round.venueName, round.venueCountry || null, round.venueAddress || null, round.venueLat || null, round.venueLng || null] + ); + venueId = venueResult.insertId; + } + + const [scheduleResult] = await conn.query( + 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', + [CONCERT_CATEGORY_ID, title.trim(), round.date, round.time || null] + ); + const scheduleId = scheduleResult.insertId; + newScheduleIds.push(scheduleId); + + const [concertResult] = await conn.query( + 'INSERT INTO schedule_concert (schedule_id, series_id, venue_id) VALUES (?, ?, ?)', + [scheduleId, seriesId, venueId] + ); + newConcertIds.push(concertResult.insertId); + + if (memberIds.length > 0) { + const values = memberIds.map(memberId => [scheduleId, memberId]); + await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); + } + } + + // 5. 회차별 세트리스트 재생성 + for (let roundIdx = 0; roundIdx < newConcertIds.length; roundIdx++) { + const concertId = newConcertIds[roundIdx]; + const roundSetlist = setlists[roundIdx] || setlists[0] || []; + + for (let i = 0; i < roundSetlist.length; i++) { + const song = roundSetlist[i]; + if (!song.songName?.trim()) continue; + + const [setlistResult] = await conn.query( + 'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)', + [concertId, i + 1, song.songName.trim(), song.albumName?.trim() || null] + ); + + if (song.memberIds?.length > 0) { + const memberValues = song.memberIds.map(memberId => [setlistResult.insertId, memberId]); + await conn.query('INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?', [memberValues]); + } + } + } + + // 6. 굿즈 관리 (유지할 것 외 삭제 + 새 파일 추가) + const [existingMd] = await conn.query( + 'SELECT id, image_id FROM concert_series_md WHERE series_id = ?', [seriesId] + ); + const keepSet = new Set(keepMerchandiseIds); + const toDelete = existingMd.filter(m => !keepSet.has(m.id)); + + for (const md of toDelete) { + await conn.query('DELETE FROM concert_series_md WHERE id = ?', [md.id]); + await conn.query('DELETE FROM images WHERE id = ?', [md.image_id]); + } + + // 유지된 항목 순서 업데이트 + let sortOrder = 1; + for (const keepId of keepMerchandiseIds) { + await conn.query('UPDATE concert_series_md SET sort_order = ? WHERE id = ?', [sortOrder++, keepId]); + } + + // 새 굿즈 추가 + for (const buffer of merchandiseBuffers) { + const filename = `${String(sortOrder).padStart(2, '0')}.webp`; + const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertMerchandise(seriesId, filename, buffer); + const [imgResult] = await conn.query( + 'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', + [originalUrl, mediumUrl, thumbUrl] + ); + await conn.query( + 'INSERT INTO concert_series_md (series_id, image_id, sort_order) VALUES (?, ?, ?)', + [seriesId, imgResult.insertId, sortOrder++] + ); + } + + return { scheduleIds: newScheduleIds }; + }); + + // Meilisearch 동기화 + const [categoryRows] = await db.query('SELECT name, color FROM schedule_categories WHERE id = ?', [CONCERT_CATEGORY_ID]); + const category = categoryRows[0] || {}; + let memberNames = ''; + if (memberIds.length > 0) { + const [members] = await db.query('SELECT name FROM members WHERE id IN (?) ORDER BY id', [memberIds]); + memberNames = members.map(m => m.name).join(','); + } + for (const scheduleId of result.scheduleIds) { + const [scheduleRows] = await db.query('SELECT title, date, time FROM schedules WHERE id = ?', [scheduleId]); + const s = scheduleRows[0]; + if (s) { + await addOrUpdateSchedule(meilisearch, { + id: scheduleId, + title: s.title, + date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date, + time: s.time || '', + category_id: CONCERT_CATEGORY_ID, + category_name: category.name || '', + category_color: category.color || '', + member_names: memberNames, + }); + } + } + + logActivity(db, { actor: 'admin', action: 'update', category: 'concert', targetType: 'concert', targetId: parseInt(seriesId), summary: `콘서트 일정 수정: ${title}` }); + return { success: true, seriesId: parseInt(seriesId) }; + } catch (err) { + fastify.log.error(`콘서트 수정 오류: ${err.message}`); + return serverError(reply, err.message); + } + }); + /** * POST /api/admin/concert/schedule * 콘서트 일정 저장 (multipart/form-data) diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 1e0a795..2784702 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -75,7 +75,7 @@ export function buildSource(schedule) { * @returns {object} 포맷된 일정 객체 */ export function formatSchedule(rawSchedule, members = []) { - return { + const result = { id: rawSchedule.id, title: rawSchedule.title, date: normalizeDate(rawSchedule.date), @@ -88,6 +88,10 @@ export function formatSchedule(rawSchedule, members = []) { source: buildSource(rawSchedule), members, }; + if (rawSchedule.concert_series_id) { + result.concertSeriesId = rawSchedule.concert_series_id; + } + return result; } /** @@ -296,11 +300,13 @@ const SCHEDULE_LIST_SQL = ` sy.video_id as youtube_video_id, sy.video_type as youtube_video_type, sx.post_id as x_post_id, - sx.username as x_username + sx.username as x_username, + scon.series_id as concert_series_id FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id LEFT JOIN schedule_x sx ON s.id = sx.schedule_id + LEFT JOIN schedule_concert scon ON s.id = scon.schedule_id `; /** diff --git a/frontend/src/api/admin/concert.js b/frontend/src/api/admin/concert.js index fce0101..a07a62a 100644 --- a/frontend/src/api/admin/concert.js +++ b/frontend/src/api/admin/concert.js @@ -1,13 +1,25 @@ /** * 콘서트 관리자 API */ -import { fetchFormData } from '@/api/client'; +import { fetchAuthApi, fetchFormData } from '@/api/client'; /** * 콘서트 일정 생성 - * @param {FormData} formData - 콘서트 데이터 - * @returns {Promise<{success: boolean, seriesId: number}>} */ export async function createConcertSchedule(formData) { return fetchFormData('/admin/concert/schedule', formData, 'POST'); } + +/** + * 콘서트 시리즈 상세 조회 (수정 폼용) + */ +export async function getConcertSchedule(seriesId) { + return fetchAuthApi(`/admin/concert/schedule/${seriesId}`); +} + +/** + * 콘서트 일정 수정 + */ +export async function updateConcertSchedule(seriesId, formData) { + return fetchFormData(`/admin/concert/schedule/${seriesId}`, formData, 'PUT'); +} diff --git a/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx b/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx index 0161105..761f7a8 100644 --- a/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx +++ b/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx @@ -17,12 +17,17 @@ import { /** * 카테고리별 수정 경로 반환 */ -export const getEditPath = (scheduleId, categoryName) => { +export const getEditPath = (scheduleId, categoryName, schedule) => { switch (categoryName) { case '유튜브': return `/admin/schedule/${scheduleId}/edit/youtube`; case 'X': return `/admin/schedule/${scheduleId}/edit/x`; + case '콘서트': + if (schedule?.concertSeriesId) { + return `/admin/schedule/concert/${schedule.concertSeriesId}/edit`; + } + return `/admin/schedule/${scheduleId}/edit`; default: return `/admin/schedule/${scheduleId}/edit`; } @@ -134,7 +139,7 @@ const ScheduleItem = memo(function ScheduleItem({ )} + + + + + ); +} + +export default ConcertEditForm; diff --git a/frontend/src/routes/pc/admin/index.jsx b/frontend/src/routes/pc/admin/index.jsx index ad9d73c..82235c8 100644 --- a/frontend/src/routes/pc/admin/index.jsx +++ b/frontend/src/routes/pc/admin/index.jsx @@ -32,6 +32,7 @@ import AdminSchedules from '@/pages/pc/admin/schedules/Schedules'; import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm'; import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form'; import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm'; +import AdminConcertEditForm from '@/pages/pc/admin/schedules/edit/ConcertEditForm'; import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory'; import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict'; import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots'; @@ -57,6 +58,7 @@ export default function AdminRoutes() { } /> } /> } /> + } /> } /> } /> } />