feat(schedule): 행사 카테고리 추가 (학교 행사)

- schedule_categories에 '행사' 카테고리(id=11) 시드, CATEGORY_IDS.EVENT 상수 추가
- event_venues / schedule_event 테이블 생성 (subtype, school_name, venue_id, post_urls, poster_image_ids)
- routes/admin/events.js 신설: multipart 기반 CRUD + 다중 포스터 업로드 + 카카오맵 venue upsert
- services/image.js에 uploadEventPoster 추가 (event/{scheduleId}/poster/...)
- 공개 /schedules 서비스의 SCHEDULE_LIST_SQL / getScheduleDetail에 행사 JOIN 및 응답(subtype, schoolName, venue, posters, postUrls)
- buildSource에 EVENT 분기 추가 → source.name = 학교명
- Meilisearch 동기화: source_name에 school_name 포함, 부분 검색 대응을 위한 resolveSchoolNames 추가
- 프론트: form/index.jsx에 '행사' 분기, EventForm 컴포넌트 신설 (LocationSearchDialog 재사용, 다중 포스터/URL)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-23 09:04:42 +09:00
parent f2a15e07d6
commit d9836d2f5d
11 changed files with 978 additions and 4 deletions

View file

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

View file

@ -6,6 +6,7 @@ export const CATEGORY_IDS = {
VARIETY: 10,
BIRTHDAY: 8,
DEBUT: 9,
EVENT: 11,
};
// 데뷔일 (fromis_9: 2018년 1월 24일)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<>
<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"
>
{/* 기본 정보 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6">기본 정보</h2>
<div className="space-y-4">
{/* 세부 타입 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
세부 타입
</label>
<div className="flex flex-wrap gap-2">
{SUBTYPES.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSubtype(opt.value)}
className={`px-4 py-2 rounded-lg text-sm border transition-colors ${
subtype === opt.value
? "border-primary bg-primary text-white"
: "border-gray-200 text-gray-600 hover:border-gray-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 제목 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
제목 *
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
{/* 학교명 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<GraduationCap size={14} />
학교명 *
</label>
<input
type="text"
value={schoolName}
onChange={(e) => 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"
/>
</div>
{/* 날짜/시간 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-700 mb-1.5 block">날짜 *</label>
<DatePicker value={date} onChange={setDate} />
</div>
<div>
<label className="text-sm font-medium text-gray-700 mb-1.5 block">시간 (선택)</label>
<TimePicker value={time} onChange={setTime} />
</div>
</div>
{/* 장소 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<MapPin size={14} />
장소 *
</label>
{venue ? (
<div className="flex items-start gap-3 p-3 border border-gray-200 rounded-lg">
<MapPin size={16} className="text-primary mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{venue.name}</p>
{venue.address && (
<p className="text-sm text-gray-500 truncate">{venue.address}</p>
)}
</div>
<button
type="button"
onClick={() => setVenueDialogOpen(true)}
className="text-sm text-primary hover:underline"
>
변경
</button>
<button
type="button"
onClick={() => setVenue(null)}
className="text-gray-400 hover:text-red-500"
>
<X size={16} />
</button>
</div>
) : (
<button
type="button"
onClick={() => setVenueDialogOpen(true)}
className="w-full px-3 py-2.5 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
>
장소 검색
</button>
)}
</div>
</div>
</div>
{/* 출연 멤버 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6">
<Users size={18} />
출연 멤버
</h2>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={toggleAllMembers}
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
selectedMemberIds.length === members.length
? "border-primary bg-primary text-white"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}
</button>
{members.map((member) => {
const isSelected = selectedMemberIds.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleMember(member.id)}
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
isSelected ? "border-primary" : "border-gray-200"
}`}
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
{member.image_url ? (
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-300" />
)}
</div>
<span className="text-sm text-gray-700">{member.name}</span>
</button>
);
})}
</div>
</div>
{/* 포스터 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
<ImageIcon size={18} />
포스터 <span className="text-sm font-normal text-gray-400">(선택, 여러 가능)</span>
</h2>
<div className="flex flex-wrap gap-3">
{posterFiles.map((item, idx) => (
<div key={idx} className="relative">
<img src={item.preview} alt={`poster ${idx}`} className="h-32 w-32 object-cover rounded-lg border border-gray-200" />
<button
type="button"
onClick={() => removePoster(idx)}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600"
>
</button>
</div>
))}
<label className="flex items-center justify-center h-32 w-32 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors">
<div className="text-center">
<ImageIcon size={20} className="mx-auto text-gray-400 mb-1" />
<span className="text-xs text-gray-400">추가</span>
</div>
<input type="file" accept="image/*" multiple className="hidden" onChange={handlePosterChange} />
</label>
</div>
</div>
{/* URL */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
<Link2 size={18} />
관련 URL <span className="text-sm font-normal text-gray-400">(선택, 여러 가능)</span>
</h2>
<div className="flex gap-2 mb-3">
<input
type="url"
value={urlInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={addUrl}
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
추가
</button>
</div>
{postUrls.length > 0 && (
<ul className="space-y-1.5">
{postUrls.map((url, idx) => (
<li key={idx} className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-lg">
<a href={url} target="_blank" rel="noreferrer" className="flex-1 truncate text-sm text-gray-600 hover:underline">
{url}
</a>
<button
type="button"
onClick={() => removeUrl(idx)}
className="text-gray-400 hover:text-red-500"
>
<X size={16} />
</button>
</li>
))}
</ul>
)}
</div>
{/* 버튼 */}
<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>
{/* 장소 검색 다이얼로그 */}
<LocationSearchDialog
isOpen={venueDialogOpen}
onClose={() => setVenueDialogOpen(false)}
onSelect={(place) => setVenue(place)}
/>
</>
);
}
export default EventForm;

View file

@ -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 <VarietyForm />;
case '행사':
return <EventForm />;
//
default:
return (