diff --git a/backend/src/routes/admin/youtube.js b/backend/src/routes/admin/youtube.js index 34b065b..062450c 100644 --- a/backend/src/routes/admin/youtube.js +++ b/backend/src/routes/admin/youtube.js @@ -132,6 +132,110 @@ export default async function youtubeRoutes(fastify) { return reply.code(500).send({ error: err.message }); } }); + + /** + * PUT /api/admin/youtube/schedule/:id + * YouTube 일정 수정 (멤버, 영상 유형 수정 가능) + */ + fastify.put('/schedule/:id', { + schema: { + tags: ['admin/youtube'], + summary: 'YouTube 일정 수정', + security: [{ bearerAuth: [] }], + params: { + type: 'object', + properties: { + id: { type: 'integer' }, + }, + required: ['id'], + }, + body: { + type: 'object', + properties: { + memberIds: { type: 'array', items: { type: 'integer' } }, + videoType: { type: 'string', enum: ['video', 'shorts'] }, + }, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const { memberIds = [], videoType } = request.body; + + try { + // 일정 존재 확인 + const [schedules] = await db.query( + 'SELECT id, title, date, time FROM schedules WHERE id = ? AND category_id = ?', + [id, YOUTUBE_CATEGORY_ID] + ); + if (schedules.length === 0) { + return reply.code(404).send({ error: 'YouTube 일정을 찾을 수 없습니다.' }); + } + + // 영상 유형 수정 + if (videoType) { + await db.query( + 'UPDATE schedule_youtube SET video_type = ? WHERE schedule_id = ?', + [videoType, id] + ); + } + + // 기존 멤버 삭제 + await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); + + // 새 멤버 추가 + if (memberIds.length > 0) { + const values = memberIds.map(memberId => [id, memberId]); + await db.query( + 'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', + [values] + ); + } + + // 멤버 이름 조회 (Meilisearch 동기화용) + 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(','); + } + + // YouTube 채널 정보 조회 + const [youtubeInfo] = await db.query( + 'SELECT channel_name FROM schedule_youtube WHERE schedule_id = ?', + [id] + ); + const channelName = youtubeInfo[0]?.channel_name || ''; + + // 카테고리 정보 조회 + const [categoryRows] = await db.query( + 'SELECT name, color FROM schedule_categories WHERE id = ?', + [YOUTUBE_CATEGORY_ID] + ); + const category = categoryRows[0] || {}; + + // Meilisearch 동기화 + const schedule = schedules[0]; + await addOrUpdateSchedule(meilisearch, { + id: schedule.id, + title: schedule.title, + date: schedule.date, + time: schedule.time || '', + category_id: YOUTUBE_CATEGORY_ID, + category_name: category.name || '', + category_color: category.color || '', + member_names: memberNames, + source_name: channelName, + }); + + return { success: true }; + } catch (err) { + fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`); + return reply.code(500).send({ error: err.message }); + } + }); } /** diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 36fa758..36c83fe 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -112,6 +112,16 @@ export default async function schedulesRoutes(fastify) { } const s = schedules[0]; + + // 멤버 정보 조회 + const [members] = await db.query(` + SELECT m.id, m.name + FROM schedule_members sm + JOIN members m ON sm.member_id = m.id + WHERE sm.schedule_id = ? + ORDER BY m.id + `, [id]); + const result = { id: s.id, title: s.title, @@ -122,6 +132,15 @@ export default async function schedulesRoutes(fastify) { name: s.category_name, color: s.category_color, }, + members: members, + youtube: s.youtube_video_id ? { + videoId: s.youtube_video_id, + videoType: s.youtube_video_type, + channelName: s.youtube_channel, + } : null, + x: s.x_post_id ? { + postId: s.x_post_id, + } : null, created_at: s.created_at, updated_at: s.updated_at, }; @@ -256,6 +275,28 @@ async function handleMonthlySchedules(db, year, month) { ORDER BY s.date ASC, s.time ASC `, [startDate, endDate]); + // 일정 멤버 조회 + const scheduleIds = schedules.map(s => s.id); + let memberMap = {}; + + if (scheduleIds.length > 0) { + const [scheduleMembers] = await db.query(` + SELECT sm.schedule_id, m.name + FROM schedule_members sm + JOIN members m ON sm.member_id = m.id + WHERE sm.schedule_id IN (?) + ORDER BY m.id + `, [scheduleIds]); + + // 일정별 멤버 그룹화 + for (const sm of scheduleMembers) { + if (!memberMap[sm.schedule_id]) { + memberMap[sm.schedule_id] = []; + } + memberMap[sm.schedule_id].push({ name: sm.name }); + } + } + // 생일 조회 const [birthdays] = await db.query(` SELECT m.id, m.name, m.name_en, m.birth_date, @@ -281,6 +322,12 @@ async function handleMonthlySchedules(db, year, month) { }; } + // 멤버 정보 (5명 이상이면 프로미스나인) + const scheduleMembers = memberMap[s.id] || []; + const members = scheduleMembers.length >= 5 + ? [{ name: '프로미스나인' }] + : scheduleMembers; + const schedule = { id: s.id, title: s.title, @@ -290,6 +337,7 @@ async function handleMonthlySchedules(db, year, month) { name: s.category_name, color: s.category_color, }, + members, }; // source 정보 추가 (YouTube: 2, X: 3) diff --git a/docs/development.md b/docs/development.md index 1cc814c..2d5a576 100644 --- a/docs/development.md +++ b/docs/development.md @@ -164,6 +164,53 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile --- +## 프론트엔드 개발 가이드 + +### React Query 사용 (데이터 페칭) + +데이터 페칭 시 `useEffect` 대신 `useQuery`를 사용합니다. + +**이유:** +- `useEffect`는 React StrictMode에서 2번 실행됨 (개발 모드) +- `useQuery`는 자동 캐싱, 중복 요청 방지, 에러/로딩 상태 관리 제공 + +**예시:** +```jsx +// ❌ Bad - useEffect 사용 +const [data, setData] = useState(null); +const [loading, setLoading] = useState(true); + +useEffect(() => { + fetch('/api/data') + .then(res => res.json()) + .then(data => setData(data)) + .finally(() => setLoading(false)); +}, []); + +// ✅ Good - useQuery 사용 +import { useQuery } from '@tanstack/react-query'; + +const { data, isLoading } = useQuery({ + queryKey: ['data'], + queryFn: () => fetch('/api/data').then(res => res.json()), +}); +``` + +**캐시 무효화:** +```jsx +import { useQueryClient } from '@tanstack/react-query'; + +const queryClient = useQueryClient(); + +// 특정 쿼리 무효화 +queryClient.invalidateQueries({ queryKey: ['schedules'] }); + +// 모든 쿼리 무효화 +queryClient.invalidateQueries(); +``` + +--- + ## 유용한 명령어 ```bash diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8714204..6d1df21 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -42,6 +42,7 @@ import ScheduleFormPage from './pages/pc/admin/schedule/form'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict'; +import YouTubeEditForm from './pages/pc/admin/schedule/edit/YouTubeEditForm'; // 레이아웃 import PCLayout from './components/pc/Layout'; @@ -76,6 +77,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 3b60683..377b01c 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -5,7 +5,7 @@ import { Home, ChevronRight, Calendar, Plus, Edit2, Trash2, ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book } from 'lucide-react'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; @@ -27,6 +27,24 @@ const decodeHtmlEntities = (text) => { return textarea.value; }; +// 카테고리 ID 상수 +const CATEGORY_IDS = { + YOUTUBE: 2, + X: 3, +}; + +// 카테고리별 수정 경로 반환 +const getEditPath = (scheduleId, categoryId) => { + switch (categoryId) { + case CATEGORY_IDS.YOUTUBE: + return `/admin/schedule/${scheduleId}/edit/youtube`; + case CATEGORY_IDS.X: + return `/admin/schedule/${scheduleId}/edit/x`; + default: + return `/admin/schedule/${scheduleId}/edit`; + } +}; + // 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지 const ScheduleItem = memo(function ScheduleItem({ schedule, @@ -119,7 +137,7 @@ const ScheduleItem = memo(function ScheduleItem({ )}