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 { useNavigate, Link } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +13,112 @@ import Tooltip from '../../../components/Tooltip';
|
||||||
import useScheduleStore from '../../../stores/useScheduleStore';
|
import useScheduleStore from '../../../stores/useScheduleStore';
|
||||||
import { getTodayKST, formatDate } from '../../../utils/date';
|
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() {
|
function AdminSchedule() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -166,17 +272,20 @@ function AdminSchedule() {
|
||||||
return colors[color] || 'bg-gray-100 text-gray-700';
|
return colors[color] || 'bg-gray-100 text-gray-700';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 일정 날짜별 맵 (O(1) 조회용) - 성능 최적화
|
// 일정 데이터를 지연 처리하여 달력 UI 응답성 향상
|
||||||
|
const deferredSchedules = useDeferredValue(schedules);
|
||||||
|
|
||||||
|
// 일정 날짜별 맵 (O(1) 조회용) - 지연된 데이터로 점 표시
|
||||||
const scheduleDateMap = useMemo(() => {
|
const scheduleDateMap = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
schedules.forEach(s => {
|
deferredSchedules.forEach(s => {
|
||||||
const dateStr = formatDate(s.date);
|
const dateStr = formatDate(s.date);
|
||||||
if (!map.has(dateStr)) {
|
if (!map.has(dateStr)) {
|
||||||
map.set(dateStr, s);
|
map.set(dateStr, s);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [schedules]);
|
}, [deferredSchedules]);
|
||||||
|
|
||||||
// 해당 날짜에 일정이 있는지 확인 (O(1))
|
// 해당 날짜에 일정이 있는지 확인 (O(1))
|
||||||
const hasSchedule = (day) => {
|
const hasSchedule = (day) => {
|
||||||
|
|
@ -245,6 +354,13 @@ function AdminSchedule() {
|
||||||
}
|
}
|
||||||
}, [loading]); // 로딩이 끝나면 스크롤 복원
|
}, [loading]); // 로딩이 끝나면 스크롤 복원
|
||||||
|
|
||||||
|
// 날짜 변경 시 스크롤 맨 위로 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
// 스크롤 위치 저장
|
// 스크롤 위치 저장
|
||||||
const handleScroll = (e) => {
|
const handleScroll = (e) => {
|
||||||
setScrollPosition(e.target.scrollTop);
|
setScrollPosition(e.target.scrollTop);
|
||||||
|
|
@ -415,38 +531,50 @@ function AdminSchedule() {
|
||||||
return (str || '').toLowerCase().replace(/[\s\-_.,!?#@]/g, '');
|
return (str || '').toLowerCase().replace(/[\s\-_.,!?#@]/g, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링)
|
// 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링) - useMemo로 최적화
|
||||||
const filteredSchedules = isSearchMode
|
const filteredSchedules = useMemo(() => {
|
||||||
? (searchTerm ? searchResults : []) // 검색 모드: 검색 전엔 빈 목록, 검색 후엔 API 결과
|
if (isSearchMode) {
|
||||||
: schedules.filter(schedule => { // 일반 모드: 로컬 필터링
|
return searchTerm ? searchResults : [];
|
||||||
|
}
|
||||||
|
// 일반 모드: 로컬 필터링
|
||||||
|
return schedules.filter(schedule => {
|
||||||
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id);
|
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id);
|
||||||
const scheduleDate = formatDate(schedule.date);
|
const scheduleDate = formatDate(schedule.date);
|
||||||
const matchesDate = !selectedDate || scheduleDate === selectedDate;
|
const matchesDate = !selectedDate || scheduleDate === selectedDate;
|
||||||
return matchesCategory && matchesDate;
|
return matchesCategory && matchesDate;
|
||||||
});
|
});
|
||||||
|
}, [isSearchMode, searchTerm, searchResults, schedules, selectedCategories, selectedDate]);
|
||||||
|
|
||||||
// 검색 모드일 때 카테고리별 검색 결과 카운트 계산
|
// 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
|
||||||
const getSearchCategoryCount = (categoryId) => {
|
const categoryCounts = useMemo(() => {
|
||||||
if (!isSearchMode || !searchTerm) {
|
const source = (isSearchMode && searchTerm) ? searchResults : schedules;
|
||||||
return schedules.filter(s => s.category_id === categoryId).length;
|
const counts = new Map();
|
||||||
}
|
let total = 0;
|
||||||
return searchResults.filter(s => s.category_id === categoryId).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색 모드일 때 전체 일정 수
|
source.forEach(s => {
|
||||||
const getTotalCount = () => {
|
// 선택된 날짜가 있으면 해당 날짜만 카운트
|
||||||
if (!isSearchMode || !searchTerm) {
|
if (selectedDate) {
|
||||||
return schedules.length;
|
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 sortedCategories = useMemo(() => {
|
||||||
|
const total = categoryCounts.get('total') || 0;
|
||||||
|
|
||||||
return categories
|
return categories
|
||||||
.map(category => ({
|
.map(category => ({
|
||||||
...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)
|
.filter(category => category.id === 'all' || category.count > 0)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|
@ -456,7 +584,7 @@ function AdminSchedule() {
|
||||||
if (b.name === '기타') return -1;
|
if (b.name === '기타') return -1;
|
||||||
return b.count - a.count;
|
return b.count - a.count;
|
||||||
});
|
});
|
||||||
}, [categories, schedules, searchResults, isSearchMode, searchTerm]);
|
}, [categories, categoryCounts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
|
@ -1154,99 +1282,18 @@ function AdminSchedule() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* 일반 모드: 기존 렌더링 */
|
/* 일반 모드: ScheduleItem 컴포넌트 사용 */
|
||||||
filteredSchedules.map((schedule, index) => (
|
filteredSchedules.map((schedule, index) => (
|
||||||
<motion.div
|
<ScheduleItem
|
||||||
key={`${schedule.id}-${selectedDate || 'all'}`}
|
key={`${schedule.id}-${selectedDate || 'all'}`}
|
||||||
initial={{ opacity: 0 }}
|
schedule={schedule}
|
||||||
animate={{ opacity: 1 }}
|
index={index}
|
||||||
transition={{ delay: Math.min(index, 10) * 0.03 }}
|
selectedDate={selectedDate}
|
||||||
className="p-6 hover:bg-gray-50 transition-colors group"
|
categories={categories}
|
||||||
>
|
getColorStyle={getColorStyle}
|
||||||
<div className="flex items-start gap-4">
|
navigate={navigate}
|
||||||
<div className="w-20 text-center flex-shrink-0">
|
openDeleteDialog={openDeleteDialog}
|
||||||
<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' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue