일정 관리
fromis_9의 일정을 관리합니다
카테고리
등록된 일정이 없습니다
import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { LogOut, Home, ChevronRight, Calendar, Plus, Edit2, Trash2, ChevronLeft, Search, ChevronDown, AlertTriangle, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2 } from 'lucide-react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useInView } from 'react-intersection-observer'; import Toast from '../../../components/Toast'; import Tooltip from '../../../components/Tooltip'; import useScheduleStore from '../../../stores/useScheduleStore'; import { getTodayKST } from '../../../utils/date'; 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] = useState(null); const scrollContainerRef = useRef(null); const SEARCH_LIMIT = 5; // 테스트용 5개 // 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가 undefined이면 오늘 날짜로 초기화 (null은 전체보기이므로 유지) useEffect(() => { if (selectedDate === undefined) { 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'; }; // 해당 날짜에 일정이 있는지 확인 const hasSchedule = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return schedules.some(s => { const scheduleDate = new Date(s.date).toISOString().split('T')[0]; return scheduleDate === dateStr; }); }; // 해당 날짜의 첫 번째 일정 카테고리 색상 const getScheduleColor = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const schedule = schedules.find(s => { const scheduleDate = new Date(s.date).toISOString().split('T')[0]; return scheduleDate === dateStr; }); if (!schedule) return null; const cat = categories.find(c => c.id === schedule.category_id); return cat?.color || '#4A7C59'; }; // Toast 자동 숨김 useEffect(() => { if (toast) { const timer = setTimeout(() => setToast(null), 3000); return () => clearTimeout(timer); } }, [toast]); 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 (isSearchMode && searchTerm) { searchSchedules(searchTerm); } }, []); // 컴포넌트 마운트 시에만 // 스크롤 위치 복원 useEffect(() => { if (scrollContainerRef.current && scrollPosition > 0) { scrollContainerRef.current.scrollTop = scrollPosition; } }, [loading]); // 로딩이 끝나면 스크롤 복원 // 스크롤 위치 저장 const handleScroll = (e) => { setScrollPosition(e.target.scrollTop); }; // 카테고리 로드 함수 const fetchCategories = async () => { try { const res = await fetch('/api/admin/schedule-categories'); const data = await res.json(); setCategories([ { id: 'all', name: '전체', color: 'gray' }, ...data ]); } catch (error) { console.error('카테고리 로드 오류:', error); } }; // 일정 로드 함수 const fetchSchedules = async () => { setLoading(true); try { const res = await fetch(`/api/admin/schedules?year=${year}&month=${month + 1}`); const data = await res.json(); 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 handleLogout = () => { localStorage.removeItem('adminToken'); localStorage.removeItem('adminUser'); navigate('/admin'); }; // 월 이동 const prevMonth = () => { setSlideDirection(-1); setCurrentDate(new Date(year, month - 1, 1)); setSelectedDate(null); setSchedules([]); // 이전 달 데이터 즉시 초기화 }; const nextMonth = () => { setSlideDirection(1); setCurrentDate(new Date(year, month + 1, 1)); setSelectedDate(null); 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) => { setCurrentDate(new Date(year, newMonth, 1)); setShowYearMonthPicker(false); setViewMode('yearMonth'); }; // 날짜 선택 const selectDate = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; setSelectedDate(selectedDate === dateStr ? null : dateStr); }; // 전체보기 const showAll = () => { setSelectedDate(null); }; // 삭제 관련 상태 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 { const token = localStorage.getItem('adminToken'); const response = await fetch(`/api/admin/schedules/${scheduleToDelete.id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}`, }, }); if (response.ok) { setToast({ type: 'success', message: '일정이 삭제되었습니다.' }); fetchSchedules(); // 일정 목록 새로고침 } else { const data = await response.json(); setToast({ type: 'error', message: data.error || '삭제 실패' }); } } catch (error) { console.error('삭제 오류:', error); setToast({ type: 'error', message: '삭제 중 오류가 발생했습니다.' }); } finally { setDeleting(false); setDeleteDialogOpen(false); setScheduleToDelete(null); } }; // 검색어 정규화 (대소문자, 띄어쓰기, 특수문자 무시) const normalizeForSearch = (str) => { return (str || '').toLowerCase().replace(/[\s\-_.,!?#@]/g, ''); }; // 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링) const filteredSchedules = isSearchMode ? (searchTerm ? searchResults : []) // 검색 모드: 검색 전엔 빈 목록, 검색 후엔 API 결과 : schedules.filter(schedule => { // 일반 모드: 로컬 필터링 const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id); const scheduleDate = new Date(schedule.date).toISOString().split('T')[0]; const matchesDate = !selectedDate || scheduleDate === selectedDate; return matchesCategory && matchesDate; }); // 검색 모드일 때 카테고리별 검색 결과 카운트 계산 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; }; // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지) const sortedCategories = useMemo(() => { return categories .map(category => ({ ...category, count: category.id === 'all' ? getTotalCount() : getSearchCategoryCount(category.id) })) .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, schedules, searchResults, isSearchMode, searchTerm]); return (
다음 일정을 삭제하시겠습니까?
{scheduleToDelete?.title}
이 작업은 되돌릴 수 없습니다.
fromis_9의 일정을 관리합니다
등록된 일정이 없습니다