517 lines
19 KiB
React
517 lines
19 KiB
React
|
|
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;
|