fromis_9/frontend-temp/src/pages/schedule/MobileSchedule.jsx

517 lines
19 KiB
React
Raw Normal View History

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 (
<div className="flex flex-col h-full bg-gray-50">
{/* 헤더 */}
<div className="bg-white sticky top-0 z-20">
{isSearchMode ? (
// 검색 모드 헤더
<div className="flex items-center gap-2 px-4 py-3">
<button onClick={exitSearchMode} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<div className="flex-1 relative">
<input
type="text"
placeholder="일정 검색..."
value={searchInput}
autoFocus
onChange={(e) => 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"
/>
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
{searchInput && (
<button
onClick={() => {
setSearchInput('');
setSearchTerm('');
}}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X size={18} className="text-gray-400" />
</button>
)}
</div>
</div>
) : (
// 일반 모드 헤더
<>
<div className="flex items-center justify-between px-4 py-3">
<button
onClick={() => setShowMonthPicker(!showMonthPicker)}
className="flex items-center gap-1 text-lg font-bold"
>
{year} {month + 1}
<ChevronDown size={20} className={`transition-transform ${showMonthPicker ? 'rotate-180' : ''}`} />
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setIsSearchMode(true)}
className="p-2 hover:bg-gray-100 rounded-full"
>
<Search size={20} className="text-gray-600" />
</button>
<button
onClick={() => setViewMode(viewMode === 'calendar' ? 'list' : 'calendar')}
className="p-2 hover:bg-gray-100 rounded-full"
>
{viewMode === 'calendar' ? (
<List size={20} className="text-gray-600" />
) : (
<CalendarIcon size={20} className="text-gray-600" />
)}
</button>
</div>
</div>
{/* 월 선택 드롭다운 */}
<AnimatePresence>
{showMonthPicker && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-t border-gray-100"
>
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCurrentDate(new Date(year - 1, month, 1))}
disabled={year <= MIN_YEAR}
className={`p-1 ${year <= MIN_YEAR ? 'opacity-30' : ''}`}
>
<ChevronLeft size={20} />
</button>
<span className="font-medium">{year}</span>
<button onClick={() => setCurrentDate(new Date(year + 1, month, 1))} className="p-1">
<ChevronRight size={20} />
</button>
</div>
<div className="grid grid-cols-4 gap-2">
{MONTHS.map((m, i) => (
<button
key={m}
onClick={() => {
setCurrentDate(new Date(year, i, 1));
setShowMonthPicker(false);
}}
className={`py-2 rounded-lg text-sm ${
month === i ? 'bg-primary text-white' : 'hover:bg-gray-100'
}`}
>
{m}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 달력 모드 - 달력 그리드 */}
{viewMode === 'calendar' && (
<div className="px-4 pb-4">
{/* 월 네비게이션 */}
<div className="flex items-center justify-between mb-4">
<button onClick={prevMonth} disabled={!canGoPrevMonth} className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}>
<ChevronLeft size={20} />
</button>
<span className="font-medium">{month + 1}</span>
<button onClick={nextMonth} className="p-1">
<ChevronRight size={20} />
</button>
</div>
{/* 요일 헤더 */}
<div className="grid grid-cols-7 mb-2">
{WEEKDAYS.map((day, i) => (
<div
key={day}
className={`text-center text-xs font-medium py-1 ${
i === 0 ? 'text-red-500' : i === 6 ? 'text-blue-500' : 'text-gray-500'
}`}
>
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="grid grid-cols-7 gap-1">
{/* 전달 빈 칸 */}
{Array.from({ length: firstDay }).map((_, i) => (
<div key={`empty-${i}`} className="aspect-square" />
))}
{/* 현재 달 날짜 */}
{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 (
<button
key={day}
onClick={() => selectDate(day)}
className={`aspect-square flex flex-col items-center justify-center rounded-lg text-sm relative
${isSelected ? 'bg-primary text-white' : ''}
${isToday && !isSelected ? 'text-primary font-bold' : ''}
${dayOfWeek === 0 && !isSelected ? 'text-red-500' : ''}
${dayOfWeek === 6 && !isSelected ? 'text-blue-500' : ''}
`}
>
<span>{day}</span>
{!isSelected && daySchedules.length > 0 && (
<div className="absolute bottom-1 flex gap-0.5">
{daySchedules.slice(0, 3).map((s, idx) => (
<span
key={idx}
className="w-1 h-1 rounded-full"
style={{ backgroundColor: s.category_color || '#4A7C59' }}
/>
))}
</div>
)}
</button>
);
})}
</div>
</div>
)}
</>
)}
</div>
{/* 일정 목록 */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : isSearchMode && searchTerm ? (
// 검색 결과
<div className="p-4 space-y-3">
{searchResults.length > 0 ? (
<>
{searchResults.map((schedule) => (
<div key={schedule.id}>
{schedule.is_birthday ? (
<MobileBirthdayCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
) : (
<MobileScheduleSearchCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</div>
))}
<div ref={loadMoreRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
</>
) : (
<div className="text-center py-20 text-gray-400">검색 결과가 없습니다</div>
)}
</div>
) : viewMode === 'calendar' ? (
// 달력 모드 - 선택된 날짜의 일정
<div className="p-4 space-y-3">
{filteredSchedules.length > 0 ? (
filteredSchedules.map((schedule) => (
<div key={schedule.id}>
{schedule.is_birthday ? (
<MobileBirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
) : (
<MobileScheduleListCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</div>
))
) : (
<div className="text-center py-20 text-gray-400">
{selectedDate ? '이 날짜에 일정이 없습니다' : '이번 달에 일정이 없습니다'}
</div>
)}
</div>
) : (
// 리스트 모드 - 날짜별 그룹화
<div className="divide-y divide-gray-100">
{groupedSchedules.length > 0 ? (
groupedSchedules.map(([date, daySchedules]) => {
const d = dayjs(date);
return (
<div key={date} className="bg-white">
<div className="sticky top-0 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-600">
{d.format('M월 D일')} ({WEEKDAYS[d.day()]})
</div>
<div className="p-4 space-y-3">
{daySchedules.map((schedule) => (
<div key={schedule.id}>
{schedule.is_birthday ? (
<MobileBirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
) : (
<MobileScheduleListCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</div>
))}
</div>
</div>
);
})
) : (
<div className="text-center py-20 text-gray-400">이번 달에 일정이 없습니다</div>
)}
</div>
)}
</div>
</div>
);
}
export default MobileSchedule;