import { useState, useEffect, useRef } 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 } from 'lucide-react'; import Toast from '../../../components/Toast'; import Tooltip from '../../../components/Tooltip'; function AdminSchedule() { const navigate = useNavigate(); // KST 기준 오늘 날짜 (YYYY-MM-DD) const getTodayKST = () => { const now = new Date(); const kstOffset = 9 * 60 * 60 * 1000; const kstDate = new Date(now.getTime() + kstOffset); return kstDate.toISOString().split('T')[0]; }; // sessionStorage에서 저장된 상태 복원 const getStoredState = () => { try { const stored = sessionStorage.getItem('adminScheduleState'); return stored ? JSON.parse(stored) : null; } catch { return null; } }; const storedState = getStoredState(); const [loading, setLoading] = useState(false); const [user, setUser] = useState(null); const [toast, setToast] = useState(null); const [searchInput, setSearchInput] = useState(storedState?.searchInput || ''); const [searchTerm, setSearchTerm] = useState(storedState?.searchTerm || ''); const [isSearchMode, setIsSearchMode] = useState(storedState?.isSearchMode || false); const [searchResults, setSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [selectedCategories, setSelectedCategories] = useState(storedState?.selectedCategories || []); const [selectedDate, setSelectedDate] = useState(storedState?.selectedDate || getTodayKST()); const [currentDate, setCurrentDate] = useState( storedState?.currentDate ? new Date(storedState.currentDate) : new Date() ); 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(); }, [navigate]); // 월이 변경될 때마다 일정 로드 useEffect(() => { fetchSchedules(); }, [year, month]); // 상태를 sessionStorage에 저장 (페이지 이동 시 복원용) useEffect(() => { const stateToSave = { searchInput, searchTerm, isSearchMode, selectedCategories, selectedDate, currentDate: currentDate.toISOString(), }; sessionStorage.setItem('adminScheduleState', JSON.stringify(stateToSave)); }, [searchInput, searchTerm, isSearchMode, selectedCategories, selectedDate, currentDate]); // 검색 모드로 돌아왔을 때 검색 결과 다시 로드 useEffect(() => { if (isSearchMode && searchTerm) { searchSchedules(searchTerm); } }, []); // 컴포넌트 마운트 시에만 // 카테고리 로드 함수 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); } }; // 검색 함수 (Meilisearch API 호출) const searchSchedules = async (term) => { if (!term.trim()) { setSearchResults([]); return; } setSearchLoading(true); try { const res = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`); const data = await res.json(); setSearchResults(data); } catch (error) { console.error('검색 오류:', error); } finally { setSearchLoading(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; }; return (
setToast(null)} /> {/* 삭제 확인 다이얼로그 */} {deleteDialogOpen && ( !deleting && setDeleteDialogOpen(false)} > e.stopPropagation()} >

일정 삭제

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

{scheduleToDelete?.title}

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

)}
{/* 헤더 */}
fromis_9 Admin
안녕하세요, {user?.username}
{/* 메인 콘텐츠 */}
{/* 브레드크럼 */}
일정 관리
{/* 타이틀 + 추가 버튼 */}

일정 관리

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(); 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}
)); })()}
{/* 범례 및 전체보기 */}
일정 있음
{/* 카테고리 필터 */}

카테고리

{categories.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); searchSchedules(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 ? (

등록된 일정이 없습니다

) : (
{filteredSchedules.map((schedule, index) => (
{/* 날짜 */}
{/* 검색 모드일 때 년/월 표시 */} {isSearchMode && searchTerm && (
{new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1}
)}
{new Date(schedule.date).getDate()}
{new Date(schedule.date).toLocaleDateString('ko-KR', { weekday: 'short' })}
{/* 내용 */}
{schedule.category_name || '미지정'} {schedule.source_name && ( {schedule.source_name} )} {schedule.time?.slice(0, 5)}

{schedule.title}

{schedule.description && (

{schedule.description}

)} {/* 멤버 태그 */} {(() => { // members 배열 또는 member_names 문자열 처리 const memberList = schedule.members?.length > 0 ? schedule.members : schedule.member_names ? schedule.member_names.split(',').filter(n => n.trim()).map((name, idx) => ({ id: idx, name: name.trim() })) : []; if (memberList.length === 0) return null; return (
{memberList.length >= 5 ? ( 프로미스나인 ) : ( memberList.map((member) => ( {member.name} )) )}
); })()}
{/* 액션 버튼 */}
{schedule.source_url && ( e.stopPropagation()} className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" > )}
))}
)}
); } export default AdminSchedule;