import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft } from 'lucide-react'; function Schedule() { const navigate = useNavigate(); // KST 기준 오늘 날짜 (YYYY-MM-DD) const getTodayKST = () => { const now = new Date(); const kstOffset = 9 * 60 * 60 * 1000; // 9시간 const kstDate = new Date(now.getTime() + kstOffset); return kstDate.toISOString().split('T')[0]; }; const [currentDate, setCurrentDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(getTodayKST()); // KST 기준 오늘 const [showYearMonthPicker, setShowYearMonthPicker] = useState(false); const [viewMode, setViewMode] = useState('yearMonth'); const [slideDirection, setSlideDirection] = useState(0); const pickerRef = useRef(null); // 데이터 상태 const [schedules, setSchedules] = useState([]); const [categories, setCategories] = useState([]); const [selectedCategories, setSelectedCategories] = useState([]); const [loading, setLoading] = useState(true); // 카테고리 필터 툴팁 const [showCategoryTooltip, setShowCategoryTooltip] = useState(false); const categoryRef = useRef(null); // 검색 상태 const [isSearchMode, setIsSearchMode] = useState(false); const [searchInput, setSearchInput] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); // 데이터 로드 useEffect(() => { fetchSchedules(); fetchCategories(); }, []); const fetchSchedules = async () => { try { const response = await fetch('/api/schedules'); if (response.ok) { const data = await response.json(); setSchedules(data); } } catch (error) { console.error('일정 로드 오류:', error); } finally { setLoading(false); } }; const fetchCategories = async () => { try { const response = await fetch('/api/schedule-categories'); if (response.ok) { const data = await response.json(); setCategories(data); } } catch (error) { console.error('카테고리 로드 오류:', error); } }; // 검색 함수 (Meilisearch API 호출) const searchSchedules = async (term) => { if (!term.trim()) { setSearchResults([]); return; } setSearchLoading(true); try { const response = await fetch(`/api/schedules?search=${encodeURIComponent(term)}`); if (response.ok) { const data = await response.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 (categoryRef.current && !categoryRef.current.contains(event.target)) { setShowCategoryTooltip(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // 달력 관련 함수 const getDaysInMonth = (year, month) => new Date(year, month + 1, 0).getDate(); const getFirstDayOfMonth = (year, month) => new Date(year, month, 1).getDay(); const year = currentDate.getFullYear(); const month = currentDate.getMonth(); const daysInMonth = getDaysInMonth(year, month); const firstDay = getFirstDayOfMonth(year, month); const days = ['일', '월', '화', '수', '목', '금', '토']; // 스케줄이 있는 날짜 목록 (ISO 형식에서 YYYY-MM-DD 추출) const scheduleDates = schedules.map(s => s.date ? s.date.split('T')[0] : ''); // 해당 날짜의 첫 번째 일정 카테고리 색상 const getScheduleColor = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const schedule = schedules.find(s => (s.date ? s.date.split('T')[0] : '') === dateStr); if (!schedule) return null; const cat = categories.find(c => c.id === schedule.category_id); return cat?.color || '#4A7C59'; }; const hasSchedule = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return scheduleDates.includes(dateStr); }; const prevMonth = () => { setSlideDirection(-1); setCurrentDate(new Date(year, month - 1, 1)); setSelectedDate(null); // 월 변경 시 초기화 }; const nextMonth = () => { setSlideDirection(1); setCurrentDate(new Date(year, month + 1, 1)); setSelectedDate(null); // 월 변경 시 초기화 }; const selectDate = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; setSelectedDate(selectedDate === dateStr ? null : dateStr); }; 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 toggleCategory = (categoryId) => { setSelectedCategories(prev => prev.includes(categoryId) ? prev.filter(id => id !== categoryId) : [...prev, categoryId] ); }; // 필터링된 스케줄 (useMemo로 성능 최적화, 시간순 정렬) const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`; const filteredSchedules = useMemo(() => { // 검색 모드일 때 if (isSearchMode) { // 검색 전엔 빈 목록, 검색 후엔 API 결과 (Meilisearch 유사도순 유지) if (!searchTerm) return []; return searchResults; } // 일반 모드: 기존 필터링 return schedules .filter(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; const matchesDate = selectedDate ? scheduleDate === selectedDate : scheduleDate.startsWith(currentYearMonth); const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id); return matchesDate && matchesCategory; }) .sort((a, b) => { const dateA = a.date ? a.date.split('T')[0] : ''; const dateB = b.date ? b.date.split('T')[0] : ''; if (dateA !== dateB) return dateA.localeCompare(dateB); const timeA = a.time || '00:00:00'; const timeB = b.time || '00:00:00'; return timeA.localeCompare(timeB); }); }, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]); const formatDate = (dateStr) => { const date = new Date(dateStr); const dayNames = ['일', '월', '화', '수', '목', '금', '토']; return { month: date.getMonth() + 1, day: date.getDate(), weekday: dayNames[date.getDay()], }; }; // 일정 클릭 핸들러 const handleScheduleClick = (schedule) => { // 설명이 없고 URL만 있으면 바로 링크 열기 if (!schedule.description && schedule.source_url) { window.open(schedule.source_url, '_blank'); } else { // 상세 페이지로 이동 (추후 구현) navigate(`/schedule/${schedule.id}`); } }; // 년도 범위 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 monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; const prevYearRange = () => setCurrentDate(new Date(year - 10, month, 1)); const nextYearRange = () => setCurrentDate(new Date(year + 10, month, 1)); // 선택된 카테고리 이름 const getSelectedCategoryNames = () => { if (selectedCategories.length === 0) return '전체'; const names = selectedCategories.map(id => { const cat = categories.find(c => c.id === id); return cat?.name || ''; }).filter(Boolean); if (names.length <= 2) return names.join(', '); return `${names.slice(0, 2).join(', ')} 외 ${names.length - 2}개`; }; // 카테고리 색상 가져오기 const getCategoryColor = (categoryId) => { const cat = categories.find(c => c.id === categoryId); return cat?.color || '#808080'; }; const getCategoryName = (categoryId) => { const cat = categories.find(c => c.id === categoryId); return cat?.name || ''; }; // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지) const sortedCategories = useMemo(() => { return categories .map(category => { const count = isSearchMode && searchTerm ? searchResults.filter(s => s.category_id === category.id).length : schedules.filter(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; return scheduleDate.startsWith(currentYearMonth) && s.category_id === category.id; }).length; return { ...category, count }; }) .filter(category => category.count > 0) .sort((a, b) => { if (a.name === '기타') return 1; if (b.name === '기타') return -1; return b.count - a.count; }); }, [categories, schedules, searchResults, isSearchMode, searchTerm, currentYearMonth]); return (
{/* 헤더 */}
일정 프로미스나인의 다가오는 일정을 확인하세요
{/* 왼쪽: 달력 + 카테고리 */}
{/* 달력 */}
{/* 달력 헤더 */}
{/* 년/월 선택 팝업 */} {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}
)); })()}
{/* 범례 및 전체보기 */}
일정 있음
{/* 카테고리 필터 */}

카테고리

{/* 전체 */} {/* 개별 카테고리 - useMemo로 정렬됨 */} {sortedCategories.map(category => { const isSelected = selectedCategories.includes(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}
); })}
)}
)}
)}
{!isSearchMode && ( {filteredSchedules.length}개 일정 )}
{loading ? (
로딩 중...
) : filteredSchedules.length > 0 ? ( filteredSchedules.map((schedule, index) => { const formatted = formatDate(schedule.date); const categoryColor = getCategoryColor(schedule.category_id); const categoryName = getCategoryName(schedule.category_id); return ( handleScheduleClick(schedule)} className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer" > {/* 날짜 영역 */}
{/* 검색 모드일 때 년.월 표시, 일반 모드에서는 월 표시 안함 */} {isSearchMode && searchTerm && ( {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} )} {formatted.day} {formatted.weekday}
{/* 스케줄 내용 */}

{schedule.title}

{schedule.time && (
{schedule.time.slice(0, 5)}
)} {categoryName && (
{categoryName} {schedule.source_name && ` · ${schedule.source_name}`}
)}
{/* 멤버 태그 (별도 줄) */} {(() => { const memberList = schedule.members ? schedule.members.map(m => m.name) : (schedule.member_names ? schedule.member_names.split(',') : []); if (memberList.length === 0) return null; // 5명 이상이면 '프로미스나인' 단일 태그 if (memberList.length >= 5) { return (
프로미스나인
); } // 그 외에는 멤버별 개별 태그 return (
{memberList.map((name, i) => ( {name} ))}
); })()}
); }) ) : (
{selectedDate ? '선택한 날짜에 일정이 없습니다.' : '예정된 일정이 없습니다.'}
)}
); } export default Schedule;