perf: AdminSchedule 성능 최적화
- filteredSchedules useMemo로 불필요한 재계산 방지 - ScheduleItem React.memo 컴포넌트로 분리하여 리렌더링 방지 - categoryCounts useMemo 맵으로 O(1) 카테고리 카운트 조회 - 카테고리 카운트를 선택된 날짜 기준으로 계산 - useDeferredValue로 달력 점 표시 지연 처리하여 UI 응답성 향상 - selectedDate 변경 시 스크롤 맨 위로 초기화
This commit is contained in:
parent
a2f89644a6
commit
fc35a35987
1 changed files with 163 additions and 116 deletions
|
|
@ -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 (
|
||||
<motion.div
|
||||
key={`${schedule.id}-${selectedDate || 'all'}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: Math.min(index, 10) * 0.03 }}
|
||||
className="p-6 hover:bg-gray-50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-20 text-center flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{scheduleDate.getDate()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{schedule.title}</h3>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
||||
{schedule.time && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{schedule.time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={14} />
|
||||
{categoryName}
|
||||
</span>
|
||||
{schedule.source_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={14} />
|
||||
{schedule.source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{memberList.length >= 5 ? (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
memberList.map((name, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
{name.trim()}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{schedule.source_url && (
|
||||
<a
|
||||
href={schedule.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteDialog(schedule)}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
// 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
|
||||
const categoryCounts = useMemo(() => {
|
||||
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
|
||||
const counts = new Map();
|
||||
let total = 0;
|
||||
|
||||
// 검색 모드일 때 전체 일정 수
|
||||
const getTotalCount = () => {
|
||||
if (!isSearchMode || !searchTerm) {
|
||||
return schedules.length;
|
||||
source.forEach(s => {
|
||||
// 선택된 날짜가 있으면 해당 날짜만 카운트
|
||||
if (selectedDate) {
|
||||
const scheduleDate = formatDate(s.date);
|
||||
if (scheduleDate !== selectedDate) return;
|
||||
}
|
||||
return searchResults.length;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
|
|
@ -1154,99 +1282,18 @@ function AdminSchedule() {
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 일반 모드: 기존 렌더링 */
|
||||
/* 일반 모드: ScheduleItem 컴포넌트 사용 */
|
||||
filteredSchedules.map((schedule, index) => (
|
||||
<motion.div
|
||||
<ScheduleItem
|
||||
key={`${schedule.id}-${selectedDate || 'all'}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: Math.min(index, 10) * 0.03 }}
|
||||
className="p-6 hover:bg-gray-50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-20 text-center flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{new Date(schedule.date).getDate()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||
style={{ backgroundColor: getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
|
||||
schedule={schedule}
|
||||
index={index}
|
||||
selectedDate={selectedDate}
|
||||
categories={categories}
|
||||
getColorStyle={getColorStyle}
|
||||
navigate={navigate}
|
||||
openDeleteDialog={openDeleteDialog}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{schedule.title}</h3>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
||||
{schedule.time && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{schedule.time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={14} />
|
||||
{categories.find(c => c.id === schedule.category_id)?.name || '미분류'}
|
||||
</span>
|
||||
{schedule.source_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={14} />
|
||||
{schedule.source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
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 (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{memberList.length >= 5 ? (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
memberList.map((name, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
{name.trim()}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{schedule.source_url && (
|
||||
<a
|
||||
href={schedule.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteDialog(schedule)}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue