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:
caadiq 2026-03-31 18:25:01 +09:00
parent 637172ddd7
commit ce41fc1a60
6 changed files with 675 additions and 7 deletions

View file

@ -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)

View file

@ -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
`;
/**

View file

@ -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');
}

View file

@ -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} />

View 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;

View file

@ -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>} />