feat(concert): 콘서트 수정 페이지 추가
- 백엔드: GET /admin/concert/schedule/:seriesId (상세 조회) - 백엔드: PUT /admin/concert/schedule/:seriesId (수정) - 프론트엔드: ConcertEditForm 페이지 (생성 폼 컴포넌트 재사용) - 라우트: /admin/schedule/concert/:seriesId/edit 등록 - 일정 목록: 콘서트 카테고리 편집 버튼이 수정 페이지로 연결 - schedule.js: formatSchedule에 concertSeriesId 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
637172ddd7
commit
ce41fc1a60
6 changed files with 675 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
|
||||
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name, schedule))}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
|
|||
305
frontend/src/pages/pc/admin/schedules/edit/ConcertEditForm.jsx
Normal file
305
frontend/src/pages/pc/admin/schedules/edit/ConcertEditForm.jsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
|
||||
import AdminLayout from "@/components/pc/admin/layout/Layout";
|
||||
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 { getConcertSchedule, updateConcertSchedule } from "@/api/admin/concert";
|
||||
|
||||
import ConcertInfoSection from "../form/concert/ConcertInfoSection";
|
||||
import ScheduleSection from "../form/concert/ScheduleSection";
|
||||
import SetlistSection from "../form/concert/SetlistSection";
|
||||
import MerchandiseSection from "../form/concert/MerchandiseSection";
|
||||
|
||||
/**
|
||||
* 콘서트 일정 수정 폼
|
||||
*/
|
||||
function ConcertEditForm() {
|
||||
const { seriesId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { toast, setToast } = useToast();
|
||||
const { isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 멤버/앨범 데이터
|
||||
const { data: membersData = [] } = useQuery({
|
||||
queryKey: ["members"],
|
||||
queryFn: getMembers,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const members = membersData.filter((m) => !m.is_former);
|
||||
|
||||
const { data: albumsData = [] } = useQuery({
|
||||
queryKey: ["albums"],
|
||||
queryFn: getAlbums,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// 기존 데이터 로드
|
||||
const { data: concertData, isLoading: isLoadingConcert } = useQuery({
|
||||
queryKey: ["concert", seriesId],
|
||||
queryFn: () => getConcertSchedule(seriesId),
|
||||
enabled: isAuthenticated && !!seriesId,
|
||||
});
|
||||
|
||||
// 폼 상태
|
||||
const [title, setTitle] = useState("");
|
||||
const [posterFile, setPosterFile] = useState(null);
|
||||
const [posterPreview, setPosterPreview] = useState(null);
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||
const [rounds, setRoundsRaw] = useState([]);
|
||||
const [setlists, setSetlists] = useState({});
|
||||
const [merchandiseItems, setMerchandiseItems] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// 기존 데이터로 초기화
|
||||
useEffect(() => {
|
||||
if (concertData && !initialized) {
|
||||
setTitle(concertData.title || "");
|
||||
setPosterPreview(concertData.posterUrl || null);
|
||||
setSelectedMemberIds(concertData.memberIds || []);
|
||||
setRoundsRaw(concertData.rounds || []);
|
||||
setSetlists(concertData.setlists || {});
|
||||
setMerchandiseItems(
|
||||
(concertData.merchandise || []).map((m) => ({
|
||||
id: m.id,
|
||||
existingId: m.id,
|
||||
preview: m.thumbUrl || m.mediumUrl,
|
||||
file: null,
|
||||
}))
|
||||
);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [concertData, initialized]);
|
||||
|
||||
// 회차 변경 시 세트리스트 동기화
|
||||
const setRounds = (updater) => {
|
||||
setRoundsRaw((prev) => {
|
||||
const newRounds = typeof updater === "function" ? updater(prev) : updater;
|
||||
|
||||
setSetlists((prevSetlists) => {
|
||||
const updated = { ...prevSetlists };
|
||||
for (const round of newRounds) {
|
||||
if (!updated[round.id]) {
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
// 멤버 토글
|
||||
const toggleMember = (memberId) => {
|
||||
setSelectedMemberIds((prev) =>
|
||||
prev.includes(memberId)
|
||||
? prev.filter((id) => id !== memberId)
|
||||
: [...prev, memberId]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllMembers = () => {
|
||||
if (selectedMemberIds.length === members.length) {
|
||||
setSelectedMemberIds([]);
|
||||
} else {
|
||||
setSelectedMemberIds(members.map((m) => m.id));
|
||||
}
|
||||
};
|
||||
|
||||
// 포스터
|
||||
const handlePosterChange = (file) => {
|
||||
setPosterFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setPosterPreview(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handlePosterRemove = () => {
|
||||
setPosterFile(null);
|
||||
setPosterPreview(null);
|
||||
};
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
setToast({ type: "error", message: "공연명을 입력해주세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
const validRounds = rounds.filter((r) => r.date);
|
||||
if (validRounds.length === 0) {
|
||||
setToast({ type: "error", message: "최소 1개 이상의 공연 일정이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("title", title.trim());
|
||||
formData.append("memberIds", JSON.stringify(selectedMemberIds));
|
||||
|
||||
if (posterFile) {
|
||||
formData.append("poster", posterFile);
|
||||
}
|
||||
|
||||
const roundsData = validRounds.map((r) => ({
|
||||
date: r.date,
|
||||
time: r.time || null,
|
||||
venueId: r.venue?.id || null,
|
||||
venueName: r.venue?.name || null,
|
||||
venueCountry: r.venue?.country || null,
|
||||
venueAddress: r.venue?.address || null,
|
||||
venueLat: r.venue?.lat || null,
|
||||
venueLng: r.venue?.lng || null,
|
||||
}));
|
||||
formData.append("rounds", JSON.stringify(roundsData));
|
||||
|
||||
// 회차별 세트리스트
|
||||
const setlistsData = validRounds.map((r) => {
|
||||
const roundSetlist = setlists[r.id] || [];
|
||||
return roundSetlist
|
||||
.filter((s) => s.songName?.trim())
|
||||
.map((s) => ({
|
||||
songName: s.songName.trim(),
|
||||
albumName: s.albumName?.trim() || null,
|
||||
memberIds: s.memberIds || [],
|
||||
}));
|
||||
});
|
||||
formData.append("setlists", JSON.stringify(setlistsData));
|
||||
|
||||
// 기존 유지할 굿즈 ID
|
||||
const keepIds = merchandiseItems
|
||||
.filter((item) => item.existingId && !item.file)
|
||||
.map((item) => item.existingId);
|
||||
formData.append("keepMerchandiseIds", JSON.stringify(keepIds));
|
||||
|
||||
// 새 굿즈 파일
|
||||
merchandiseItems.forEach((item) => {
|
||||
if (item.file) {
|
||||
formData.append("merchandise", item.file);
|
||||
}
|
||||
});
|
||||
|
||||
await updateConcertSchedule(seriesId, formData);
|
||||
|
||||
setToast({ type: "success", message: "콘서트 일정이 수정되었습니다." });
|
||||
setTimeout(() => navigate("/admin/schedule"), 1000);
|
||||
} catch (err) {
|
||||
console.error("콘서트 수정 실패:", err);
|
||||
setToast({ type: "error", message: err.message || "수정에 실패했습니다." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingConcert) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
<ConcertInfoSection
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
posterPreview={posterPreview}
|
||||
onPosterChange={handlePosterChange}
|
||||
onPosterRemove={handlePosterRemove}
|
||||
members={members}
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
onToggleMember={toggleMember}
|
||||
onToggleAllMembers={toggleAllMembers}
|
||||
/>
|
||||
|
||||
<ScheduleSection rounds={rounds} setRounds={setRounds} />
|
||||
|
||||
<MerchandiseSection
|
||||
items={merchandiseItems}
|
||||
setItems={setMerchandiseItems}
|
||||
/>
|
||||
|
||||
<SetlistSection
|
||||
rounds={rounds}
|
||||
setlists={setlists}
|
||||
setSetlists={setSetlists}
|
||||
members={members}
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
albums={albumsData}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/admin/schedule")}
|
||||
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
수정 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
수정
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConcertEditForm;
|
||||
|
|
@ -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() {
|
|||
<Route path="/admin/schedule/new-legacy" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/:id/edit" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/concert/:seriesId/edit" element={<RequireAuth><AdminConcertEditForm /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
||||
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue