import { useState, useEffect, useRef, useMemo, memo, useDeferredValue } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Home, ChevronRight, Calendar, Plus, Edit2, Trash2, ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2 } from 'lucide-react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; import Toast from '../../../components/Toast'; import Tooltip from '../../../components/Tooltip'; import AdminLayout from '../../../components/admin/AdminLayout'; import ConfirmDialog from '../../../components/admin/ConfirmDialog'; import useScheduleStore from '../../../stores/useScheduleStore'; 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) => { if (!text) return ''; const textarea = document.createElement('textarea'); textarea.innerHTML = text; return textarea.value; }; // 일정 아이템 컴포넌트 - 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()]}요일

{decodeHtmlEntities(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()} )) )}
)}
{schedule.source_url && ( e.stopPropagation()} className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" > )}
); }); function AdminSchedule() { const navigate = useNavigate(); // Zustand 스토어에서 상태 가져오기 const { searchInput, setSearchInput, searchTerm, setSearchTerm, isSearchMode, setIsSearchMode, selectedCategories, setSelectedCategories, selectedDate, setSelectedDate, currentDate, setCurrentDate, scrollPosition, setScrollPosition, } = useScheduleStore(); // 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들) const [loading, setLoading] = useState(false); const [user, setUser] = useState(null); const { toast, setToast } = useToast(); const scrollContainerRef = useRef(null); const SEARCH_LIMIT = 20; // 페이지당 20개 const ESTIMATED_ITEM_HEIGHT = 100; // 아이템 추정 높이 (동적 측정) // Intersection Observer for infinite scroll const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px', }); // useInfiniteQuery for search const { data: searchData, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading: searchLoading, } = useInfiniteQuery({ queryKey: ['adminScheduleSearch', searchTerm], queryFn: async ({ pageParam = 0 }) => { const response = await fetch( `/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}` ); if (!response.ok) throw new Error('Search failed'); return response.json(); }, getNextPageParam: (lastPage) => { if (lastPage.hasMore) { return lastPage.offset + lastPage.schedules.length; } return undefined; }, enabled: !!searchTerm && isSearchMode, }); // Flatten search results const searchResults = useMemo(() => { if (!searchData?.pages) return []; return searchData.pages.flatMap(page => page.schedules); }, [searchData]); const searchTotal = searchData?.pages?.[0]?.total || 0; // Auto fetch next page when scrolled to bottom useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { fetchNextPage(); } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); // selectedDate가 없으면 오늘 날짜로 초기화 useEffect(() => { if (!selectedDate) { setSelectedDate(getTodayKST()); } }, []); const [slideDirection, setSlideDirection] = useState(0); // 년월 선택 관련 (Schedule.jsx와 동일한 패턴) const [showYearMonthPicker, setShowYearMonthPicker] = useState(false); const [showCategoryTooltip, setShowCategoryTooltip] = useState(false); const [viewMode, setViewMode] = useState('yearMonth'); // 'yearMonth' | 'months' const pickerRef = useRef(null); const categoryTooltipRef = useRef(null); // 달력 관련 const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const days = ['일', '월', '화', '수', '목', '금', '토']; const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; // 년도 범위 (현재 년도 기준 10년 단위 - Schedule.jsx와 동일) const startYear = Math.floor(year / 10) * 10 - 1; const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i); // 현재 년도/월 확인 함수 const isCurrentYear = (y) => new Date().getFullYear() === y; const isCurrentMonth = (m) => { const today = new Date(); return today.getFullYear() === year && today.getMonth() === m; }; 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 colorMap = { blue: 'bg-blue-500', green: 'bg-green-500', purple: 'bg-purple-500', red: 'bg-red-500', pink: 'bg-pink-500', yellow: 'bg-yellow-500', orange: 'bg-orange-500', gray: 'bg-gray-500', }; // 색상 스타일 (기본 색상 또는 커스텀 HEX) const getColorStyle = (color) => { if (!color) return { className: 'bg-gray-500' }; if (color.startsWith('#')) { return { style: { backgroundColor: color } }; } return { className: colorMap[color] || 'bg-gray-500' }; }; // 카테고리별 색상 (배지용) const getCategoryColor = (color) => { const colors = { blue: 'bg-blue-100 text-blue-700', green: 'bg-green-100 text-green-700', purple: 'bg-purple-100 text-purple-700', red: 'bg-red-100 text-red-700', pink: 'bg-pink-100 text-pink-700', yellow: 'bg-yellow-100 text-yellow-700', orange: 'bg-orange-100 text-orange-700', gray: 'bg-gray-100 text-gray-700', }; if (color?.startsWith('#')) { return 'bg-gray-100 text-gray-700'; } return colors[color] || 'bg-gray-100 text-gray-700'; }; // 일정 데이터를 지연 처리하여 달력 UI 응답성 향상 const deferredSchedules = useDeferredValue(schedules); // 일정 날짜별 맵 (O(1) 조회용) - 지연된 데이터로 점 표시 const scheduleDateMap = useMemo(() => { const map = new Map(); deferredSchedules.forEach(s => { const dateStr = formatDate(s.date); if (!map.has(dateStr)) { map.set(dateStr, s); } }); return map; }, [deferredSchedules]); // 해당 날짜에 일정이 있는지 확인 (O(1)) const hasSchedule = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return scheduleDateMap.has(dateStr); }; // 해당 날짜의 첫 번째 일정 카테고리 색상 (O(1)) const getScheduleColor = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const schedule = scheduleDateMap.get(dateStr); if (!schedule) return null; const cat = categories.find(c => c.id === schedule.category_id); return cat?.color || '#4A7C59'; }; useEffect(() => { const token = localStorage.getItem('adminToken'); const userData = localStorage.getItem('adminUser'); if (!token || !userData) { navigate('/admin'); return; } setUser(JSON.parse(userData)); // 카테고리 로드 fetchCategories(); // sessionStorage에서 토스트 메시지 확인 (일정 추가/수정 완료 시) const savedToast = sessionStorage.getItem('scheduleToast'); if (savedToast) { setToast(JSON.parse(savedToast)); sessionStorage.removeItem('scheduleToast'); } }, [navigate]); // 월이 변경될 때마다 일정 로드 useEffect(() => { fetchSchedules(); }, [year, month]); // 스크롤 위치 복원 useEffect(() => { if (scrollContainerRef.current && scrollPosition > 0) { scrollContainerRef.current.scrollTop = scrollPosition; } }, [loading]); // 로딩이 끝나면 스크롤 복원 // 날짜 변경 시 스크롤 맨 위로 초기화 useEffect(() => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } }, [selectedDate]); // 스크롤 위치 저장 const handleScroll = (e) => { setScrollPosition(e.target.scrollTop); }; // 카테고리 로드 함수 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); try { const data = await schedulesApi.getSchedules(year, month + 1); setSchedules(data); } catch (error) { console.error('일정 로드 오류:', error); } finally { setLoading(false); } }; // 외부 클릭 시 피커 닫기 useEffect(() => { const handleClickOutside = (event) => { if (pickerRef.current && !pickerRef.current.contains(event.target)) { setShowYearMonthPicker(false); setViewMode('yearMonth'); } if (categoryTooltipRef.current && !categoryTooltipRef.current.contains(event.target)) { setShowCategoryTooltip(false); } }; if (showYearMonthPicker || showCategoryTooltip) { document.addEventListener('mousedown', handleClickOutside); } return () => document.removeEventListener('mousedown', handleClickOutside); }, [showYearMonthPicker, showCategoryTooltip]); // 월 이동 const prevMonth = () => { setSlideDirection(-1); const newDate = new Date(year, month - 1, 1); setCurrentDate(newDate); // 이번달이면 오늘, 다른 달이면 1일 선택 const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { setSelectedDate(getTodayKST()); } else { const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; setSelectedDate(firstDay); } setSchedules([]); // 이전 달 데이터 즉시 초기화 }; const nextMonth = () => { setSlideDirection(1); const newDate = new Date(year, month + 1, 1); setCurrentDate(newDate); // 이번달이면 오늘, 다른 달이면 1일 선택 const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { setSelectedDate(getTodayKST()); } else { const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; setSelectedDate(firstDay); } setSchedules([]); // 이전 달 데이터 즉시 초기화 }; // 년도 범위 이동 const prevYearRange = () => setCurrentDate(new Date(year - 10, month, 1)); const nextYearRange = () => setCurrentDate(new Date(year + 10, month, 1)); // 년도 선택 시 월 선택 모드로 전환 const selectYear = (newYear) => { setCurrentDate(new Date(newYear, month, 1)); setViewMode('months'); }; // 월 선택 시 적용 후 닫기 const selectMonth = (newMonth) => { const newDate = new Date(year, newMonth, 1); setCurrentDate(newDate); // 이번달이면 오늘, 다른 달이면 1일 선택 const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { setSelectedDate(getTodayKST()); } else { const firstDay = `${year}-${String(newMonth + 1).padStart(2, '0')}-01`; setSelectedDate(firstDay); } setShowYearMonthPicker(false); setViewMode('yearMonth'); }; // 날짜 선택 (토글 없이 항상 선택) const selectDate = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; setSelectedDate(dateStr); }; // 삭제 관련 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [scheduleToDelete, setScheduleToDelete] = useState(null); const [deleting, setDeleting] = useState(false); // 삭제 확인 다이얼로그 열기 const openDeleteDialog = (schedule) => { setScheduleToDelete(schedule); setDeleteDialogOpen(true); }; // 일정 삭제 const handleDelete = async () => { if (!scheduleToDelete) return; setDeleting(true); try { await schedulesApi.deleteSchedule(scheduleToDelete.id); setToast({ type: 'success', message: '일정이 삭제되었습니다.' }); fetchSchedules(); } catch (error) { console.error('삭제 오류:', error); setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' }); } finally { setDeleting(false); setDeleteDialogOpen(false); setScheduleToDelete(null); } }; // 검색어 정규화 (대소문자, 띄어쓰기, 특수문자 무시) const normalizeForSearch = (str) => { return (str || '').toLowerCase().replace(/[\s\-_.,!?#@]/g, ''); }; // 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링) - useMemo로 최적화 const filteredSchedules = useMemo(() => { if (isSearchMode) { if (!searchTerm) return []; // 카테고리 필터링 적용 if (selectedCategories.length === 0) return searchResults; return searchResults.filter(s => selectedCategories.includes(s.category_id)); } // 일반 모드: 로컬 필터링 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 virtualizer = useVirtualizer({ count: isSearchMode && searchTerm ? filteredSchedules.length : 0, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ESTIMATED_ITEM_HEIGHT, overscan: 5, // 버퍼 아이템 수 }); // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준 const categoryCounts = useMemo(() => { const source = (isSearchMode && searchTerm) ? searchResults : schedules; const counts = new Map(); let total = 0; source.forEach(s => { // 검색 모드가 아닐 때만 선택된 날짜 기준으로 필터링 if (!isSearchMode && 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' ? total : (categoryCounts.get(category.id) || 0) })) .filter(category => category.id === 'all' || category.count > 0) .sort((a, b) => { if (a.id === 'all') return -1; if (b.id === 'all') return 1; if (a.name === '기타') return 1; if (b.name === '기타') return -1; return b.count - a.count; }); }, [categories, categoryCounts]); return ( setToast(null)} /> {/* 삭제 확인 다이얼로그 */} setDeleteDialogOpen(false)} onConfirm={handleDelete} title="일정 삭제" message={ <>

다음 일정을 삭제하시겠습니까?

{scheduleToDelete?.title}

이 작업은 되돌릴 수 없습니다.

} loading={deleting} /> {/* 메인 콘텐츠 - 전체 높이 차지 */}
{/* 브레드크럼 */}
일정 관리
{/* 타이틀 + 추가 버튼 */}

일정 관리

fromis_9의 일정을 관리합니다

{/* 왼쪽: 달력 + 카테고리 필터 */}
{/* 달력 (Schedule.jsx와 동일한 패턴) */} {/* 달력 헤더 */}
{/* 년/월 선택 팝업 (Schedule.jsx와 동일한 스타일) */} {showYearMonthPicker && ( {/* 헤더 - 년도 범위 이동 */}
{viewMode === 'yearMonth' ? `${yearRange[0]} - ${yearRange[yearRange.length - 1]}` : `${year}년`}
{viewMode === 'yearMonth' && ( {/* 년도 선택 */}
년도
{yearRange.map((y) => ( ))}
{/* 월 선택 */}
{monthNames.map((m, i) => ( ))}
)} {viewMode === 'months' && ( {/* 월 선택 */}
월 선택
{monthNames.map((m, i) => ( ))}
)}
)}
{/* 요일 헤더 + 날짜 그리드 */} {/* 요일 헤더 */}
{days.map((day, i) => (
{day}
))}
{/* 날짜 그리드 */}
{/* 전달 날짜 */} {Array.from({ length: firstDay }).map((_, i) => { const prevMonthDays = getDaysInMonth(year, month - 1); const day = prevMonthDays - firstDay + i + 1; return (
{day}
); })} {/* 현재 달 날짜 */} {Array.from({ length: daysInMonth }).map((_, i) => { const day = i + 1; const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const isSelected = selectedDate === dateStr; const hasEvent = hasSchedule(day); const eventColor = getScheduleColor(day); const dayOfWeek = (firstDay + i) % 7; const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); // 해당 날짜의 일정 목록 (점 표시용, 최대 3개) const daySchedules = schedules.filter(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return scheduleDate === dateStr; }).slice(0, 3); return ( ); })} {/* 다음달 날짜 */} {(() => { const totalCells = firstDay + daysInMonth; const remainder = totalCells % 7; const nextDays = remainder === 0 ? 0 : 7 - remainder; return Array.from({ length: nextDays }).map((_, i) => (
{i + 1}
)); })()}
{/* 범례 */}
일정 있음
{/* 카테고리 필터 */}

카테고리

{/* 카테고리 - useMemo로 정렬됨 */} {sortedCategories.map(category => { const isSelected = category.id === 'all' ? selectedCategories.length === 0 : selectedCategories.includes(category.id); const handleClick = () => { if (category.id === 'all') { setSelectedCategories([]); } else { if (selectedCategories.includes(category.id)) { setSelectedCategories(selectedCategories.filter(id => id !== category.id)); } else { setSelectedCategories([...selectedCategories, category.id]); } } }; return ( ); })}
{/* 오른쪽: 일정 목록 */}
{/* 일정 목록 */}
{isSearchMode ? ( /* 검색 모드 */ setSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { setSearchTerm(searchInput); } else if (e.key === 'Escape') { setIsSearchMode(false); setSearchInput(''); setSearchTerm(''); setSearchResults([]); } }} className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400" /> ) : ( /* 일반 모드 */ {selectedDate ? (() => { const d = new Date(selectedDate); const dayNames = ['일', '월', '화', '수', '목', '금', '토']; return `${d.getMonth() + 1}월 ${d.getDate()}일 ${dayNames[d.getDay()]}요일`; })() : `${month + 1}월 전체 일정` }
{/* 카테고리 필터 */} {selectedCategories.length > 0 && (
{showCategoryTooltip && ( {selectedCategories.map(id => { const cat = categories.find(c => c.id === id); if (!cat) return null; return (
{cat.name}
); })}
)}
)} {filteredSchedules.length}개 일정 )}
{loading ? (
) : filteredSchedules.length === 0 ? (

등록된 일정이 없습니다

) : (
{isSearchMode && searchTerm ? ( /* 검색 모드: 가상 스크롤 */ <>
{virtualizer.getVirtualItems().map((virtualItem) => { const schedule = filteredSchedules[virtualItem.index]; if (!schedule) return null; return (
{new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1}
{new Date(schedule.date).getDate()}
{['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일
c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }} />

{decodeHtmlEntities(schedule.title)}

{schedule.time && ( {schedule.time.slice(0, 5)} )} {categories.find(c => c.id === schedule.category_id)?.name || '미분류'} {schedule.source_name && ( {schedule.source_name} )}
{schedule.member_names && (
{schedule.member_names.split(',').length >= 5 ? ( 프로미스나인 ) : ( schedule.member_names.split(',').map((name, i) => ( {name.trim()} )) )}
)}
{schedule.source_url && ( e.stopPropagation()} className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" > )}
); })}
{/* 무한 스크롤 트리거 & 로딩 인디케이터 */}
{isFetchingNextPage && (
)} {!hasNextPage && filteredSchedules.length > 0 && (
{filteredSchedules.length}개 표시 (모두 로드됨)
)}
) : ( /* 일반 모드: ScheduleItem 컴포넌트 사용 */ filteredSchedules.map((schedule, index) => ( )) )}
)}
); } export default AdminSchedule;