diff --git a/backend/sql/schedule_event.sql b/backend/sql/schedule_event.sql new file mode 100644 index 0000000..1c0762b --- /dev/null +++ b/backend/sql/schedule_event.sql @@ -0,0 +1,29 @@ +-- 행사 장소 (카카오맵 기반) +CREATE TABLE IF NOT EXISTS event_venues ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL, + address VARCHAR(300), + road_address VARCHAR(300), + lat DECIMAL(10, 7), + lng DECIMAL(10, 7), + kakao_id VARCHAR(30), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_kakao_id (kakao_id) +); + +-- 행사 상세 (schedules와 1:1) +-- subtype: 'university' (학교 축제) 등 세부 타입 slug +-- school_name: 학교 행사의 경우 대학/학교명 +-- venue_id: 장소 FK (선택) +-- post_urls: 인스타/공식 URL 배열 (JSON) +CREATE TABLE IF NOT EXISTS schedule_event ( + schedule_id INT PRIMARY KEY, + subtype VARCHAR(30) NOT NULL, + school_name VARCHAR(100), + venue_id INT, + post_urls JSON, + poster_image_ids JSON, + FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE, + FOREIGN KEY (venue_id) REFERENCES event_venues(id) ON DELETE SET NULL, + INDEX idx_subtype (subtype) +); diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 76523a3..5955d13 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -6,6 +6,7 @@ export const CATEGORY_IDS = { VARIETY: 10, BIRTHDAY: 8, DEBUT: 9, + EVENT: 11, }; // 데뷔일 (fromis_9: 2018년 1월 24일) diff --git a/backend/src/routes/admin/events.js b/backend/src/routes/admin/events.js new file mode 100644 index 0000000..6eb5934 --- /dev/null +++ b/backend/src/routes/admin/events.js @@ -0,0 +1,357 @@ +import { CATEGORY_IDS } from '../../config/index.js'; +import { withTransaction } from '../../utils/transaction.js'; +import { uploadEventPoster } from '../../services/image.js'; +import { logActivity } from '../../utils/log.js'; +import { syncScheduleById } from '../../services/meilisearch/index.js'; + +const EVENT_CATEGORY_ID = CATEGORY_IDS.EVENT; +const VALID_SUBTYPES = ['university']; + +/** + * 장소를 upsert (kakao_id 기준) 후 venue_id 반환 + */ +async function upsertVenue(db, venue) { + if (!venue) return null; + if (venue.id) return venue.id; + if (!venue.name) return null; + + // kakao_id가 있으면 먼저 조회 + if (venue.kakao_id) { + const [rows] = await db.query('SELECT id FROM event_venues WHERE kakao_id = ?', [venue.kakao_id]); + if (rows.length > 0) return rows[0].id; + } + + const [result] = await db.query( + `INSERT INTO event_venues (name, address, road_address, lat, lng, kakao_id) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + venue.name, + venue.address || null, + venue.road_address || venue.roadAddress || null, + venue.lat ?? null, + venue.lng ?? null, + venue.kakao_id || venue.kakaoId || null, + ] + ); + return result.insertId; +} + +/** + * multipart에서 payload(JSON 문자열) + poster 파일들 추출 + */ +async function parseMultipartEventForm(request) { + const parts = request.parts(); + let payload = null; + const posterFiles = []; + + for await (const part of parts) { + if (part.type === 'file') { + const buf = await part.toBuffer(); + posterFiles.push({ + filename: part.filename, + buffer: buf, + mimetype: part.mimetype, + }); + } else if (part.fieldname === 'payload') { + payload = JSON.parse(part.value); + } + } + + return { payload, posterFiles }; +} + +/** + * images 테이블에 INSERT 후 id 반환 + */ +async function saveImageRecord(db, { originalUrl, mediumUrl, thumbUrl }) { + const [result] = await db.query( + `INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)`, + [originalUrl, mediumUrl, thumbUrl] + ); + return result.insertId; +} + +/** + * 행사 관련 관리자 라우트 + */ +export default async function eventsRoutes(fastify) { + const { db, meilisearch } = fastify; + + /** + * GET /api/admin/events/:id + * 행사 상세 조회 (수정 폼용) + */ + fastify.get('/:id', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + const [rows] = await db.query(` + SELECT s.id, s.title, s.date, s.time, + se.subtype, se.school_name, se.venue_id, se.post_urls, se.poster_image_ids, + ev.name as venue_name, ev.address as venue_address, + ev.road_address as venue_road_address, ev.lat as venue_lat, ev.lng as venue_lng, + ev.kakao_id as venue_kakao_id + FROM schedules s + JOIN schedule_event se ON s.id = se.schedule_id + LEFT JOIN event_venues ev ON se.venue_id = ev.id + WHERE s.id = ? + `, [id]); + + if (rows.length === 0) { + return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' }); + } + + const r = rows[0]; + + // 멤버 + const [memberRows] = await db.query( + 'SELECT member_id FROM schedule_members WHERE schedule_id = ?', + [id] + ); + const memberIds = memberRows.map(m => m.member_id); + + // 포스터 이미지 (순서 유지) + const posterIds = r.poster_image_ids + ? (typeof r.poster_image_ids === 'string' ? JSON.parse(r.poster_image_ids) : r.poster_image_ids) + : []; + let posters = []; + if (posterIds.length > 0) { + const [posterRows] = await db.query( + `SELECT id, original_url, medium_url, thumb_url FROM images WHERE id IN (?) ORDER BY FIELD(id, ?)`, + [posterIds, posterIds] + ); + posters = posterRows.map(p => ({ + id: p.id, + originalUrl: p.original_url, + mediumUrl: p.medium_url, + thumbUrl: p.thumb_url, + })); + } + + const date = r.date instanceof Date + ? r.date.toISOString().split('T')[0] + : String(r.date).split('T')[0]; + + return { + id: r.id, + title: r.title, + date, + time: r.time ? r.time.substring(0, 5) : '', + subtype: r.subtype, + schoolName: r.school_name || '', + memberIds, + venue: r.venue_id ? { + id: r.venue_id, + name: r.venue_name, + address: r.venue_address, + roadAddress: r.venue_road_address, + lat: r.venue_lat, + lng: r.venue_lng, + kakao_id: r.venue_kakao_id, + } : null, + postUrls: r.post_urls + ? (typeof r.post_urls === 'string' ? JSON.parse(r.post_urls) : r.post_urls) + : [], + posters, + }; + }); + + /** + * POST /api/admin/events + * 행사 생성 (multipart/form-data: payload + poster 파일들) + */ + fastify.post('/', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { payload, posterFiles } = await parseMultipartEventForm(request); + + if (!payload) { + return reply.code(400).send({ error: 'payload가 필요합니다.' }); + } + + const { + title, date, time, subtype = 'university', schoolName, + memberIds = [], venue, postUrls = [], + } = payload; + + if (!title || !date || !schoolName) { + return reply.code(400).send({ error: '제목/날짜/학교명은 필수입니다.' }); + } + if (!VALID_SUBTYPES.includes(subtype)) { + return reply.code(400).send({ error: `알 수 없는 subtype: ${subtype}` }); + } + if (!venue) { + return reply.code(400).send({ error: '장소가 필요합니다.' }); + } + + const scheduleId = await withTransaction(db, async (conn) => { + // 1) venue upsert + const venueId = await upsertVenue(conn, venue); + + // 2) schedules INSERT + const [sResult] = await conn.query( + `INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)`, + [EVENT_CATEGORY_ID, title, date, time || null] + ); + const sid = sResult.insertId; + + // 3) schedule_event INSERT (poster는 트랜잭션 후 업로드, 그 때 UPDATE) + await conn.query( + `INSERT INTO schedule_event (schedule_id, subtype, school_name, venue_id, post_urls) + VALUES (?, ?, ?, ?, ?)`, + [ + sid, + subtype, + schoolName, + venueId, + postUrls.length > 0 ? JSON.stringify(postUrls) : null, + ] + ); + + // 4) 멤버 연결 + if (memberIds.length > 0) { + const values = memberIds.map(mid => [sid, mid]); + await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); + } + + return sid; + }); + + // 5) 포스터 업로드 (트랜잭션 밖 — S3 I/O) + if (posterFiles.length > 0) { + const uploadedIds = []; + for (let i = 0; i < posterFiles.length; i++) { + const ext = (posterFiles[i].filename.split('.').pop() || 'webp').toLowerCase(); + const filename = `${String(i + 1).padStart(2, '0')}.${ext === 'jpg' ? 'jpeg' : ext}`; + const urls = await uploadEventPoster(scheduleId, filename, posterFiles[i].buffer); + const imgId = await saveImageRecord(db, urls); + uploadedIds.push(imgId); + } + await db.query( + `UPDATE schedule_event SET poster_image_ids = ? WHERE schedule_id = ?`, + [JSON.stringify(uploadedIds), scheduleId] + ); + } + + // Meilisearch 동기화 + await syncScheduleById(meilisearch, db, scheduleId); + + logActivity(db, { + actor: 'admin', action: 'create', category: 'schedule', + targetType: 'event_schedule', targetId: scheduleId, + summary: `행사 생성: ${title}`, + }); + + reply.code(201); + return { id: scheduleId }; + }); + + /** + * PUT /api/admin/events/:id + * 행사 수정 (multipart: payload + 새 poster 파일들) + * payload.keepPosterIds: 유지할 기존 포스터 ID 배열 (순서대로) + */ + fastify.put('/:id', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const { payload, posterFiles } = await parseMultipartEventForm(request); + + if (!payload) { + return reply.code(400).send({ error: 'payload가 필요합니다.' }); + } + + const [existing] = await db.query('SELECT schedule_id, poster_image_ids FROM schedule_event WHERE schedule_id = ?', [id]); + if (existing.length === 0) { + return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' }); + } + + const { + title, date, time, subtype, schoolName, + memberIds = [], venue, postUrls = [], keepPosterIds = [], + } = payload; + + await withTransaction(db, async (conn) => { + // schedules UPDATE + await conn.query( + `UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?`, + [title, date, time || null, id] + ); + + // venue upsert + const venueId = venue ? await upsertVenue(conn, venue) : null; + + // schedule_event UPDATE + await conn.query( + `UPDATE schedule_event + SET subtype = ?, school_name = ?, venue_id = ?, post_urls = ? + WHERE schedule_id = ?`, + [ + subtype, + schoolName, + venueId, + postUrls.length > 0 ? JSON.stringify(postUrls) : null, + id, + ] + ); + + // 멤버 재등록 + await conn.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); + if (memberIds.length > 0) { + const values = memberIds.map(mid => [id, mid]); + await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); + } + }); + + // 포스터: 새 파일 업로드 후 keepPosterIds + 새 id 순서로 저장 + const newIds = []; + for (let i = 0; i < posterFiles.length; i++) { + const ext = (posterFiles[i].filename.split('.').pop() || 'webp').toLowerCase(); + const filename = `${Date.now()}_${i}.${ext === 'jpg' ? 'jpeg' : ext}`; + const urls = await uploadEventPoster(id, filename, posterFiles[i].buffer); + const imgId = await saveImageRecord(db, urls); + newIds.push(imgId); + } + const finalIds = [...keepPosterIds, ...newIds]; + await db.query( + `UPDATE schedule_event SET poster_image_ids = ? WHERE schedule_id = ?`, + [finalIds.length > 0 ? JSON.stringify(finalIds) : null, id] + ); + + await addOrUpdateSchedule(meilisearch, db, parseInt(id)); + + logActivity(db, { + actor: 'admin', action: 'update', category: 'schedule', + targetType: 'event_schedule', targetId: parseInt(id), + summary: `행사 수정: ${title}`, + }); + + return { id: parseInt(id) }; + }); + + /** + * DELETE /api/admin/events/:id + */ + fastify.delete('/:id', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + const [existing] = await db.query('SELECT s.title FROM schedules s JOIN schedule_event se ON s.id = se.schedule_id WHERE s.id = ?', [id]); + if (existing.length === 0) { + return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' }); + } + + // schedules CASCADE로 schedule_event/schedule_members/schedule_images도 정리됨 + await db.query('DELETE FROM schedules WHERE id = ?', [id]); + + logActivity(db, { + actor: 'admin', action: 'delete', category: 'schedule', + targetType: 'event_schedule', targetId: parseInt(id), + summary: `행사 삭제: ${existing[0].title}`, + }); + + return { success: true }; + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index a214e61..c9a84ba 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -9,6 +9,7 @@ import xBotsRoutes from './admin/x-bots.js'; import youtubeAdminRoutes from './admin/youtube.js'; import xAdminRoutes from './admin/x.js'; import concertAdminRoutes from './admin/concert.js'; +import eventsAdminRoutes from './admin/events.js'; import varietyAdminRoutes from './admin/variety.js'; import placesAdminRoutes from './admin/places.js'; import logsAdminRoutes from './admin/logs.js'; @@ -51,6 +52,9 @@ export default async function routes(fastify) { // 관리자 - 콘서트 라우트 fastify.register(concertAdminRoutes, { prefix: '/admin/concert' }); + // 관리자 - 행사 라우트 + fastify.register(eventsAdminRoutes, { prefix: '/admin/events' }); + // 관리자 - 예능 라우트 fastify.register(varietyAdminRoutes, { prefix: '/admin/variety' }); diff --git a/backend/src/services/image.js b/backend/src/services/image.js index 80a09b8..8dd1f71 100644 --- a/backend/src/services/image.js +++ b/backend/src/services/image.js @@ -257,6 +257,27 @@ export async function uploadConcertMerchandise(seriesId, filename, buffer) { return { originalUrl, mediumUrl, thumbUrl }; } +/** + * 행사 포스터 업로드 + * @param {number} scheduleId - 일정 ID + * @param {string} filename - 파일명 (예: '01.webp') + * @param {Buffer} buffer - 이미지 버퍼 + * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>} + */ +export async function uploadEventPoster(scheduleId, filename, buffer) { + const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); + + const basePath = `event/${scheduleId}/poster`; + + const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ + uploadToS3(`${basePath}/original/${filename}`, originalBuffer), + uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer), + uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer), + ]); + + return { originalUrl, mediumUrl, thumbUrl }; +} + /** * 예능 일정 썸네일 업로드 * @param {number} scheduleId - 일정 ID diff --git a/backend/src/services/meilisearch/index.js b/backend/src/services/meilisearch/index.js index 3b87913..938b029 100644 --- a/backend/src/services/meilisearch/index.js +++ b/backend/src/services/meilisearch/index.js @@ -41,6 +41,18 @@ export async function resolveMemberNames(db, query) { return members.map(m => m.name); } +/** + * 부분 이름으로 학교명 조회 (예: "인천대" → "인천대학교") + */ +async function resolveSchoolNames(db, query) { + const searchTerm = `%${query}%`; + const [rows] = await db.query( + `SELECT DISTINCT school_name FROM schedule_event WHERE school_name LIKE ?`, + [searchTerm] + ); + return rows.map(r => r.school_name).filter(Boolean); +} + /** * 현재 활동 멤버 수 조회 및 캐시 */ @@ -97,6 +109,14 @@ export async function searchSchedules(meilisearch, db, query, options = {}) { } } + // 부분 이름 → 전체 학교명 변환 (예: "인천대" → "인천대학교") + const schoolNames = await resolveSchoolNames(db, query); + for (const name of schoolNames) { + if (!searchQueries.includes(name)) { + searchQueries.push(name); + } + } + // 각 검색어로 검색 후 병합 const allHits = new Map(); // id 기준 중복 제거 @@ -160,6 +180,8 @@ function formatScheduleResponse(hit) { source = { name: hit.source_name, url: null }; } else if (hit.category_id === CATEGORY_IDS.X) { source = { name: hit.source_name || '', url: null }; + } else if (hit.category_id === CATEGORY_IDS.EVENT && hit.source_name) { + source = { name: hit.source_name, url: null }; } return { @@ -217,12 +239,13 @@ export async function syncScheduleById(meilisearch, db, scheduleId) { s.category_id, c.name as category_name, c.color as category_color, - COALESCE(sy.channel_name, sx.username) as source_name, + COALESCE(sy.channel_name, sx.username, se.school_name) as source_name, GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names 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_event se ON s.id = se.schedule_id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0 WHERE s.id = ? @@ -291,12 +314,13 @@ export async function syncAllSchedules(meilisearch, db) { s.category_id, c.name as category_name, c.color as category_color, - COALESCE(sy.channel_name, sx.username) as source_name, + COALESCE(sy.channel_name, sx.username, se.school_name) as source_name, GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names 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_event se ON s.id = se.schedule_id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0 GROUP BY s.id diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 9f11f48..277f6f3 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -65,6 +65,13 @@ export function buildSource(schedule) { }; } + if (category_id === CATEGORY_IDS.EVENT && schedule.event_school_name) { + return { + name: schedule.event_school_name, + url: null, + }; + } + return null; } @@ -91,6 +98,12 @@ export function formatSchedule(rawSchedule, members = []) { if (rawSchedule.concert_series_id) { result.concertSeriesId = rawSchedule.concert_series_id; } + if (rawSchedule.event_subtype) { + result.eventSubtype = rawSchedule.event_subtype; + if (rawSchedule.event_school_name) { + result.schoolName = rawSchedule.event_school_name; + } + } return result; } @@ -203,13 +216,25 @@ export async function getScheduleDetail(db, id, getXProfile = null) { sx.image_urls as x_image_urls, sv.broadcaster as variety_broadcaster, sv.replay_url as variety_replay_url, - svi.medium_url as variety_thumbnail_url + svi.medium_url as variety_thumbnail_url, + se.subtype as event_subtype, + se.school_name as event_school_name, + se.post_urls as event_post_urls, + se.poster_image_ids as event_poster_image_ids, + ev.id as event_venue_id, + ev.name as event_venue_name, + ev.address as event_venue_address, + ev.road_address as event_venue_road_address, + ev.lat as event_venue_lat, + ev.lng as event_venue_lng 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_variety sv ON s.id = sv.schedule_id LEFT JOIN images svi ON sv.thumbnail_id = svi.id + LEFT JOIN schedule_event se ON s.id = se.schedule_id + LEFT JOIN event_venues ev ON se.venue_id = ev.id WHERE s.id = ? `, [id]); @@ -288,6 +313,43 @@ export async function getScheduleDetail(db, id, getXProfile = null) { result.broadcaster = s.variety_broadcaster; result.replayUrl = s.variety_replay_url || null; result.thumbnailUrl = s.variety_thumbnail_url || null; + } else if (s.category_id === CATEGORY_IDS.EVENT && s.event_subtype) { + result.subtype = s.event_subtype; + result.schoolName = s.event_school_name || null; + result.postUrls = s.event_post_urls + ? (typeof s.event_post_urls === 'string' ? JSON.parse(s.event_post_urls) : s.event_post_urls) + : []; + + const posterIds = s.event_poster_image_ids + ? (typeof s.event_poster_image_ids === 'string' ? JSON.parse(s.event_poster_image_ids) : s.event_poster_image_ids) + : []; + if (posterIds.length > 0) { + const [posterRows] = await db.query( + `SELECT id, original_url, medium_url, thumb_url FROM images WHERE id IN (?) ORDER BY FIELD(id, ?)`, + [posterIds, posterIds] + ); + result.posters = posterRows.map(p => ({ + id: p.id, + originalUrl: p.original_url, + mediumUrl: p.medium_url, + thumbUrl: p.thumb_url, + })); + } else { + result.posters = []; + } + + if (s.event_venue_id) { + result.venue = { + id: s.event_venue_id, + name: s.event_venue_name, + address: s.event_venue_address, + roadAddress: s.event_venue_road_address, + lat: s.event_venue_lat, + lng: s.event_venue_lng, + }; + } else { + result.venue = null; + } } return result; @@ -310,12 +372,15 @@ const SCHEDULE_LIST_SQL = ` sy.video_type as youtube_video_type, sx.post_id as x_post_id, sx.username as x_username, - scon.series_id as concert_series_id + scon.series_id as concert_series_id, + se.subtype as event_subtype, + se.school_name as event_school_name 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 + LEFT JOIN schedule_event se ON s.id = se.schedule_id `; /** diff --git a/frontend/src/api/admin/events.js b/frontend/src/api/admin/events.js new file mode 100644 index 0000000..c740074 --- /dev/null +++ b/frontend/src/api/admin/events.js @@ -0,0 +1,33 @@ +/** + * 관리자 행사 API + */ +import { fetchAuthApi, fetchFormData } from '@/api/client'; + +/** + * 행사 상세 조회 (수정 폼용) + */ +export async function getEvent(id) { + return fetchAuthApi(`/admin/events/${id}`); +} + +/** + * 행사 생성 + * @param {FormData} formData - payload(JSON) + poster 파일들 + */ +export async function createEvent(formData) { + return fetchFormData('/admin/events', formData, 'POST'); +} + +/** + * 행사 수정 + */ +export async function updateEvent(id, formData) { + return fetchFormData(`/admin/events/${id}`, formData, 'PUT'); +} + +/** + * 행사 삭제 + */ +export async function deleteEvent(id) { + return fetchAuthApi(`/admin/events/${id}`, { method: 'DELETE' }); +} diff --git a/frontend/src/api/admin/index.js b/frontend/src/api/admin/index.js index 8f7cacf..54a7a62 100644 --- a/frontend/src/api/admin/index.js +++ b/frontend/src/api/admin/index.js @@ -9,6 +9,7 @@ export * as adminBotApi from './bots'; export * as adminStatsApi from './stats'; export * as adminSuggestionApi from './suggestions'; export * as adminLogApi from './logs'; +export * as adminEventApi from './events'; export * as adminAuthApi from './auth'; // 개별 함수 export diff --git a/frontend/src/pages/pc/admin/schedules/form/event/index.jsx b/frontend/src/pages/pc/admin/schedules/form/event/index.jsx new file mode 100644 index 0000000..841a979 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/form/event/index.jsx @@ -0,0 +1,435 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { + Save, GraduationCap, MapPin, Link2, Image as ImageIcon, Users, X, +} from "lucide-react"; + +import Toast from "@/components/common/Toast"; +import DatePicker from "@/components/pc/admin/common/DatePicker"; +import TimePicker from "@/components/pc/admin/common/TimePicker"; +import LocationSearchDialog from "@/components/pc/admin/schedule/LocationSearchDialog"; +import { useToast } from "@/hooks/common"; +import { useAdminAuth } from "@/hooks/pc/admin"; +import { getMembers } from "@/api/public/members"; +import { createEvent } from "@/api/admin/events"; + +// 세부 타입 목록 (현재는 "학교"만) +const SUBTYPES = [ + { value: "university", label: "학교 축제" }, +]; + +function EventForm() { + 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 [subtype, setSubtype] = useState("university"); + const [title, setTitle] = useState(""); + const [schoolName, setSchoolName] = useState(""); + const [date, setDate] = useState(""); + const [time, setTime] = useState(""); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const [venue, setVenue] = useState(null); + const [venueDialogOpen, setVenueDialogOpen] = useState(false); + const [posterFiles, setPosterFiles] = useState([]); // [{file, preview}] + const [postUrls, setPostUrls] = useState([]); + const [urlInput, setUrlInput] = useState(""); + const [saving, setSaving] = useState(false); + + // 멤버 토글 + 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 = (e) => { + const files = Array.from(e.target.files || []); + const newItems = files.map((file) => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve({ file, preview: reader.result }); + reader.readAsDataURL(file); + }); + }); + Promise.all(newItems).then((items) => { + setPosterFiles((prev) => [...prev, ...items]); + }); + e.target.value = ""; + }; + const removePoster = (index) => { + setPosterFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + // URL 추가/삭제 + const addUrl = () => { + const url = urlInput.trim(); + if (!url) return; + if (!postUrls.includes(url)) { + setPostUrls([...postUrls, url]); + } + setUrlInput(""); + }; + const removeUrl = (index) => { + setPostUrls(postUrls.filter((_, i) => i !== index)); + }; + + // 제출 + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!title.trim()) { + setToast({ type: "error", message: "제목을 입력해주세요." }); + return; + } + if (!schoolName.trim()) { + setToast({ type: "error", message: "학교명을 입력해주세요." }); + return; + } + if (!date) { + setToast({ type: "error", message: "날짜를 선택해주세요." }); + return; + } + if (!venue) { + setToast({ type: "error", message: "장소를 선택해주세요." }); + return; + } + + setSaving(true); + try { + const payload = { + subtype, + title: title.trim(), + schoolName: schoolName.trim(), + date, + time: time || null, + memberIds: selectedMemberIds, + venue, + postUrls, + }; + + const formData = new FormData(); + formData.append("payload", JSON.stringify(payload)); + posterFiles.forEach((item) => { + formData.append("posters", item.file); + }); + + await createEvent(formData); + + sessionStorage.setItem( + "scheduleToast", + JSON.stringify({ type: "success", message: "행사 일정이 추가되었습니다." }) + ); + navigate("/admin/schedule"); + } catch (err) { + console.error("행사 저장 실패:", err); + setToast({ type: "error", message: err.message || "저장에 실패했습니다." }); + } finally { + setSaving(false); + } + }; + + return ( + <> + setToast(null)} /> + + + {/* 기본 정보 */} +
+

기본 정보

+
+ {/* 세부 타입 */} +
+ +
+ {SUBTYPES.map((opt) => ( + + ))} +
+
+ + {/* 제목 */} +
+ + setTitle(e.target.value)} + placeholder="예: ○○대학교 대동제 초청 공연" + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ + {/* 학교명 */} +
+ + setSchoolName(e.target.value)} + placeholder="예: 연세대학교" + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ + {/* 날짜/시간 */} +
+
+ + +
+
+ + +
+
+ + {/* 장소 */} +
+ + {venue ? ( +
+ +
+

{venue.name}

+ {venue.address && ( +

{venue.address}

+ )} +
+ + +
+ ) : ( + + )} +
+
+
+ + {/* 출연 멤버 */} +
+

+ + 출연 멤버 +

+
+ + {members.map((member) => { + const isSelected = selectedMemberIds.includes(member.id); + return ( + + ); + })} +
+
+ + {/* 포스터 */} +
+

+ + 포스터 (선택, 여러 장 가능) +

+
+ {posterFiles.map((item, idx) => ( +
+ {`poster + +
+ ))} + +
+
+ + {/* URL */} +
+

+ + 관련 URL (선택, 여러 개 가능) +

+
+ setUrlInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addUrl(); + } + }} + placeholder="https://www.instagram.com/p/... 또는 공식 페이지" + className="flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> + +
+ {postUrls.length > 0 && ( +
    + {postUrls.map((url, idx) => ( +
  • + + {url} + + +
  • + ))} +
+ )} +
+ + {/* 버튼 */} +
+ + +
+
+ + {/* 장소 검색 다이얼로그 */} + setVenueDialogOpen(false)} + onSelect={(place) => setVenue(place)} + /> + + ); +} + +export default EventForm; diff --git a/frontend/src/pages/pc/admin/schedules/form/index.jsx b/frontend/src/pages/pc/admin/schedules/form/index.jsx index 2d38f3a..ff95381 100644 --- a/frontend/src/pages/pc/admin/schedules/form/index.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/index.jsx @@ -11,6 +11,7 @@ import YouTubeForm from "./YouTubeForm"; import XForm from "./XForm"; import ConcertForm from "./concert"; import VarietyForm from "./VarietyForm"; +import EventForm from "./event"; // 애니메이션 variants const containerVariants = { @@ -79,6 +80,9 @@ function ScheduleFormPage() { case '예능': return ; + case '행사': + return ; + // 다른 카테고리는 기존 폼으로 리다이렉트 default: return (