diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index d6d2e07..a9ef215 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -14,6 +14,7 @@ import { Layout as MobileLayout } from '@/components/mobile'; // 페이지 import { PCHome, MobileHome } from '@/pages/home'; import { PCMembers, MobileMembers } from '@/pages/members'; +import { PCSchedule, MobileSchedule } from '@/pages/schedule'; /** * PC 환경에서 body에 클래스 추가하는 래퍼 @@ -50,9 +51,9 @@ function App() { } /> } /> - {/* 추가 페이지는 Phase 9-11에서 구현 */} + } /> + {/* 추가 페이지는 Phase 10-11에서 구현 */} {/* } /> */} - {/* } /> */} {/* } /> */} @@ -81,9 +82,16 @@ function App() { } /> - {/* 추가 페이지는 Phase 9-11에서 구현 */} + + + + } + /> + {/* 추가 페이지는 Phase 10-11에서 구현 */} {/* } /> */} - {/* } /> */} diff --git a/frontend-temp/src/components/schedule/BirthdayCard.jsx b/frontend-temp/src/components/schedule/BirthdayCard.jsx new file mode 100644 index 0000000..31956a0 --- /dev/null +++ b/frontend-temp/src/components/schedule/BirthdayCard.jsx @@ -0,0 +1,175 @@ +import confetti from 'canvas-confetti'; +import { dayjs } from '@/utils'; + +/** + * 생일 폭죽 애니메이션 + */ +export function fireBirthdayConfetti() { + const duration = 3000; + const animationEnd = Date.now() + duration; + const colors = ['#ff69b4', '#ff1493', '#da70d6', '#ba55d3', '#9370db', '#8a2be2', '#ffd700', '#ff6347']; + + const randomInRange = (min, max) => Math.random() * (max - min) + min; + + const interval = setInterval(() => { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + clearInterval(interval); + return; + } + + const particleCount = 50 * (timeLeft / duration); + + // 왼쪽에서 발사 + confetti({ + particleCount: Math.floor(particleCount), + startVelocity: 30, + spread: 60, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + colors, + shapes: ['circle', 'square'], + gravity: 1.2, + scalar: randomInRange(0.8, 1.2), + drift: randomInRange(-0.5, 0.5), + }); + + // 오른쪽에서 발사 + confetti({ + particleCount: Math.floor(particleCount), + startVelocity: 30, + spread: 60, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + colors, + shapes: ['circle', 'square'], + gravity: 1.2, + scalar: randomInRange(0.8, 1.2), + drift: randomInRange(-0.5, 0.5), + }); + }, 250); + + // 초기 대형 폭죽 + confetti({ + particleCount: 100, + spread: 100, + origin: { x: 0.5, y: 0.6 }, + colors, + shapes: ['circle', 'square'], + startVelocity: 45, + }); +} + +/** + * PC용 생일 카드 컴포넌트 + */ +function BirthdayCard({ schedule, showYear = false, onClick }) { + const scheduleDate = dayjs(schedule.date); + const formatted = { + year: scheduleDate.year(), + month: scheduleDate.month() + 1, + day: scheduleDate.date(), + }; + + return ( +
+ {/* 배경 장식 */} +
+
+
+
+
🎉
+
+ +
+ {/* 멤버 사진 */} + {schedule.member_image && ( +
+
+ {schedule.member_names} +
+
+ )} + + {/* 내용 */} +
+ 🎂 +

{schedule.title}

+
+ + {/* 날짜 뱃지 */} +
+ {showYear && ( +
{formatted.year}
+ )} +
{formatted.month}월
+
{formatted.day}
+
+
+
+ ); +} + +/** + * Mobile용 생일 카드 컴포넌트 + */ +export function MobileBirthdayCard({ schedule, showYear = false, onClick }) { + const scheduleDate = dayjs(schedule.date); + const formatted = { + year: scheduleDate.year(), + month: scheduleDate.month() + 1, + day: scheduleDate.date(), + }; + + return ( +
+ {/* 배경 장식 */} +
+
+
+
🎉
+
+ +
+ {/* 멤버 사진 */} + {schedule.member_image && ( +
+
+ {schedule.member_names} +
+
+ )} + + {/* 내용 */} +
+ 🎂 +

{schedule.title}

+
+ + {/* 날짜 뱃지 */} +
+ {showYear && ( +
{formatted.year}
+ )} +
{formatted.month}월
+
{formatted.day}
+
+
+
+ ); +} + +export default BirthdayCard; diff --git a/frontend-temp/src/components/schedule/Calendar.jsx b/frontend-temp/src/components/schedule/Calendar.jsx new file mode 100644 index 0000000..a6a0b31 --- /dev/null +++ b/frontend-temp/src/components/schedule/Calendar.jsx @@ -0,0 +1,324 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; +import { getTodayKST, dayjs } from '@/utils'; + +const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; +const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; +const MIN_YEAR = 2017; + +/** + * 달력 컴포넌트 + * @param {Date} currentDate - 현재 표시 중인 년/월 + * @param {function} onDateChange - 년/월 변경 핸들러 + * @param {string} selectedDate - 선택된 날짜 (YYYY-MM-DD) + * @param {function} onSelectDate - 날짜 선택 핸들러 + * @param {Array} schedules - 일정 목록 (점 표시용) + * @param {function} getCategoryColor - 카테고리 색상 가져오기 + * @param {boolean} disabled - 비활성화 여부 + */ +function Calendar({ + currentDate, + onDateChange, + selectedDate, + onSelectDate, + schedules = [], + getCategoryColor, + disabled = false, +}) { + const [showYearMonthPicker, setShowYearMonthPicker] = useState(false); + const [slideDirection, setSlideDirection] = useState(0); + const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR); + const pickerRef = useRef(null); + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // 외부 클릭 시 팝업 닫기 + useEffect(() => { + const handleClickOutside = (event) => { + if (pickerRef.current && !pickerRef.current.contains(event.target)) { + setShowYearMonthPicker(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 달력 계산 + const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); + const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay(); + + const daysInMonth = getDaysInMonth(year, month); + const firstDay = getFirstDayOfMonth(year, month); + + // 일정 날짜별 맵 (O(1) 조회용) + const scheduleDateMap = useMemo(() => { + const map = new Map(); + schedules.forEach((s) => { + const dateStr = s.date ? s.date.split('T')[0] : ''; + if (!map.has(dateStr)) { + map.set(dateStr, []); + } + map.get(dateStr).push(s); + }); + return map; + }, [schedules]); + + // 2017년 1월 이전으로 이동 불가 + const canGoPrevMonth = !(year === MIN_YEAR && month === 0); + + const prevMonth = () => { + if (!canGoPrevMonth) return; + setSlideDirection(-1); + const newDate = new Date(year, month - 1, 1); + onDateChange(newDate); + // 이번달이면 오늘, 다른 달이면 1일 선택 + const today = new Date(); + if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { + onSelectDate(getTodayKST()); + } else { + onSelectDate(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`); + } + }; + + const nextMonth = () => { + setSlideDirection(1); + const newDate = new Date(year, month + 1, 1); + onDateChange(newDate); + const today = new Date(); + if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { + onSelectDate(getTodayKST()); + } else { + onSelectDate(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`); + } + }; + + const selectYear = (newYear) => { + onDateChange(new Date(newYear, month, 1)); + }; + + const selectMonth = (newMonth) => { + const newDate = new Date(year, newMonth, 1); + onDateChange(newDate); + const today = new Date(); + if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { + onSelectDate(getTodayKST()); + } else { + onSelectDate(`${year}-${String(newMonth + 1).padStart(2, '0')}-01`); + } + setShowYearMonthPicker(false); + }; + + const selectDate = (day) => { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + onSelectDate(dateStr); + }; + + // 연도 범위 + const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i); + const canGoPrevYearRange = yearRangeStart > MIN_YEAR; + const prevYearRange = () => canGoPrevYearRange && setYearRangeStart((prev) => Math.max(MIN_YEAR, prev - 12)); + const nextYearRange = () => setYearRangeStart((prev) => prev + 12); + + const currentYear = new Date().getFullYear(); + const isCurrentYear = (y) => y === currentYear; + const isCurrentMonth = (m) => { + const now = new Date(); + return year === now.getFullYear() && m === now.getMonth(); + }; + + return ( + +
+ {/* 헤더 */} +
+ + + +
+ + {/* 년/월 선택 팝업 */} + + {showYearMonthPicker && ( + +
+ + + {yearRange[0]} - {yearRange[yearRange.length - 1]} + + +
+ + {/* 년도 선택 */} +
년도
+
+ {yearRange.map((y) => ( + + ))} +
+ + {/* 월 선택 */} +
+
+ {MONTHS.map((m, i) => ( + + ))} +
+
+ )} +
+ + {/* 요일 헤더 + 날짜 그리드 */} + + +
+ {WEEKDAYS.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 dayOfWeek = (firstDay + i) % 7; + const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); + const daySchedules = (scheduleDateMap.get(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} +
+ )); + })()} +
+
+
+ + {/* 범례 */} +
+
+ + 일정 있음 +
+
+
+
+ ); +} + +export default Calendar; diff --git a/frontend-temp/src/components/schedule/CategoryFilter.jsx b/frontend-temp/src/components/schedule/CategoryFilter.jsx new file mode 100644 index 0000000..aba5237 --- /dev/null +++ b/frontend-temp/src/components/schedule/CategoryFilter.jsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { motion } from 'framer-motion'; + +/** + * 카테고리 필터 컴포넌트 + * @param {Array} categories - 카테고리 목록 + * @param {Array} selectedCategories - 선택된 카테고리 ID 목록 + * @param {function} onToggle - 카테고리 토글 핸들러 + * @param {function} onClear - 전체 선택 핸들러 + * @param {Map} categoryCounts - 카테고리별 개수 맵 + * @param {boolean} disabled - 비활성화 여부 + */ +function CategoryFilter({ + categories, + selectedCategories, + onToggle, + onClear, + categoryCounts, + disabled = false, +}) { + // 정렬된 카테고리 목록 (개수 기준, '기타'는 맨 뒤) + const sortedCategories = useMemo(() => { + return categories + .map((category) => ({ + ...category, + count: categoryCounts.get(category.id) || 0, + })) + .filter((category) => category.count > 0) + .sort((a, b) => { + if (a.name === '기타') return 1; + if (b.name === '기타') return -1; + return b.count - a.count; + }); + }, [categories, categoryCounts]); + + const totalCount = categoryCounts.get('total') || 0; + + return ( + +

카테고리

+
+ {/* 전체 */} + + + {/* 개별 카테고리 */} + {sortedCategories.map((category) => { + const isSelected = selectedCategories.includes(category.id); + return ( + + ); + })} +
+
+ ); +} + +export default CategoryFilter; diff --git a/frontend-temp/src/components/schedule/index.js b/frontend-temp/src/components/schedule/index.js index f25b711..107e66b 100644 --- a/frontend-temp/src/components/schedule/index.js +++ b/frontend-temp/src/components/schedule/index.js @@ -6,3 +6,8 @@ export { default as AdminScheduleCard } from './AdminScheduleCard'; export { default as MobileScheduleCard } from './MobileScheduleCard'; export { default as MobileScheduleListCard } from './MobileScheduleListCard'; export { default as MobileScheduleSearchCard } from './MobileScheduleSearchCard'; + +// 공통 컴포넌트 +export { default as Calendar } from './Calendar'; +export { default as CategoryFilter } from './CategoryFilter'; +export { default as BirthdayCard, MobileBirthdayCard, fireBirthdayConfetti } from './BirthdayCard'; diff --git a/frontend-temp/src/pages/schedule/MobileSchedule.jsx b/frontend-temp/src/pages/schedule/MobileSchedule.jsx new file mode 100644 index 0000000..09a7b54 --- /dev/null +++ b/frontend-temp/src/pages/schedule/MobileSchedule.jsx @@ -0,0 +1,516 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar as CalendarIcon, List } from 'lucide-react'; +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { useInView } from 'react-intersection-observer'; + +import { + MobileScheduleListCard, + MobileScheduleSearchCard, + MobileBirthdayCard, + fireBirthdayConfetti, +} from '@/components/schedule'; +import { getSchedules, searchSchedules } from '@/api/schedules'; +import { useScheduleStore } from '@/stores'; +import { getTodayKST, dayjs, getCategoryInfo } from '@/utils'; + +const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; +const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; +const SEARCH_LIMIT = 20; +const MIN_YEAR = 2017; + +/** + * Mobile 스케줄 페이지 + */ +function MobileSchedule() { + const navigate = useNavigate(); + const scrollContainerRef = useRef(null); + + // 상태 관리 (zustand store) + const { + currentDate, + setCurrentDate, + selectedDate: storedSelectedDate, + setSelectedDate: setStoredSelectedDate, + selectedCategories, + setSelectedCategories, + isSearchMode, + setIsSearchMode, + searchInput, + setSearchInput, + searchTerm, + setSearchTerm, + } = useScheduleStore(); + + const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate; + const setSelectedDate = setStoredSelectedDate; + + // 로컬 상태 + const [viewMode, setViewMode] = useState('calendar'); // 'calendar' | 'list' + const [showMonthPicker, setShowMonthPicker] = useState(false); + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // 월별 일정 데이터 + const { data: schedules = [], isLoading: loading } = useQuery({ + queryKey: ['schedules', year, month + 1], + queryFn: () => getSchedules(year, month + 1), + }); + + // 검색 무한 스크롤 + const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' }); + + const { + data: searchData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['scheduleSearch', searchTerm], + queryFn: async ({ pageParam = 0 }) => { + return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT }); + }, + 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]); + + // 무한 스크롤 트리거 + const prevInViewRef = useRef(false); + useEffect(() => { + if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { + fetchNextPage(); + } + prevInViewRef.current = inView; + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); + + // 오늘 생일 폭죽 + useEffect(() => { + if (loading || schedules.length === 0) return; + const today = getTodayKST(); + const confettiKey = `birthday-confetti-${today}`; + if (localStorage.getItem(confettiKey)) return; + const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today); + if (hasBirthdayToday) { + const timer = setTimeout(() => { + fireBirthdayConfetti(); + localStorage.setItem(confettiKey, 'true'); + }, 500); + return () => clearTimeout(timer); + } + }, [schedules, loading]); + + // 달력 계산 + const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); + const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay(); + + const daysInMonth = getDaysInMonth(year, month); + const firstDay = getFirstDayOfMonth(year, month); + + // 일정 날짜별 맵 + const scheduleDateMap = useMemo(() => { + const map = new Map(); + schedules.forEach((s) => { + const dateStr = s.date; + if (!map.has(dateStr)) { + map.set(dateStr, []); + } + map.get(dateStr).push(s); + }); + return map; + }, [schedules]); + + // 카테고리 추출 + const categories = useMemo(() => { + const categoryMap = new Map(); + schedules.forEach((s) => { + if (s.category_id && !categoryMap.has(s.category_id)) { + categoryMap.set(s.category_id, { + id: s.category_id, + name: s.category_name, + color: s.category_color, + }); + } + }); + return Array.from(categoryMap.values()); + }, [schedules]); + + // 필터링된 스케줄 + const filteredSchedules = useMemo(() => { + if (isSearchMode && searchTerm) { + if (selectedCategories.length === 0) return searchResults; + return searchResults.filter((s) => selectedCategories.includes(s.category_id)); + } + + return schedules + .filter((s) => { + const matchesDate = selectedDate ? s.date === selectedDate : true; + const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id); + return matchesDate && matchesCategory; + }) + .sort((a, b) => { + // 생일 우선 + if (a.is_birthday && !b.is_birthday) return -1; + if (!a.is_birthday && b.is_birthday) return 1; + // 시간순 + return (a.time || '00:00:00').localeCompare(b.time || '00:00:00'); + }); + }, [schedules, selectedDate, selectedCategories, isSearchMode, searchTerm, searchResults]); + + // 날짜별 그룹화 (리스트 모드용) + const groupedSchedules = useMemo(() => { + if (isSearchMode && searchTerm) { + const groups = new Map(); + searchResults.forEach((s) => { + if (!groups.has(s.date)) { + groups.set(s.date, []); + } + groups.get(s.date).push(s); + }); + return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0])); + } + + const groups = new Map(); + schedules.forEach((s) => { + if (selectedCategories.length > 0 && !selectedCategories.includes(s.category_id)) return; + if (!groups.has(s.date)) { + groups.set(s.date, []); + } + groups.get(s.date).push(s); + }); + return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0])); + }, [schedules, selectedCategories, isSearchMode, searchTerm, searchResults]); + + // 월 이동 + const canGoPrevMonth = !(year === MIN_YEAR && month === 0); + + const prevMonth = () => { + if (!canGoPrevMonth) return; + const newDate = new Date(year, month - 1, 1); + setCurrentDate(newDate); + }; + + const nextMonth = () => { + const newDate = new Date(year, month + 1, 1); + setCurrentDate(newDate); + }; + + // 날짜 선택 + const selectDate = (day) => { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + setSelectedDate(dateStr); + }; + + // 일정 클릭 + const handleScheduleClick = (schedule) => { + if (schedule.is_birthday) { + const scheduleYear = new Date(schedule.date).getFullYear(); + navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`); + return; + } + if ([2, 3, 6].includes(schedule.category_id)) { + navigate(`/schedule/${schedule.id}`); + return; + } + if (!schedule.description && schedule.source?.url) { + window.open(schedule.source.url, '_blank'); + } else { + navigate(`/schedule/${schedule.id}`); + } + }; + + // 검색 모드 종료 + const exitSearchMode = () => { + setIsSearchMode(false); + setSearchInput(''); + setSearchTerm(''); + }; + + return ( +
+ {/* 헤더 */} +
+ {isSearchMode ? ( + // 검색 모드 헤더 +
+ +
+ setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && searchInput.trim()) { + setSearchTerm(searchInput); + } + }} + className="w-full pl-10 pr-10 py-2 bg-gray-100 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" + /> + + {searchInput && ( + + )} +
+
+ ) : ( + // 일반 모드 헤더 + <> +
+ +
+ + +
+
+ + {/* 월 선택 드롭다운 */} + + {showMonthPicker && ( + +
+
+ + {year}년 + +
+
+ {MONTHS.map((m, i) => ( + + ))} +
+
+
+ )} +
+ + {/* 달력 모드 - 달력 그리드 */} + {viewMode === 'calendar' && ( +
+ {/* 월 네비게이션 */} +
+ + {month + 1}월 + +
+ + {/* 요일 헤더 */} +
+ {WEEKDAYS.map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {/* 전달 빈 칸 */} + {Array.from({ length: firstDay }).map((_, i) => ( +
+ ))} + + {/* 현재 달 날짜 */} + {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 isToday = dateStr === getTodayKST(); + const daySchedules = scheduleDateMap.get(dateStr) || []; + const dayOfWeek = (firstDay + i) % 7; + + return ( + + ); + })} +
+
+ )} + + )} +
+ + {/* 일정 목록 */} +
+ {loading ? ( +
+
+
+ ) : isSearchMode && searchTerm ? ( + // 검색 결과 +
+ {searchResults.length > 0 ? ( + <> + {searchResults.map((schedule) => ( +
+ {schedule.is_birthday ? ( + handleScheduleClick(schedule)} /> + ) : ( + handleScheduleClick(schedule)} /> + )} +
+ ))} +
+ {isFetchingNextPage && ( +
+
+
+ )} +
+ + ) : ( +
검색 결과가 없습니다
+ )} +
+ ) : viewMode === 'calendar' ? ( + // 달력 모드 - 선택된 날짜의 일정 +
+ {filteredSchedules.length > 0 ? ( + filteredSchedules.map((schedule) => ( +
+ {schedule.is_birthday ? ( + handleScheduleClick(schedule)} /> + ) : ( + handleScheduleClick(schedule)} /> + )} +
+ )) + ) : ( +
+ {selectedDate ? '이 날짜에 일정이 없습니다' : '이번 달에 일정이 없습니다'} +
+ )} +
+ ) : ( + // 리스트 모드 - 날짜별 그룹화 +
+ {groupedSchedules.length > 0 ? ( + groupedSchedules.map(([date, daySchedules]) => { + const d = dayjs(date); + return ( +
+
+ {d.format('M월 D일')} ({WEEKDAYS[d.day()]}) +
+
+ {daySchedules.map((schedule) => ( +
+ {schedule.is_birthday ? ( + handleScheduleClick(schedule)} /> + ) : ( + handleScheduleClick(schedule)} /> + )} +
+ ))} +
+
+ ); + }) + ) : ( +
이번 달에 일정이 없습니다
+ )} +
+ )} +
+
+ ); +} + +export default MobileSchedule; diff --git a/frontend-temp/src/pages/schedule/PCSchedule.jsx b/frontend-temp/src/pages/schedule/PCSchedule.jsx new file mode 100644 index 0000000..0b42a5e --- /dev/null +++ b/frontend-temp/src/pages/schedule/PCSchedule.jsx @@ -0,0 +1,595 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Search, ArrowLeft, X, Tag } from 'lucide-react'; +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useInView } from 'react-intersection-observer'; + +import { + Calendar, + CategoryFilter, + ScheduleCard, + BirthdayCard, + fireBirthdayConfetti, +} from '@/components/schedule'; +import { getSchedules, searchSchedules } from '@/api/schedules'; +import { useScheduleStore } from '@/stores'; +import { getTodayKST } from '@/utils'; + +const SEARCH_LIMIT = 20; + +/** + * PC 스케줄 페이지 + */ +function PCSchedule() { + const navigate = useNavigate(); + const scrollContainerRef = useRef(null); + const searchContainerRef = useRef(null); + const categoryRef = useRef(null); + + // 상태 관리 (zustand store) + const { + currentDate, + setCurrentDate, + selectedDate: storedSelectedDate, + setSelectedDate: setStoredSelectedDate, + selectedCategories, + setSelectedCategories, + isSearchMode, + setIsSearchMode, + searchInput, + setSearchInput, + searchTerm, + setSearchTerm, + } = useScheduleStore(); + + // 초기값 설정 + const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate; + const setSelectedDate = setStoredSelectedDate; + + // 로컬 상태 + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); + const [suggestions, setSuggestions] = useState([]); + const [originalSearchQuery, setOriginalSearchQuery] = useState(''); + const [showCategoryTooltip, setShowCategoryTooltip] = useState(false); + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // 월별 일정 데이터 + const { data: schedules = [], isLoading: loading } = useQuery({ + queryKey: ['schedules', year, month + 1], + queryFn: () => getSchedules(year, month + 1), + }); + + // 검색 무한 스크롤 + const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' }); + + const { + data: searchData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['scheduleSearch', searchTerm], + queryFn: async ({ pageParam = 0 }) => { + return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT }); + }, + 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]); + + // 무한 스크롤 트리거 + const prevInViewRef = useRef(false); + useEffect(() => { + if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { + fetchNextPage(); + } + prevInViewRef.current = inView; + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); + + // 검색어 자동완성 + useEffect(() => { + if (!originalSearchQuery?.trim()) { + setSuggestions([]); + return; + } + const timeoutId = setTimeout(async () => { + try { + const response = await fetch(`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`); + if (response.ok) { + const data = await response.json(); + setSuggestions(data.suggestions || []); + } + } catch { + setSuggestions([]); + } + }, 200); + return () => clearTimeout(timeoutId); + }, [originalSearchQuery]); + + // 오늘 생일 폭죽 + useEffect(() => { + if (loading || schedules.length === 0) return; + const today = getTodayKST(); + const confettiKey = `birthday-confetti-${today}`; + if (localStorage.getItem(confettiKey)) return; + const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today); + if (hasBirthdayToday) { + const timer = setTimeout(() => { + fireBirthdayConfetti(); + localStorage.setItem(confettiKey, 'true'); + }, 500); + return () => clearTimeout(timer); + } + }, [schedules, loading]); + + // 외부 클릭 처리 + useEffect(() => { + const handleClickOutside = (event) => { + if (categoryRef.current && !categoryRef.current.contains(event.target)) { + setShowCategoryTooltip(false); + } + if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) { + setShowSuggestions(false); + setSelectedSuggestionIndex(-1); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 날짜 변경 시 스크롤 초기화 + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }, [selectedDate]); + + // 카테고리 추출 + const categories = useMemo(() => { + const categoryMap = new Map(); + schedules.forEach((s) => { + if (s.category_id && !categoryMap.has(s.category_id)) { + categoryMap.set(s.category_id, { + id: s.category_id, + name: s.category_name, + color: s.category_color, + }); + } + }); + return Array.from(categoryMap.values()); + }, [schedules]); + + // 카테고리별 카운트 + const categoryCounts = useMemo(() => { + const source = isSearchMode && searchTerm ? searchResults : schedules; + const counts = new Map(); + let total = 0; + + source.forEach((s) => { + if (!(isSearchMode && searchTerm) && selectedDate && s.date !== selectedDate) return; + const catId = s.category_id; + if (catId) { + counts.set(catId, (counts.get(catId) || 0) + 1); + total++; + } + }); + counts.set('total', total); + return counts; + }, [schedules, searchResults, isSearchMode, searchTerm, selectedDate]); + + // 카테고리 색상/이름 가져오기 + const getCategoryColor = useCallback( + (categoryId, schedule = null) => { + if (schedule?.category_color) return schedule.category_color; + const cat = categories.find((c) => c.id === categoryId); + return cat?.color || '#808080'; + }, + [categories] + ); + + const getCategoryName = useCallback( + (categoryId, schedule = null) => { + if (schedule?.category_name) return schedule.category_name; + const cat = categories.find((c) => c.id === categoryId); + return cat?.name || ''; + }, + [categories] + ); + + // 필터링된 스케줄 + const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`; + + const filteredSchedules = useMemo(() => { + const sortWithBirthdayFirst = (list) => { + return [...list].sort((a, b) => { + const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-'); + const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-'); + if (aIsBirthday && !bIsBirthday) return -1; + if (!aIsBirthday && bIsBirthday) return 1; + return 0; + }); + }; + + if (isSearchMode) { + if (!searchTerm) return []; + if (selectedCategories.length === 0) return sortWithBirthdayFirst(searchResults); + return sortWithBirthdayFirst(searchResults.filter((s) => selectedCategories.includes(s.category_id))); + } + + const filtered = schedules + .filter((s) => { + const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth); + const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id); + return matchesDate && matchesCategory; + }) + .sort((a, b) => { + const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-'); + const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-'); + if (aIsBirthday && !bIsBirthday) return -1; + if (!aIsBirthday && bIsBirthday) return 1; + if (a.date !== b.date) return a.date.localeCompare(b.date); + return (a.time || '00:00:00').localeCompare(b.time || '00:00:00'); + }); + return filtered; + }, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]); + + // 가상 스크롤 + const virtualizer = useVirtualizer({ + count: isSearchMode && searchTerm ? filteredSchedules.length : 0, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 120, + overscan: 5, + }); + + // 일정 클릭 핸들러 + const handleScheduleClick = (schedule) => { + if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) { + const scheduleYear = new Date(schedule.date).getFullYear(); + navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`); + return; + } + if ([2, 3, 6].includes(schedule.category_id)) { + navigate(`/schedule/${schedule.id}`); + return; + } + if (!schedule.description && schedule.source?.url) { + window.open(schedule.source.url, '_blank'); + } else { + navigate(`/schedule/${schedule.id}`); + } + }; + + // 카테고리 토글 + const toggleCategory = (categoryId) => { + if (selectedCategories.includes(categoryId)) { + setSelectedCategories(selectedCategories.filter((id) => id !== categoryId)); + } else { + setSelectedCategories([...selectedCategories, categoryId]); + } + }; + + // 검색 모드 종료 + const exitSearchMode = () => { + setIsSearchMode(false); + setSearchInput(''); + setOriginalSearchQuery(''); + setSearchTerm(''); + setShowSuggestions(false); + setSelectedSuggestionIndex(-1); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }; + + // 검색 실행 + const executeSearch = () => { + if (searchInput.trim()) { + setSearchTerm(searchInput); + setShowSuggestions(false); + setSelectedSuggestionIndex(-1); + } + }; + + return ( +
+
+ {/* 헤더 */} +
+ + 일정 + + + 프로미스나인의 다가오는 일정을 확인하세요 + +
+ +
+ {/* 왼쪽: 달력 + 카테고리 */} +
+ + setSelectedCategories([])} + categoryCounts={categoryCounts} + disabled={isSearchMode && searchResults.length === 0} + /> +
+ + {/* 오른쪽: 스케줄 리스트 */} +
+ {/* 헤더 */} +
+ + {isSearchMode ? ( + +
+
+ +
+ { + setSearchInput(e.target.value); + setOriginalSearchQuery(e.target.value); + setShowSuggestions(true); + setSelectedSuggestionIndex(-1); + }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + const newIndex = selectedSuggestionIndex < suggestions.length - 1 ? selectedSuggestionIndex + 1 : 0; + setSelectedSuggestionIndex(newIndex); + if (suggestions[newIndex]) setSearchInput(suggestions[newIndex]); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const newIndex = selectedSuggestionIndex > 0 ? selectedSuggestionIndex - 1 : suggestions.length - 1; + setSelectedSuggestionIndex(newIndex); + if (suggestions[newIndex]) setSearchInput(suggestions[newIndex]); + } else if (e.key === 'Enter') { + if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) { + setSearchInput(suggestions[selectedSuggestionIndex]); + setSearchTerm(suggestions[selectedSuggestionIndex]); + } else if (searchInput.trim()) { + setSearchTerm(searchInput); + } + setShowSuggestions(false); + setSelectedSuggestionIndex(-1); + } else if (e.key === 'Escape') { + exitSearchMode(); + } + }} + className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400 text-sm" + /> + +
+ +
+ + {/* 검색어 추천 */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+
+ ) : ( + + +

+ {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 ? ( + isSearchMode && searchTerm ? ( + <> +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const schedule = filteredSchedules[virtualItem.index]; + if (!schedule) return null; + return ( +
+
+ {schedule.is_birthday ? ( + handleScheduleClick(schedule)} /> + ) : ( + handleScheduleClick(schedule)} /> + )} +
+
+ ); + })} +
+
+ {isFetchingNextPage && ( +
+
+
+ )} + {!hasNextPage && filteredSchedules.length > 0 && ( +
{filteredSchedules.length}개 표시 (모두 로드됨)
+ )} +
+ + ) : ( + filteredSchedules.map((schedule, index) => ( + + {schedule.is_birthday ? ( + handleScheduleClick(schedule)} /> + ) : ( + handleScheduleClick(schedule)} /> + )} + + )) + ) + ) : ( + !isSearchMode && ( + +
+ + + +
+

{selectedDate ? '일정이 없습니다' : '예정된 일정이 없습니다'}

+

{selectedDate ? '다른 날짜를 선택해 보세요' : '다른 달을 확인해 보세요'}

+
+ ) + )} +
+
+
+
+
+ ); +} + +export default PCSchedule; diff --git a/frontend-temp/src/pages/schedule/index.js b/frontend-temp/src/pages/schedule/index.js new file mode 100644 index 0000000..473884f --- /dev/null +++ b/frontend-temp/src/pages/schedule/index.js @@ -0,0 +1,2 @@ +export { default as PCSchedule } from './PCSchedule'; +export { default as MobileSchedule } from './MobileSchedule';