import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useInView } from 'react-intersection-observer'; // 모바일 일정 페이지 function MobileSchedule() { const [selectedDate, setSelectedDate] = useState(new Date()); const [schedules, setSchedules] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [isSearchMode, setIsSearchMode] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [showCalendar, setShowCalendar] = useState(false); const [calendarViewDate, setCalendarViewDate] = useState(() => new Date(selectedDate)); // 달력 뷰 날짜 const [calendarShowYearMonth, setCalendarShowYearMonth] = useState(false); // 달력 년월 선택 모드 const contentRef = useRef(null); // 스크롤 초기화용 // 달력 월 변경 함수 const changeCalendarMonth = (delta) => { const newDate = new Date(calendarViewDate); newDate.setMonth(newDate.getMonth() + delta); setCalendarViewDate(newDate); }; const SEARCH_LIMIT = 10; const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' }); // 검색 무한 스크롤 const { data: searchData, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading: searchLoading, } = useInfiniteQuery({ queryKey: ['mobileScheduleSearch', 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, }); const searchResults = useMemo(() => { if (!searchData?.pages) return []; return searchData.pages.flatMap(page => page.schedules); }, [searchData]); useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { fetchNextPage(); } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); // 일정 및 카테고리 로드 (월이 변경될 때만 실행) const viewMonth = `${selectedDate.getFullYear()}-${selectedDate.getMonth()}`; useEffect(() => { const year = selectedDate.getFullYear(); const month = selectedDate.getMonth() + 1; setLoading(true); Promise.all([ fetch(`/api/schedules?year=${year}&month=${month}`).then(res => res.json()), fetch('/api/schedules/categories').then(res => res.json()) ]).then(([schedulesData, categoriesData]) => { setSchedules(schedulesData); setCategories(categoriesData); setLoading(false); }).catch(console.error); }, [viewMonth]); // 월 변경 const changeMonth = (delta) => { const newDate = new Date(selectedDate); newDate.setMonth(newDate.getMonth() + delta); // 이번 달이면 오늘 날짜, 다른 달이면 1일 선택 const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { newDate.setDate(today.getDate()); } else { newDate.setDate(1); } setSelectedDate(newDate); setCalendarViewDate(newDate); }; // 날짜 변경 시 스크롤 맨 위로 초기화 useEffect(() => { if (contentRef.current) { contentRef.current.scrollTop = 0; } }, [selectedDate]); // 캘린더가 열릴 때 배경 스크롤 방지 useEffect(() => { const preventScroll = (e) => e.preventDefault(); if (showCalendar) { document.addEventListener('touchmove', preventScroll, { passive: false }); } else { document.removeEventListener('touchmove', preventScroll); } return () => { document.removeEventListener('touchmove', preventScroll); }; }, [showCalendar]); // 카테고리 색상 const getCategoryColor = (categoryId) => { const category = categories.find(c => c.id === categoryId); return category?.color || '#6b7280'; }; // 날짜별 일정 그룹화 const groupedSchedules = useMemo(() => { const groups = {}; schedules.forEach(schedule => { const date = schedule.date; if (!groups[date]) groups[date] = []; groups[date].push(schedule); }); return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0])); }, [schedules]); // 해당 달의 모든 날짜 배열 const daysInMonth = useMemo(() => { const year = selectedDate.getFullYear(); const month = selectedDate.getMonth(); const lastDay = new Date(year, month + 1, 0).getDate(); const days = []; for (let d = 1; d <= lastDay; d++) { days.push(new Date(year, month, d)); } return days; }, [selectedDate]); // 선택된 날짜의 일정 const selectedDateSchedules = useMemo(() => { // KST 기준 날짜 문자열 생성 const year = selectedDate.getFullYear(); const month = String(selectedDate.getMonth() + 1).padStart(2, '0'); const day = String(selectedDate.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; // API 응답의 date는 ISO 형식이므로 T 이전 부분만 비교 return schedules.filter(s => s.date.split('T')[0] === dateStr); }, [schedules, selectedDate]); // 요일 이름 const getDayName = (date) => { return ['일', '월', '화', '수', '목', '금', '토'][date.getDay()]; }; // 오늘 여부 const isToday = (date) => { const today = new Date(); return date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); }; // 선택된 날짜 여부 const isSelected = (date) => { return date.getDate() === selectedDate.getDate() && date.getMonth() === selectedDate.getMonth() && date.getFullYear() === selectedDate.getFullYear(); }; // 날짜 선택 컨테이너 ref const dateScrollRef = useRef(null); // 선택된 날짜로 자동 스크롤 + 페이지 스크롤 초기화 useEffect(() => { // 페이지 스크롤을 맨 위로 즉시 이동 window.scrollTo(0, 0); if (dateScrollRef.current) { const selectedDay = selectedDate.getDate(); const buttons = dateScrollRef.current.querySelectorAll('button'); if (buttons[selectedDay - 1]) { buttons[selectedDay - 1].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } } }, [selectedDate]); return ( <> {/* 툴바 (헤더 + 날짜 선택기) */}
{isSearchMode ? (
setSearchTerm(e.target.value)} className="flex-1 bg-transparent outline-none text-sm" autoFocus /> {searchTerm && ( )}
) : (
{showCalendar ? ( // 달력 열렸을 때: 년월은 absolute로 가운데 고정, 드롭다운은 바로 옆에 <>
{/* 년월 텍스트: absolute로 정확히 가운데 고정, 클릭하면 드롭다운 토글 */} {/* 드롭다운 버튼: 년월 텍스트 바로 옆에 위치하도록 가운데 배치 */}
) : ( // 달력 닫혔을 때: 기존 UI <>
{selectedDate.getFullYear()}년 {selectedDate.getMonth() + 1}월
)}
)} {/* 가로 스크롤 날짜 선택기 */} {!isSearchMode && (
{daysInMonth.map((date) => { const dayOfWeek = date.getDay(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const dateStr = `${year}-${month}-${day}`; // 해당 날짜의 일정 목록 (최대 3개) const daySchedules = schedules .filter(s => s.date?.split('T')[0] === dateStr) .slice(0, 3); return ( ); })}
)}
{/* 달력 팝업 - fixed로 위에 띄우기 */} {showCalendar && !isSearchMode && ( { setSelectedDate(date); setCalendarViewDate(date); setCalendarShowYearMonth(false); setShowCalendar(false); }} /> )} {/* 캘린더 배경 오버레이 */} {showCalendar && !isSearchMode && ( setShowCalendar(false)} className="fixed inset-0 bg-black/40 z-40" style={{ top: 0 }} /> )} {/* 컨텐츠 영역 */}
{isSearchMode && searchTerm ? ( // 검색 결과
{searchLoading ? (
) : searchResults.length === 0 ? (
검색 결과가 없습니다
) : ( <> {searchResults.map((schedule, index) => ( ))}
{isFetchingNextPage && (
)}
)}
) : loading ? (
) : selectedDateSchedules.length === 0 ? (
{selectedDate.getMonth() + 1}월 {selectedDate.getDate()}일 일정이 없습니다
) : ( // 선택된 날짜의 일정
{selectedDateSchedules.map((schedule, index) => ( ))}
)}
); } // 일정 카드 컴포넌트 (검색용) function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { 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 (

{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()} )) )}
)}
); } // 타임라인용 일정 카드 컴포넌트 - 모던 디자인 function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { 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 ( {/* 카드 본체 */}
{/* 시간 뱃지 */} {schedule.time && (
{schedule.time.slice(0, 5)}
{categoryName}
)} {/* 제목 */}

{schedule.title}

{/* 출처 */} {schedule.source_name && (
{schedule.source_name}
)} {/* 멤버 */} {memberList.length > 0 && (
{memberList.length >= 5 ? ( 프로미스나인 ) : ( memberList.map((name, i) => ( {name.trim()} )) )}
)}
); } // 달력 선택기 컴포넌트 function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelectDate, hideHeader = false, // 헤더 숨김 여부 externalViewDate, // 외부에서 제어하는 viewDate onViewDateChange, // viewDate 변경 콜백 externalShowYearMonth, // 외부에서 제어하는 년월 선택 모드 onShowYearMonthChange // 년월 선택 모드 변경 콜백 }) { const [internalViewDate, setInternalViewDate] = useState(new Date(selectedDate)); // 외부 viewDate가 있으면 사용, 없으면 내부 상태 사용 const viewDate = externalViewDate || internalViewDate; const setViewDate = (date) => { if (onViewDateChange) { onViewDateChange(date); } else { setInternalViewDate(date); } }; // 터치 스와이프 핸들링 const touchStartX = useRef(0); const touchEndX = useRef(0); // 날짜별 일정 존재 여부 및 카테고리 색상 const scheduleDates = useMemo(() => { const dateMap = {}; schedules.forEach(schedule => { const date = schedule.date.split('T')[0]; // YYYY-MM-DD 형식으로 통일 if (!dateMap[date]) { dateMap[date] = []; } const category = categories.find(c => c.id === schedule.category_id); dateMap[date].push(category?.color || '#6b7280'); }); return dateMap; }, [schedules, categories]); // 날짜별 일정 목록 가져오기 (점 표시용, 최대 3개) const getDaySchedules = (date) => { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const dateStr = `${y}-${m}-${d}`; return schedules.filter(s => s.date?.split('T')[0] === dateStr).slice(0, 3); }; const year = viewDate.getFullYear(); const month = viewDate.getMonth(); // 달력 데이터 생성 함수 const getCalendarDays = useCallback((y, m) => { const firstDay = new Date(y, m, 1); const lastDay = new Date(y, m + 1, 0); const startDay = firstDay.getDay(); const daysInMonth = lastDay.getDate(); const days = []; // 이전 달 날짜 const prevMonth = new Date(y, m, 0); for (let i = startDay - 1; i >= 0; i--) { days.push({ day: prevMonth.getDate() - i, isCurrentMonth: false, date: new Date(y, m - 1, prevMonth.getDate() - i) }); } // 현재 달 날짜 for (let i = 1; i <= daysInMonth; i++) { days.push({ day: i, isCurrentMonth: true, date: new Date(y, m, i) }); } // 다음 달 날짜 (현재 줄만 채우기) const remaining = (7 - (days.length % 7)) % 7; for (let i = 1; i <= remaining; i++) { days.push({ day: i, isCurrentMonth: false, date: new Date(y, m + 1, i) }); } return days; }, []); const changeMonth = useCallback((delta) => { const newDate = new Date(viewDate); newDate.setMonth(newDate.getMonth() + delta); setViewDate(newDate); }, [viewDate]); const isToday = (date) => { const today = new Date(); return date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); }; // 선택된 날짜인지 확인 const isSelected = (date) => { return date.getDate() === selectedDate.getDate() && date.getMonth() === selectedDate.getMonth() && date.getFullYear() === selectedDate.getFullYear(); }; // 년월 선택 모드 - 외부에서 제어 가능 const [internalShowYearMonth, setInternalShowYearMonth] = useState(false); const showYearMonth = externalShowYearMonth !== undefined ? externalShowYearMonth : internalShowYearMonth; const setShowYearMonth = (value) => { if (onShowYearMonthChange) { onShowYearMonthChange(value); } else { setInternalShowYearMonth(value); } }; const [yearRangeStart, setYearRangeStart] = useState(Math.floor(year / 12) * 12); const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i); // 배경 스크롤 막기 useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []); // 현재 달 캘린더 데이터 const currentMonthDays = useMemo(() => { return getCalendarDays(year, month); }, [year, month, getCalendarDays]); // 터치 핸들러 const handleTouchStart = (e) => { touchStartX.current = e.touches[0].clientX; }; const handleTouchMove = (e) => { touchEndX.current = e.touches[0].clientX; }; const handleTouchEnd = () => { const diff = touchStartX.current - touchEndX.current; const threshold = 50; if (Math.abs(diff) > threshold) { if (diff > 0) { changeMonth(1); } else { changeMonth(-1); } } touchStartX.current = 0; touchEndX.current = 0; }; // 월 렌더링 컴포넌트 const renderMonth = (days) => (
{/* 요일 헤더 */}
{['일', '월', '화', '수', '목', '금', '토'].map((day, i) => (
{day}
))}
{/* 날짜 그리드 */}
{days.map((item, index) => { const dayOfWeek = index % 7; const isSunday = dayOfWeek === 0; const isSaturday = dayOfWeek === 6; const daySchedules = item.isCurrentMonth ? getDaySchedules(item.date) : []; return ( ); })}
); return (
{showYearMonth ? ( // 년월 선택 UI {/* 년도 범위 헤더 */}
{yearRangeStart} - {yearRangeStart + 11}
{/* 년도 선택 */}
년도
{yearRange.map(y => ( ))}
{/* 월 선택 */}
{Array.from({ length: 12 }, (_, i) => i + 1).map(m => ( ))}
) : ( {/* 달력 헤더 - hideHeader일 때 숨김 */} {!hideHeader && (
)} {/* 달력 (터치 스와이프 지원) */} {renderMonth(currentMonthDays)} {/* 오늘 버튼 */}
)}
); } export default MobileSchedule;