diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx
index d29e14e..69b6f0a 100644
--- a/frontend/src/pages/pc/admin/AdminSchedule.jsx
+++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useMemo } from 'react';
+import { useState, useEffect, useRef, useMemo, memo, useDeferredValue } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
@@ -13,6 +13,112 @@ import Tooltip from '../../../components/Tooltip';
import useScheduleStore from '../../../stores/useScheduleStore';
import { getTodayKST, formatDate } from '../../../utils/date';
+// 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지
+const ScheduleItem = memo(function ScheduleItem({
+ schedule,
+ index,
+ selectedDate,
+ categories,
+ getColorStyle,
+ navigate,
+ openDeleteDialog
+}) {
+ const scheduleDate = new Date(schedule.date);
+ const categoryColor = getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280';
+ const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
+ const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
+ const memberList = memberNames.split(',').filter(name => name.trim());
+
+ return (
+
+
+
+
+ {scheduleDate.getDate()}
+
+
+ {['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일
+
+
+
+
+
+
+
{schedule.title}
+
+ {schedule.time && (
+
+
+ {schedule.time.slice(0, 5)}
+
+ )}
+
+
+ {categoryName}
+
+ {schedule.source_name && (
+
+
+ {schedule.source_name}
+
+ )}
+
+ {memberList.length > 0 && (
+
+ {memberList.length >= 5 ? (
+
+ 프로미스나인
+
+ ) : (
+ memberList.map((name, i) => (
+
+ {name.trim()}
+
+ ))
+ )}
+
+ )}
+
+
+
+
+
+ );
+});
+
function AdminSchedule() {
const navigate = useNavigate();
@@ -166,17 +272,20 @@ function AdminSchedule() {
return colors[color] || 'bg-gray-100 text-gray-700';
};
- // 일정 날짜별 맵 (O(1) 조회용) - 성능 최적화
+ // 일정 데이터를 지연 처리하여 달력 UI 응답성 향상
+ const deferredSchedules = useDeferredValue(schedules);
+
+ // 일정 날짜별 맵 (O(1) 조회용) - 지연된 데이터로 점 표시
const scheduleDateMap = useMemo(() => {
const map = new Map();
- schedules.forEach(s => {
+ deferredSchedules.forEach(s => {
const dateStr = formatDate(s.date);
if (!map.has(dateStr)) {
map.set(dateStr, s);
}
});
return map;
- }, [schedules]);
+ }, [deferredSchedules]);
// 해당 날짜에 일정이 있는지 확인 (O(1))
const hasSchedule = (day) => {
@@ -245,6 +354,13 @@ function AdminSchedule() {
}
}, [loading]); // 로딩이 끝나면 스크롤 복원
+ // 날짜 변경 시 스크롤 맨 위로 초기화
+ useEffect(() => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTop = 0;
+ }
+ }, [selectedDate]);
+
// 스크롤 위치 저장
const handleScroll = (e) => {
setScrollPosition(e.target.scrollTop);
@@ -415,38 +531,50 @@ function AdminSchedule() {
return (str || '').toLowerCase().replace(/[\s\-_.,!?#@]/g, '');
};
- // 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링)
- const filteredSchedules = isSearchMode
- ? (searchTerm ? searchResults : []) // 검색 모드: 검색 전엔 빈 목록, 검색 후엔 API 결과
- : schedules.filter(schedule => { // 일반 모드: 로컬 필터링
+ // 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링) - useMemo로 최적화
+ const filteredSchedules = useMemo(() => {
+ if (isSearchMode) {
+ return searchTerm ? searchResults : [];
+ }
+ // 일반 모드: 로컬 필터링
+ return schedules.filter(schedule => {
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id);
const scheduleDate = formatDate(schedule.date);
const matchesDate = !selectedDate || scheduleDate === selectedDate;
return matchesCategory && matchesDate;
});
+ }, [isSearchMode, searchTerm, searchResults, schedules, selectedCategories, selectedDate]);
- // 검색 모드일 때 카테고리별 검색 결과 카운트 계산
- const getSearchCategoryCount = (categoryId) => {
- if (!isSearchMode || !searchTerm) {
- return schedules.filter(s => s.category_id === categoryId).length;
- }
- return searchResults.filter(s => s.category_id === categoryId).length;
- };
-
- // 검색 모드일 때 전체 일정 수
- const getTotalCount = () => {
- if (!isSearchMode || !searchTerm) {
- return schedules.length;
- }
- return searchResults.length;
- };
+ // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
+ const categoryCounts = useMemo(() => {
+ const source = (isSearchMode && searchTerm) ? searchResults : schedules;
+ const counts = new Map();
+ let total = 0;
+
+ source.forEach(s => {
+ // 선택된 날짜가 있으면 해당 날짜만 카운트
+ if (selectedDate) {
+ const scheduleDate = formatDate(s.date);
+ if (scheduleDate !== selectedDate) return;
+ }
+
+ const catId = s.category_id;
+ counts.set(catId, (counts.get(catId) || 0) + 1);
+ total++;
+ });
+
+ counts.set('total', total);
+ return counts;
+ }, [schedules, searchResults, isSearchMode, searchTerm, selectedDate]);
// 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지)
const sortedCategories = useMemo(() => {
+ const total = categoryCounts.get('total') || 0;
+
return categories
.map(category => ({
...category,
- count: category.id === 'all' ? getTotalCount() : getSearchCategoryCount(category.id)
+ count: category.id === 'all' ? total : (categoryCounts.get(category.id) || 0)
}))
.filter(category => category.id === 'all' || category.count > 0)
.sort((a, b) => {
@@ -456,7 +584,7 @@ function AdminSchedule() {
if (b.name === '기타') return -1;
return b.count - a.count;
});
- }, [categories, schedules, searchResults, isSearchMode, searchTerm]);
+ }, [categories, categoryCounts]);
return (
@@ -1154,99 +1282,18 @@ function AdminSchedule() {
>
) : (
- /* 일반 모드: 기존 렌더링 */
+ /* 일반 모드: ScheduleItem 컴포넌트 사용 */
filteredSchedules.map((schedule, index) => (
-
-
-
-
- {new Date(schedule.date).getDate()}
-
-
- {['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일
-
-
-
-
c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
- />
-
-
-
{schedule.title}
-
- {schedule.time && (
-
-
- {schedule.time.slice(0, 5)}
-
- )}
-
-
- {categories.find(c => c.id === schedule.category_id)?.name || '미분류'}
-
- {schedule.source_name && (
-
-
- {schedule.source_name}
-
- )}
-
- {(() => {
- const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
- const memberList = memberNames.split(',').filter(name => name.trim());
- if (memberList.length === 0) return null;
- return (
-
- {memberList.length >= 5 ? (
-
- 프로미스나인
-
- ) : (
- memberList.map((name, i) => (
-
- {name.trim()}
-
- ))
- )}
-
- );
- })()}
-
-
-
-
-
+ schedule={schedule}
+ index={index}
+ selectedDate={selectedDate}
+ categories={categories}
+ getColorStyle={getColorStyle}
+ navigate={navigate}
+ openDeleteDialog={openDeleteDialog}
+ />
))
)}