일정 관리 기능 개선: 봇 스케줄러 리팩토링, 멤버 표시, UI 개선

- 봇 스케줄러: 서버 시작 시 자동 초기화, 10초 간격 상태 동기화
- DB 리팩토링: bots 테이블에서 YouTube 컬럼 분리, bot_youtube_config 활용
- 봇별 커스텀 설정: BOT_CUSTOM_CONFIG 상수로 코드 내 관리
- 공개/관리자 일정 목록에 멤버 태그 표시 (5명 이상이면 '프로미스나인')
- 일정 목록 글씨 크기 증가 및 UI 개선
- source_name 관리자 일정에 뱃지로 표시
- 봇 시작/정지 토스트에 봇 이름 포함
This commit is contained in:
caadiq 2026-01-06 00:27:35 +09:00
parent 0c91abd722
commit 52332babea
9 changed files with 356 additions and 34 deletions

View file

@ -1176,7 +1176,7 @@ router.get("/schedules", async (req, res) => {
const [schedules] = await pool.query( const [schedules] = await pool.query(
`SELECT `SELECT
s.id, s.title, s.date, s.time, s.end_date, s.end_time, s.id, s.title, s.date, s.time, s.end_date, s.end_time,
s.category_id, s.description, s.source_url, s.category_id, s.description, s.source_url, s.source_name,
s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng, s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng,
s.created_at, s.created_at,
c.name as category_name, c.color as category_color c.name as category_name, c.color as category_color
@ -1233,6 +1233,7 @@ router.post(
category, category,
description, description,
url, url,
sourceName,
members, members,
locationName, locationName,
locationAddress, locationAddress,
@ -1249,9 +1250,9 @@ router.post(
// 일정 삽입 // 일정 삽입
const [scheduleResult] = await connection.query( const [scheduleResult] = await connection.query(
`INSERT INTO schedules `INSERT INTO schedules
(title, date, time, end_date, end_time, category_id, description, source_url, (title, date, time, end_date, end_time, category_id, description, source_url, source_name,
location_name, location_address, location_detail, location_lat, location_lng) location_name, location_address, location_detail, location_lat, location_lng)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
title, title,
date, date,
@ -1261,6 +1262,7 @@ router.post(
category || null, category || null,
description || null, description || null,
url || null, url || null,
sourceName || null,
locationName || null, locationName || null,
locationAddress || null, locationAddress || null,
locationDetail || null, locationDetail || null,
@ -1426,6 +1428,7 @@ router.put(
category, category,
description, description,
url, url,
sourceName,
members, members,
locationName, locationName,
locationAddress, locationAddress,
@ -1451,6 +1454,7 @@ router.put(
category_id = ?, category_id = ?,
description = ?, description = ?,
source_url = ?, source_url = ?,
source_name = ?,
location_name = ?, location_name = ?,
location_address = ?, location_address = ?,
location_detail = ?, location_detail = ?,
@ -1466,6 +1470,7 @@ router.put(
category || null, category || null,
description || null, description || null,
url || null, url || null,
sourceName || null,
locationName || null, locationName || null,
locationAddress || null, locationAddress || null,
locationDetail || null, locationDetail || null,
@ -1662,7 +1667,12 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => {
// 봇 목록 조회 // 봇 목록 조회
router.get("/bots", authenticateToken, async (req, res) => { router.get("/bots", authenticateToken, async (req, res) => {
try { try {
const [bots] = await pool.query(`SELECT * FROM bots ORDER BY id ASC`); const [bots] = await pool.query(`
SELECT b.*, c.channel_id, c.rss_url, c.channel_name
FROM bots b
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
ORDER BY b.id ASC
`);
res.json(bots); res.json(bots);
} catch (error) { } catch (error) {
console.error("봇 목록 조회 오류:", error); console.error("봇 목록 조회 오류:", error);

View file

@ -15,11 +15,16 @@ router.get("/", async (req, res) => {
s.time, s.time,
s.category_id, s.category_id,
s.source_url, s.source_url,
s.source_name,
s.location_name, s.location_name,
c.name as category_name, c.name as category_name,
c.color as category_color c.color as category_color,
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
FROM schedules s FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
LEFT JOIN members m ON sm.member_id = m.id
GROUP BY s.id
ORDER BY s.date DESC, s.time DESC ORDER BY s.date DESC, s.time DESC
`); `);

View file

@ -6,6 +6,7 @@ import albumsRouter from "./routes/albums.js";
import statsRouter from "./routes/stats.js"; import statsRouter from "./routes/stats.js";
import adminRouter from "./routes/admin.js"; import adminRouter from "./routes/admin.js";
import schedulesRouter from "./routes/schedules.js"; import schedulesRouter from "./routes/schedules.js";
import { initScheduler } from "./services/youtube-scheduler.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -40,6 +41,14 @@ app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "dist", "index.html")); res.sendFile(path.join(__dirname, "dist", "index.html"));
}); });
app.listen(PORT, () => { app.listen(PORT, async () => {
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`); console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
// YouTube 봇 스케줄러 초기화
try {
await initScheduler();
console.log("📺 YouTube 봇 스케줄러 초기화 완료");
} catch (error) {
console.error("YouTube 스케줄러 초기화 오류:", error);
}
}); });

View file

@ -5,12 +5,37 @@ import pool from "../lib/db.js";
const YOUTUBE_API_KEY = const YOUTUBE_API_KEY =
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
// RSS 파서 설정 // 봇별 커스텀 설정 (DB 대신 코드에서 관리)
// botId를 키로 사용
const BOT_CUSTOM_CONFIG = {
// MUSINSA TV: 제목에 '성수기' 포함된 영상만, 이채영 기본 멤버, description에서 멤버 추출
3: {
titleFilter: "성수기",
defaultMemberId: 7, // 이채영
extractMembersFromDesc: true,
},
};
/**
* 커스텀 설정 조회
*/
function getBotCustomConfig(botId) {
return (
BOT_CUSTOM_CONFIG[botId] || {
titleFilter: null,
defaultMemberId: null,
extractMembersFromDesc: false,
}
);
}
// RSS 파서 설정 (media:description 포함)
const rssParser = new Parser({ const rssParser = new Parser({
customFields: { customFields: {
item: [ item: [
["yt:videoId", "videoId"], ["yt:videoId", "videoId"],
["yt:channelId", "channelId"], ["yt:channelId", "channelId"],
["media:group", "mediaGroup"],
], ],
}, },
}); });
@ -89,9 +114,16 @@ export async function parseRSSFeed(rssUrl) {
const videoType = getVideoType(link); const videoType = getVideoType(link);
const publishedAt = toKST(new Date(item.pubDate)); const publishedAt = toKST(new Date(item.pubDate));
// media:group에서 description 추출
let description = "";
if (item.mediaGroup && item.mediaGroup["media:description"]) {
description = item.mediaGroup["media:description"][0] || "";
}
return { return {
videoId, videoId,
title: item.title, title: item.title,
description,
publishedAt, publishedAt,
date: formatDate(publishedAt), date: formatDate(publishedAt),
time: formatTime(publishedAt), time: formatTime(publishedAt),
@ -186,6 +218,7 @@ export async function fetchAllVideosFromAPI(channelId) {
videos.push({ videos.push({
videoId, videoId,
title: snippet.title, title: snippet.title,
description: snippet.description || "",
publishedAt, publishedAt,
date: formatDate(publishedAt), date: formatDate(publishedAt),
time: formatTime(publishedAt), time: formatTime(publishedAt),
@ -209,8 +242,17 @@ export async function fetchAllVideosFromAPI(channelId) {
/** /**
* 영상을 일정으로 추가 (source_url로 중복 체크) * 영상을 일정으로 추가 (source_url로 중복 체크)
* @param {Object} video - 영상 정보
* @param {number} categoryId - 카테고리 ID
* @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택)
* @param {string} sourceName - 출처 이름 (선택)
*/ */
export async function createScheduleFromVideo(video, categoryId) { export async function createScheduleFromVideo(
video,
categoryId,
memberIds = [],
sourceName = null
) {
try { try {
// source_url로 중복 체크 // source_url로 중복 체크
const [existing] = await pool.query( const [existing] = await pool.query(
@ -224,25 +266,82 @@ export async function createScheduleFromVideo(video, categoryId) {
// 일정 생성 // 일정 생성
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO schedules (title, date, time, category_id, source_url) `INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[video.title, video.date, video.time, categoryId, video.videoUrl] [
video.title,
video.date,
video.time,
categoryId,
video.videoUrl,
sourceName,
]
); );
return result.insertId; const scheduleId = result.insertId;
// 멤버 연결
if (memberIds.length > 0) {
const uniqueMemberIds = [...new Set(memberIds)]; // 중복 제거
const memberValues = uniqueMemberIds.map((memberId) => [
scheduleId,
memberId,
]);
await pool.query(
`INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`,
[memberValues]
);
}
return scheduleId;
} catch (error) { } catch (error) {
console.error("일정 생성 오류:", error); console.error("일정 생성 오류:", error);
throw error; throw error;
} }
} }
/**
* 멤버 이름 목록 조회
*/
async function getMemberNameMap() {
const [members] = await pool.query("SELECT id, name FROM members");
const nameMap = {};
for (const m of members) {
nameMap[m.name] = m.id;
}
return nameMap;
}
/**
* description에서 멤버 이름 추출
*/
function extractMemberIdsFromDescription(description, memberNameMap) {
if (!description) return [];
const memberIds = [];
for (const [name, id] of Object.entries(memberNameMap)) {
if (description.includes(name)) {
memberIds.push(id);
}
}
return memberIds;
}
/** /**
* 봇의 영상 동기화 (RSS 기반) * 봇의 영상 동기화 (RSS 기반)
*/ */
export async function syncNewVideos(botId) { export async function syncNewVideos(botId) {
try { try {
// 봇 정보 조회 (bots 테이블에서 직접) // 봇 정보 조회 (bot_youtube_config 조인)
const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]); const [bots] = await pool.query(
`
SELECT b.*, c.channel_id, c.rss_url
FROM bots b
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
WHERE b.id = ?
`,
[botId]
);
if (bots.length === 0) { if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다."); throw new Error("봇을 찾을 수 없습니다.");
@ -254,16 +353,53 @@ export async function syncNewVideos(botId) {
throw new Error("RSS URL이 설정되지 않았습니다."); throw new Error("RSS URL이 설정되지 않았습니다.");
} }
// 봇별 커스텀 설정 조회
const customConfig = getBotCustomConfig(botId);
const categoryId = await getYoutubeCategory(); const categoryId = await getYoutubeCategory();
// RSS 피드 파싱 // RSS 피드 파싱
const videos = await parseRSSFeed(bot.rss_url); const videos = await parseRSSFeed(bot.rss_url);
let addedCount = 0; let addedCount = 0;
for (const video of videos) { // 멤버 추출을 위한 이름 맵 조회 (필요 시)
// Shorts도 포함하여 일정으로 추가 let memberNameMap = null;
if (customConfig.extractMembersFromDesc) {
memberNameMap = await getMemberNameMap();
}
const scheduleId = await createScheduleFromVideo(video, categoryId); for (const video of videos) {
// 제목 필터 적용 (설정된 경우)
if (
customConfig.titleFilter &&
!video.title.includes(customConfig.titleFilter)
) {
continue; // 필터에 맞지 않으면 스킵
}
// 멤버 ID 수집
const memberIds = [];
// 기본 멤버 추가
if (customConfig.defaultMemberId) {
memberIds.push(customConfig.defaultMemberId);
}
// description에서 멤버 추출 (설정된 경우)
if (customConfig.extractMembersFromDesc && memberNameMap) {
const extractedIds = extractMemberIdsFromDescription(
video.description,
memberNameMap
);
memberIds.push(...extractedIds);
}
const scheduleId = await createScheduleFromVideo(
video,
categoryId,
memberIds,
bot.name
);
if (scheduleId) { if (scheduleId) {
addedCount++; addedCount++;
} }
@ -299,8 +435,16 @@ export async function syncNewVideos(botId) {
*/ */
export async function syncAllVideos(botId) { export async function syncAllVideos(botId) {
try { try {
// 봇 정보 조회 (bots 테이블에서 직접) // 봇 정보 조회 (bot_youtube_config 조인)
const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]); const [bots] = await pool.query(
`
SELECT b.*, c.channel_id, c.rss_url
FROM bots b
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
WHERE b.id = ?
`,
[botId]
);
if (bots.length === 0) { if (bots.length === 0) {
throw new Error("봇을 찾을 수 없습니다."); throw new Error("봇을 찾을 수 없습니다.");
@ -312,16 +456,53 @@ export async function syncAllVideos(botId) {
throw new Error("Channel ID가 설정되지 않았습니다."); throw new Error("Channel ID가 설정되지 않았습니다.");
} }
// 봇별 커스텀 설정 조회
const customConfig = getBotCustomConfig(botId);
const categoryId = await getYoutubeCategory(); const categoryId = await getYoutubeCategory();
// API로 전체 영상 수집 // API로 전체 영상 수집
const videos = await fetchAllVideosFromAPI(bot.channel_id); const videos = await fetchAllVideosFromAPI(bot.channel_id);
let addedCount = 0; let addedCount = 0;
for (const video of videos) { // 멤버 추출을 위한 이름 맵 조회 (필요 시)
// Shorts도 포함하여 일정으로 추가 let memberNameMap = null;
if (customConfig.extractMembersFromDesc) {
memberNameMap = await getMemberNameMap();
}
const scheduleId = await createScheduleFromVideo(video, categoryId); for (const video of videos) {
// 제목 필터 적용 (설정된 경우)
if (
customConfig.titleFilter &&
!video.title.includes(customConfig.titleFilter)
) {
continue; // 필터에 맞지 않으면 스킵
}
// 멤버 ID 수집
const memberIds = [];
// 기본 멤버 추가
if (customConfig.defaultMemberId) {
memberIds.push(customConfig.defaultMemberId);
}
// description에서 멤버 추출 (설정된 경우)
if (customConfig.extractMembersFromDesc && memberNameMap) {
const extractedIds = extractMemberIdsFromDescription(
video.description,
memberNameMap
);
memberIds.push(...extractedIds);
}
const scheduleId = await createScheduleFromVideo(
video,
categoryId,
memberIds,
bot.name
);
if (scheduleId) { if (scheduleId) {
addedCount++; addedCount++;
} }

View file

@ -5,6 +5,13 @@ import { syncNewVideos } from "./youtube-bot.js";
// 봇별 스케줄러 인스턴스 저장 // 봇별 스케줄러 인스턴스 저장
const schedulers = new Map(); const schedulers = new Map();
/**
* 봇이 메모리에서 실행 중인지 확인
*/
export function isBotRunning(botId) {
return schedulers.has(botId);
}
/** /**
* 개별 스케줄 등록 * 개별 스케줄 등록
*/ */
@ -40,6 +47,30 @@ export function unregisterBot(botId) {
} }
} }
/**
* 10 간격으로 메모리 상태와 DB status 동기화
*/
async function syncBotStatuses() {
try {
const [bots] = await pool.query("SELECT id, status FROM bots");
for (const bot of bots) {
const isRunningInMemory = schedulers.has(bot.id);
const isRunningInDB = bot.status === "running";
// 메모리에 없는데 DB가 running이면 → 서버 크래시 등으로 불일치, DB를 stopped로 업데이트
if (!isRunningInMemory && isRunningInDB) {
await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [
bot.id,
]);
console.log(`[Scheduler] Bot ${bot.id} 상태 동기화: stopped`);
}
}
} catch (error) {
console.error("[Scheduler] 상태 동기화 오류:", error.message);
}
}
/** /**
* 서버 시작 실행 중인 봇들 스케줄 등록 * 서버 시작 실행 중인 봇들 스케줄 등록
*/ */
@ -54,6 +85,10 @@ export async function initScheduler() {
} }
console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`); console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`);
// 10초 간격으로 상태 동기화 (DB status와 메모리 상태 일치 유지)
setInterval(syncBotStatuses, 10000);
console.log(`[Scheduler] 10초 간격 상태 동기화 시작`);
} catch (error) { } catch (error) {
console.error("[Scheduler] 초기화 오류:", error); console.error("[Scheduler] 초기화 오류:", error);
} }
@ -102,4 +137,5 @@ export default {
unregisterBot, unregisterBot,
startBot, startBot,
stopBot, stopBot,
isBotRunning,
}; };

View file

@ -726,23 +726,55 @@ function Schedule() {
<div className="flex-1 p-6 flex flex-col justify-center"> <div className="flex-1 p-6 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3> <h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
<div className="flex flex-wrap gap-3 text-sm text-gray-500"> <div className="flex flex-wrap gap-3 text-base text-gray-500">
{schedule.time && ( {schedule.time && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock size={14} style={{ color: categoryColor }} /> <Clock size={16} style={{ color: categoryColor }} />
<span>{schedule.time.slice(0, 5)}</span> <span>{schedule.time.slice(0, 5)}</span>
</div> </div>
)} )}
{categoryName && ( {categoryName && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span <span
className="w-2 h-2 rounded-full" className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: categoryColor }} style={{ backgroundColor: categoryColor }}
/> />
<span>{categoryName}</span> <span>
{categoryName}
{schedule.source_name && ` · ${schedule.source_name}`}
</span>
</div> </div>
)} )}
</div> </div>
{/* 멤버 태그 (별도 줄) */}
{(() => {
const memberList = schedule.members
? schedule.members.map(m => m.name)
: (schedule.member_names ? schedule.member_names.split(',') : []);
if (memberList.length === 0) return null;
// 5 ''
if (memberList.length >= 5) {
return (
<div className="flex flex-wrap gap-1.5 mt-2">
<span className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
프로미스나인
</span>
</div>
);
}
//
return (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
{name}
</span>
))}
</div>
);
})()}
</div> </div>
</motion.div> </motion.div>
); );

View file

@ -952,10 +952,33 @@ function AdminSchedule() {
> >
{schedule.category_name || '미지정'} {schedule.category_name || '미지정'}
</span> </span>
{schedule.source_name && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">
{schedule.source_name}
</span>
)}
<span className="text-sm text-gray-400">{schedule.time?.slice(0, 5)}</span> <span className="text-sm text-gray-400">{schedule.time?.slice(0, 5)}</span>
</div> </div>
<h4 className="font-medium text-gray-900 mb-1">{schedule.title}</h4> <h4 className="font-medium text-gray-900 mb-2">{schedule.title}</h4>
<p className="text-sm text-gray-500">{schedule.description}</p> {schedule.description && (
<p className="text-sm text-gray-500 mb-1">{schedule.description}</p>
)}
{/* 멤버 태그 */}
{schedule.members && schedule.members.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{schedule.members.length >= 5 ? (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span>
) : (
schedule.members.map((member) => (
<span key={member.id} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
{member.name}
</span>
))
)}
</div>
)}
</div> </div>
{/* 액션 버튼 */} {/* 액션 버튼 */}

View file

@ -63,7 +63,7 @@ function AdminScheduleBots() {
}; };
// / // /
const toggleBot = async (botId, currentStatus) => { const toggleBot = async (botId, currentStatus, botName) => {
try { try {
const token = localStorage.getItem('adminToken'); const token = localStorage.getItem('adminToken');
const action = currentStatus === 'running' ? 'stop' : 'start'; const action = currentStatus === 'running' ? 'stop' : 'start';
@ -76,7 +76,7 @@ function AdminScheduleBots() {
if (response.ok) { if (response.ok) {
setToast({ setToast({
type: 'success', type: 'success',
message: action === 'start' ? '봇이 시작되었습니다.' : '봇이 정지되었습니다.' message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`
}); });
fetchBots(); // fetchBots(); //
} else { } else {
@ -173,6 +173,19 @@ function AdminScheduleBots() {
}); });
}; };
// ( //)
const formatInterval = (minutes) => {
if (!minutes) return '-';
if (minutes >= 1440) {
const days = Math.floor(minutes / 1440);
return `${days}`;
} else if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
return `${hours}시간`;
}
return `${minutes}`;
};
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Toast toast={toast} onClose={() => setToast(null)} /> <Toast toast={toast} onClose={() => setToast(null)} />
@ -298,9 +311,7 @@ function AdminScheduleBots() {
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 mb-3"> <p className="text-sm text-gray-500 mb-3">
채널: {bot.channel_name || bot.channel_id} | 채널: {bot.channel_name || bot.channel_id} | {formatInterval(bot.check_interval)} 간격
{bot.include_shorts ? ' Shorts 포함' : ' Shorts 제외'} |
{bot.check_interval} 간격
</p> </p>
{/* 메타 정보 */} {/* 메타 정보 */}
@ -343,7 +354,7 @@ function AdminScheduleBots() {
)} )}
</button> </button>
<button <button
onClick={() => toggleBot(bot.id, bot.status)} onClick={() => toggleBot(bot.id, bot.status, bot.name)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${ className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
bot.status === 'running' bot.status === 'running'
? 'bg-gray-100 text-gray-600 hover:bg-gray-200' ? 'bg-gray-100 text-gray-600 hover:bg-gray-200'

View file

@ -619,6 +619,7 @@ function AdminScheduleForm() {
category: '', category: '',
description: '', description: '',
url: '', url: '',
sourceName: '',
members: [], members: [],
images: [], images: [],
// //
@ -757,6 +758,7 @@ function AdminScheduleForm() {
category: data.category_id || '', category: data.category_id || '',
description: data.description || '', description: data.description || '',
url: data.source_url || '', url: data.source_url || '',
sourceName: data.source_name || '',
members: data.members?.map(m => m.id) || [], members: data.members?.map(m => m.id) || [],
images: [], images: [],
locationName: data.location_name || '', locationName: data.location_name || '',
@ -989,6 +991,7 @@ function AdminScheduleForm() {
category: formData.category, category: formData.category,
description: formData.description.trim() || null, description: formData.description.trim() || null,
url: formData.url.trim() || null, url: formData.url.trim() || null,
sourceName: formData.sourceName.trim() || null,
members: formData.members, members: formData.members,
locationName: formData.locationName.trim() || null, locationName: formData.locationName.trim() || null,
locationAddress: formData.locationAddress.trim() || null, locationAddress: formData.locationAddress.trim() || null,
@ -1462,6 +1465,18 @@ function AdminScheduleForm() {
/> />
</div> </div>
</div> </div>
{/* 출처 이름 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">출처 이름</label>
<input
type="text"
value={formData.sourceName}
onChange={(e) => setFormData({ ...formData, sourceName: e.target.value })}
placeholder="예: 스프:스튜디오 프로미스나인, MUSINSA TV"
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div> </div>
</div> </div>