From b89255780e0e11be2df16ea07a5ddc47a5cf0420 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 17 Jan 2026 17:52:15 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20API=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=ED=98=95=EC=8B=9D=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 응답(날짜별 그룹화)을 플랫 배열로 변환하는 로직 추가 - 별도 카테고리 API 호출 제거, 일정 데이터에서 카테고리 추출 - PC/모바일 Schedule.jsx, AdminSchedule.jsx 수정 Co-Authored-By: Claude Opus 4.5 --- frontend/src/api/admin/categories.js | 2 +- frontend/src/api/admin/schedules.js | 18 ++++- frontend/src/api/public/schedules.js | 23 ++++-- frontend/src/pages/mobile/public/Schedule.jsx | 23 ++++-- frontend/src/pages/pc/admin/AdminSchedule.jsx | 40 +++++----- frontend/src/pages/pc/public/Schedule.jsx | 73 +++++++++++-------- 6 files changed, 113 insertions(+), 66 deletions(-) diff --git a/frontend/src/api/admin/categories.js b/frontend/src/api/admin/categories.js index 05992b0..fe2ab78 100644 --- a/frontend/src/api/admin/categories.js +++ b/frontend/src/api/admin/categories.js @@ -5,7 +5,7 @@ import { fetchAdminApi } from "../index"; // 카테고리 목록 조회 export async function getCategories() { - return fetchAdminApi("/api/admin/schedule-categories"); + return fetchAdminApi("/api/schedules/categories"); } // 카테고리 생성 diff --git a/frontend/src/api/admin/schedules.js b/frontend/src/api/admin/schedules.js index 433c721..15418dd 100644 --- a/frontend/src/api/admin/schedules.js +++ b/frontend/src/api/admin/schedules.js @@ -5,7 +5,23 @@ import { fetchAdminApi, fetchAdminFormData } from "../index"; // 일정 목록 조회 (월별) export async function getSchedules(year, month) { - return fetchAdminApi(`/api/admin/schedules?year=${year}&month=${month}`); + const data = await fetchAdminApi(`/api/schedules?year=${year}&month=${month}`); + + // 날짜별 그룹화된 응답을 플랫 배열로 변환 + const schedules = []; + for (const [date, dayData] of Object.entries(data)) { + for (const schedule of dayData.schedules) { + const category = schedule.category || {}; + schedules.push({ + ...schedule, + date, + category_id: category.id, + category_name: category.name, + category_color: category.color, + }); + } + } + return schedules; } // 일정 검색 (Meilisearch) diff --git a/frontend/src/api/public/schedules.js b/frontend/src/api/public/schedules.js index 372e85e..0eeb301 100644 --- a/frontend/src/api/public/schedules.js +++ b/frontend/src/api/public/schedules.js @@ -6,7 +6,23 @@ import { getTodayKST } from "../../utils/date"; // 일정 목록 조회 (월별) export async function getSchedules(year, month) { - return fetchApi(`/api/schedules?year=${year}&month=${month}`); + const data = await fetchApi(`/api/schedules?year=${year}&month=${month}`); + + // 날짜별 그룹화된 응답을 플랫 배열로 변환 + const schedules = []; + for (const [date, dayData] of Object.entries(data)) { + for (const schedule of dayData.schedules) { + const category = schedule.category || {}; + schedules.push({ + ...schedule, + date, + category_id: category.id, + category_name: category.name, + category_color: category.color, + }); + } + } + return schedules; } // 다가오는 일정 조회 (오늘 이후) @@ -29,11 +45,6 @@ export async function getSchedule(id) { return fetchApi(`/api/schedules/${id}`); } -// 카테고리 목록 조회 -export async function getCategories() { - return fetchApi("/api/schedule-categories"); -} - // X 프로필 정보 조회 export async function getXProfile(username) { return fetchApi(`/api/schedules/x-profile/${encodeURIComponent(username)}`); diff --git a/frontend/src/pages/mobile/public/Schedule.jsx b/frontend/src/pages/mobile/public/Schedule.jsx index 7bcb54c..6de6543 100644 --- a/frontend/src/pages/mobile/public/Schedule.jsx +++ b/frontend/src/pages/mobile/public/Schedule.jsx @@ -7,7 +7,7 @@ import { useInView } from 'react-intersection-observer'; import { useVirtualizer } from '@tanstack/react-virtual'; import confetti from 'canvas-confetti'; import { getTodayKST } from '../../../utils/date'; -import { getSchedules, getCategories, searchSchedules } from '../../../api/public/schedules'; +import { getSchedules, searchSchedules } from '../../../api/public/schedules'; import useScheduleStore from '../../../stores/useScheduleStore'; // 폭죽 애니메이션 함수 @@ -265,18 +265,27 @@ function MobileSchedule() { const viewYear = selectedDate.getFullYear(); const viewMonth = selectedDate.getMonth() + 1; - // 카테고리 데이터 로드 - const { data: categories = [] } = useQuery({ - queryKey: ['scheduleCategories'], - queryFn: getCategories, - }); - // 월별 일정 데이터 로드 const { data: schedules = [], isLoading: loading } = useQuery({ queryKey: ['schedules', viewYear, viewMonth], queryFn: () => getSchedules(viewYear, viewMonth), }); + // 카테고리는 일정 데이터에서 추출 + const categories = useMemo(() => { + const categoryMap = new Map(); + schedules.forEach(s => { + if (s.category_id && !categoryMap.has(s.category_id)) { + categoryMap.set(s.category_id, { + id: s.category_id, + name: s.category_name, + color: s.category_color, + }); + } + }); + return Array.from(categoryMap.values()); + }, [schedules]); + // 달력 표시용 일정 데이터 (calendarViewDate 기준) const calendarYear = calendarViewDate.getFullYear(); const calendarMonth = calendarViewDate.getMonth() + 1; diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index ff2c9f9..4f4969d 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -18,7 +18,6 @@ import useAdminAuth from '../../../hooks/useAdminAuth'; import useToast from '../../../hooks/useToast'; import { getTodayKST, formatDate } from '../../../utils/date'; import * as schedulesApi from '../../../api/admin/schedules'; -import * as categoriesApi from '../../../api/admin/categories'; // HTML 엔티티 디코딩 함수 const decodeHtmlEntities = (text) => { @@ -287,14 +286,27 @@ function AdminSchedule() { const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); - // 카테고리 목록 (API에서 로드) - const [categories, setCategories] = useState([ - { id: 'all', name: '전체', color: 'gray' } - ]); - // 일정 목록 (API에서 로드) const [schedules, setSchedules] = useState([]); + // 카테고리는 일정 데이터에서 추출 + const categories = useMemo(() => { + const categoryMap = new Map(); + schedules.forEach(s => { + if (s.category_id && !categoryMap.has(s.category_id)) { + categoryMap.set(s.category_id, { + id: s.category_id, + name: s.category_name, + color: s.category_color, + }); + } + }); + return [ + { id: 'all', name: '전체', color: 'gray' }, + ...Array.from(categoryMap.values()) + ]; + }, [schedules]); + // 카테고리 색상 맵핑 const colorMap = { blue: 'bg-blue-500', @@ -367,9 +379,6 @@ function AdminSchedule() { useEffect(() => { if (!isAuthenticated) return; - // 카테고리 로드 - fetchCategories(); - // sessionStorage에서 토스트 메시지 확인 (일정 추가/수정 완료 시) const savedToast = sessionStorage.getItem('scheduleToast'); if (savedToast) { @@ -404,19 +413,6 @@ function AdminSchedule() { }; - // 카테고리 로드 함수 - const fetchCategories = async () => { - try { - const data = await categoriesApi.getCategories(); - setCategories([ - { id: 'all', name: '전체', color: 'gray' }, - ...data - ]); - } catch (error) { - console.error('카테고리 로드 오류:', error); - } - }; - // 일정 로드 함수 const fetchSchedules = async () => { setLoading(true); diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index 14f23f3..10fff83 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -7,7 +7,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; import confetti from 'canvas-confetti'; import { getTodayKST } from '../../../utils/date'; -import { getSchedules, getCategories, searchSchedules as searchSchedulesApi } from '../../../api/public/schedules'; +import { getSchedules } from '../../../api/public/schedules'; import useScheduleStore from '../../../stores/useScheduleStore'; // HTML 엔티티 디코딩 함수 @@ -158,12 +158,6 @@ function Schedule() { const [slideDirection, setSlideDirection] = useState(0); const pickerRef = useRef(null); - // 카테고리 데이터 로드 (useQuery) - const { data: categories = [] } = useQuery({ - queryKey: ['scheduleCategories'], - queryFn: getCategories, - }); - // 월별 일정 데이터 로드 (useQuery) const year = currentDate.getFullYear(); const month = currentDate.getMonth(); @@ -172,6 +166,21 @@ function Schedule() { queryFn: () => getSchedules(year, month + 1), }); + // 카테고리는 일정 데이터에서 추출 + const categories = useMemo(() => { + const categoryMap = new Map(); + schedules.forEach(s => { + if (s.category_id && !categoryMap.has(s.category_id)) { + categoryMap.set(s.category_id, { + id: s.category_id, + name: s.category_name, + color: s.category_color, + }); + } + }); + return Array.from(categoryMap.values()); + }, [schedules]); + // 오늘 생일이 있으면 폭죽 발사 (하루에 한 번만) useEffect(() => { if (loading || schedules.length === 0) return; @@ -354,6 +363,8 @@ function Schedule() { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const schedule = scheduleDateMap.get(dateStr); if (!schedule) return null; + // schedule에서 직접 색상 가져오기 + if (schedule.category_color) return schedule.category_color; const cat = categories.find(c => c.id === schedule.category_id); return cat?.color || '#4A7C59'; }; @@ -420,11 +431,11 @@ function Schedule() { // 카테고리 토글 const toggleCategory = (categoryId) => { - setSelectedCategories(prev => - prev.includes(categoryId) - ? prev.filter(id => id !== categoryId) - : [...prev, categoryId] - ); + if (selectedCategories.includes(categoryId)) { + setSelectedCategories(selectedCategories.filter(id => id !== categoryId)); + } else { + setSelectedCategories([...selectedCategories, categoryId]); + } }; // 필터링된 스케줄 (useMemo로 성능 최적화, 시간순 정렬) @@ -475,7 +486,7 @@ function Schedule() { const source = (isSearchMode && searchTerm) ? searchResults : schedules; const counts = new Map(); let total = 0; - + source.forEach(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; // 검색 모드에서 검색어가 있을 때는 전체 대상 @@ -483,12 +494,14 @@ function Schedule() { if (!(isSearchMode && searchTerm) && selectedDate) { if (scheduleDate !== selectedDate) return; } - + const catId = s.category_id; - counts.set(catId, (counts.get(catId) || 0) + 1); - total++; + if (catId) { + counts.set(catId, (counts.get(catId) || 0) + 1); + total++; + } }); - + counts.set('total', total); return counts; }, [schedules, searchResults, isSearchMode, searchTerm, selectedDate]); @@ -553,16 +566,18 @@ function Schedule() { return `${names.slice(0, 2).join(', ')} 외 ${names.length - 2}개`; }; - // 카테고리 색상 가져오기 - const getCategoryColor = (categoryId) => { + // 카테고리 색상 가져오기 (schedule 객체에서 직접 가져오거나 categories에서 조회) + const getCategoryColor = useCallback((categoryId, schedule = null) => { + if (schedule?.category_color) return schedule.category_color; const cat = categories.find(c => c.id === categoryId); return cat?.color || '#808080'; - }; + }, [categories]); - const getCategoryName = (categoryId) => { + const getCategoryName = useCallback((categoryId, schedule = null) => { + if (schedule?.category_name) return schedule.category_name; const cat = categories.find(c => c.id === categoryId); return cat?.name || ''; - }; + }, [categories]); // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지) const sortedCategories = useMemo(() => { @@ -786,10 +801,10 @@ function Schedule() { {!isSelected && daySchedules.length > 0 && ( {daySchedules.map((schedule, idx) => ( - ))} @@ -1124,10 +1139,10 @@ function Schedule() { {virtualizer.getVirtualItems().map((virtualItem) => { const schedule = filteredSchedules[virtualItem.index]; if (!schedule) return null; - + const formatted = formatDate(schedule.date); - const categoryColor = getCategoryColor(schedule.category_id); - const categoryName = getCategoryName(schedule.category_id); + const categoryColor = getCategoryColor(schedule.category_id, schedule); + const categoryName = getCategoryName(schedule.category_id, schedule); return (
{ const formatted = formatDate(schedule.date); - const categoryColor = getCategoryColor(schedule.category_id); - const categoryName = getCategoryName(schedule.category_id); + const categoryColor = getCategoryColor(schedule.category_id, schedule); + const categoryName = getCategoryName(schedule.category_id, schedule); return (