모바일 일정: 달력 헤더 툴바 통합, 년월 드롭다운 개선

This commit is contained in:
caadiq 2026-01-07 15:20:30 +09:00
parent 767cbcaf5f
commit f2b0170cf8

View file

@ -13,6 +13,15 @@ function MobileSchedule() {
const [isSearchMode, setIsSearchMode] = useState(false); const [isSearchMode, setIsSearchMode] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showCalendar, setShowCalendar] = useState(false); const [showCalendar, setShowCalendar] = useState(false);
const [calendarViewDate, setCalendarViewDate] = useState(new Date()); //
const [calendarShowYearMonth, setCalendarShowYearMonth] = useState(false); //
//
const changeCalendarMonth = (delta) => {
const newDate = new Date(calendarViewDate);
newDate.setMonth(newDate.getMonth() + delta);
setCalendarViewDate(newDate);
};
const SEARCH_LIMIT = 10; const SEARCH_LIMIT = 10;
const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' }); const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
@ -53,11 +62,14 @@ function MobileSchedule() {
} }
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
// // ( )
const viewMonth = `${selectedDate.getFullYear()}-${selectedDate.getMonth()}`;
useEffect(() => { useEffect(() => {
const year = selectedDate.getFullYear(); const year = selectedDate.getFullYear();
const month = selectedDate.getMonth() + 1; const month = selectedDate.getMonth() + 1;
setLoading(true);
Promise.all([ Promise.all([
fetch(`/api/schedules?year=${year}&month=${month}`).then(res => res.json()), fetch(`/api/schedules?year=${year}&month=${month}`).then(res => res.json()),
fetch('/api/schedules/categories').then(res => res.json()) fetch('/api/schedules/categories').then(res => res.json())
@ -66,7 +78,7 @@ function MobileSchedule() {
setCategories(categoriesData); setCategories(categoriesData);
setLoading(false); setLoading(false);
}).catch(console.error); }).catch(console.error);
}, [selectedDate]); }, [viewMonth]);
// //
const changeMonth = (delta) => { const changeMonth = (delta) => {
@ -75,6 +87,23 @@ function MobileSchedule() {
setSelectedDate(newDate); setSelectedDate(newDate);
}; };
//
useEffect(() => {
const preventScroll = (e) => e.preventDefault();
if (showCalendar) {
document.body.style.overflow = 'hidden';
document.addEventListener('touchmove', preventScroll, { passive: false });
} else {
document.body.style.overflow = '';
document.removeEventListener('touchmove', preventScroll);
}
return () => {
document.body.style.overflow = '';
document.removeEventListener('touchmove', preventScroll);
};
}, [showCalendar]);
// //
const getCategoryColor = (categoryId) => { const getCategoryColor = (categoryId) => {
const category = categories.find(c => c.id === categoryId); const category = categories.find(c => c.id === categoryId);
@ -92,6 +121,63 @@ function MobileSchedule() {
return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0])); return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0]));
}, [schedules]); }, [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(() => {
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 ( return (
<div className="pb-4"> <div className="pb-4">
{/* 헤더 */} {/* 헤더 */}
@ -122,55 +208,188 @@ function MobileSchedule() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between px-4 py-3"> <div className="relative flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-1"> {showCalendar ? (
<button // : absolute ,
onClick={() => setShowCalendar(!showCalendar)} <>
className="p-2 rounded-lg hover:bg-gray-100" <div className="flex items-center gap-1">
> <button
<Calendar size={20} className="text-primary" /> onClick={() => {
</button> setShowCalendar(false);
<button onClick={() => changeMonth(-1)} className="p-2"> setCalendarShowYearMonth(false);
<ChevronLeft size={20} /> }}
</button> className="p-2 rounded-lg hover:bg-gray-100"
</div> >
<span className="font-bold"> <Calendar size={20} className="text-primary" />
{selectedDate.getFullYear()} {selectedDate.getMonth() + 1} </button>
</span> <button onClick={() => changeCalendarMonth(-1)} className="p-2">
<div className="flex items-center gap-1"> <ChevronLeft size={20} />
<button onClick={() => changeMonth(1)} className="p-2"> </button>
<ChevronRight size={20} /> </div>
</button>
<button onClick={() => setIsSearchMode(true)} className="p-2"> {/* 년월 텍스트: absolute로 정확히 가운데 고정, 클릭하면 드롭다운 토글 */}
<Search size={20} /> <button
</button> onClick={() => setCalendarShowYearMonth(!calendarShowYearMonth)}
</div> className={`absolute left-1/2 -translate-x-1/2 font-bold transition-colors ${
calendarShowYearMonth ? 'text-primary' : ''
}`}
>
{calendarViewDate.getFullYear()} {calendarViewDate.getMonth() + 1}
</button>
{/* 드롭다운 버튼: 년월 텍스트 바로 옆에 위치하도록 가운데 배치 */}
<button
onClick={() => setCalendarShowYearMonth(!calendarShowYearMonth)}
className={`absolute transition-colors ${
calendarShowYearMonth ? 'text-primary' : ''
}`}
style={{ left: 'calc(50% + 52px)' }}
>
<ChevronDown
size={16}
className={`transition-transform duration-200 ${calendarShowYearMonth ? 'rotate-180' : ''}`}
/>
</button>
<div className="flex items-center gap-1">
<button onClick={() => changeCalendarMonth(1)} className="p-2">
<ChevronRight size={20} />
</button>
<button onClick={() => setIsSearchMode(true)} className="p-2">
<Search size={20} />
</button>
</div>
</>
) : (
// : UI
<>
<div className="flex items-center gap-1">
<button
onClick={() => {
setCalendarViewDate(selectedDate);
setShowCalendar(true);
}}
className="p-2 rounded-lg hover:bg-gray-100"
>
<Calendar size={20} className="text-gray-600" />
</button>
<button onClick={() => changeMonth(-1)} className="p-2">
<ChevronLeft size={20} />
</button>
</div>
<span className="font-bold">
{selectedDate.getFullYear()} {selectedDate.getMonth() + 1}
</span>
<div className="flex items-center gap-1">
<button onClick={() => changeMonth(1)} className="p-2">
<ChevronRight size={20} />
</button>
<button onClick={() => setIsSearchMode(true)} className="p-2">
<Search size={20} />
</button>
</div>
</>
)}
</div>
)}
{/* 가로 스크롤 날짜 선택기 */}
{!isSearchMode && (
<div
ref={dateScrollRef}
className="flex overflow-x-auto scrollbar-hide px-2 py-2 gap-1"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{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}`;
const hasSchedule = schedules.some(s => s.date.split('T')[0] === dateStr);
return (
<button
key={date.getDate()}
onClick={() => setSelectedDate(date)}
className={`flex flex-col items-center min-w-[44px] py-2 px-1 rounded-xl transition-all ${
isSelected(date)
? 'bg-primary text-white'
: 'hover:bg-gray-100'
}`}
>
<span className={`text-[10px] font-medium ${
isSelected(date)
? 'text-white/80'
: dayOfWeek === 0
? 'text-red-400'
: dayOfWeek === 6
? 'text-blue-400'
: 'text-gray-400'
}`}>
{getDayName(date)}
</span>
<span className={`text-sm font-semibold mt-0.5 ${
isSelected(date)
? 'text-white'
: isToday(date)
? 'text-primary'
: 'text-gray-700'
}`}>
{date.getDate()}
</span>
{hasSchedule && !isSelected(date) && (
<div className="w-1 h-1 rounded-full bg-primary mt-1" />
)}
</button>
);
})}
</div> </div>
)} )}
{/* 달력 팝업 */}
<AnimatePresence>
{showCalendar && !isSearchMode && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-t bg-white"
>
<CalendarPicker
selectedDate={selectedDate}
schedules={schedules}
categories={categories}
onSelectDate={(date) => {
setSelectedDate(date);
setShowCalendar(false);
}}
/>
</motion.div>
)}
</AnimatePresence>
</div> </div>
{/* 달력 팝업 - fixed로 위에 띄우기 */}
<AnimatePresence>
{showCalendar && !isSearchMode && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="fixed left-0 right-0 bg-white shadow-lg z-50 border-b overflow-hidden"
style={{ top: '56px' }}
>
<CalendarPicker
selectedDate={selectedDate}
schedules={schedules}
categories={categories}
hideHeader={true}
externalViewDate={calendarViewDate}
onViewDateChange={setCalendarViewDate}
externalShowYearMonth={calendarShowYearMonth}
onShowYearMonthChange={setCalendarShowYearMonth}
onSelectDate={(date) => {
setSelectedDate(date);
setShowCalendar(false);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* 캘린더 배경 오버레이 */}
<AnimatePresence>
{showCalendar && !isSearchMode && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowCalendar(false)}
className="fixed inset-0 bg-black/40 z-40"
style={{ top: 0 }}
/>
)}
</AnimatePresence>
{/* 컨텐츠 */} {/* 컨텐츠 */}
<div className="px-4 py-4"> <div className="px-4 py-4">
{isSearchMode && searchTerm ? ( {isSearchMode && searchTerm ? (
@ -208,50 +427,22 @@ function MobileSchedule() {
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div> </div>
) : groupedSchedules.length === 0 ? ( ) : selectedDateSchedules.length === 0 ? (
<div className="text-center py-8 text-gray-400"> <div className="text-center py-8 text-gray-400">
이번 일정이 없습니다 {selectedDate.getMonth() + 1} {selectedDate.getDate()} 일정이 없습니다
</div> </div>
) : ( ) : (
// //
<div className="space-y-6"> <div className="space-y-3">
{groupedSchedules.map(([date, daySchedules], groupIndex) => { {selectedDateSchedules.map((schedule, index) => (
const dateObj = new Date(date); <TimelineScheduleCard
const month = dateObj.getMonth() + 1; key={schedule.id}
const day = dateObj.getDate(); schedule={schedule}
const weekday = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()]; categoryColor={getCategoryColor(schedule.category_id)}
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6; categories={categories}
delay={index * 0.05}
return ( />
<div key={date}> ))}
{/* 날짜 헤더 - 심플 스타일 */}
<div className="flex items-center gap-3 mb-3">
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl font-bold ${
isWeekend
? 'bg-red-50 text-red-500'
: 'bg-primary/10 text-primary'
}`}>
<span className="text-lg">{day}</span>
<span className="text-xs opacity-70">{weekday}</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
</div>
{/* 일정 카드들 */}
<div className="space-y-3">
{daySchedules.map((schedule, index) => (
<TimelineScheduleCard
key={schedule.id}
schedule={schedule}
categoryColor={getCategoryColor(schedule.category_id)}
categories={categories}
delay={groupIndex * 0.05 + index * 0.02}
/>
))}
</div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
@ -395,8 +586,28 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 }
} }
// //
function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelectDate }) { function CalendarPicker({
const [viewDate, setViewDate] = useState(new Date(selectedDate)); 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 touchStartX = useRef(0);
@ -480,8 +691,17 @@ function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelec
date.getFullYear() === today.getFullYear(); date.getFullYear() === today.getFullYear();
}; };
// // -
const [showYearMonth, setShowYearMonth] = useState(false); 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 [yearRangeStart, setYearRangeStart] = useState(Math.floor(year / 12) * 12);
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i); const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
@ -659,16 +879,6 @@ function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelec
</button> </button>
))} ))}
</div> </div>
{/* 취소 버튼 */}
<div className="mt-4 flex justify-center">
<button
onClick={() => setShowYearMonth(false)}
className="text-xs text-gray-500 px-4 py-1.5"
>
취소
</button>
</div>
</motion.div> </motion.div>
) : ( ) : (
<motion.div <motion.div
@ -678,28 +888,30 @@ function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelec
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
> >
{/* 달력 헤더 */} {/* 달력 헤더 - hideHeader일 때 숨김 */}
<div className="flex items-center justify-between mb-4"> {!hideHeader && (
<button <div className="flex items-center justify-between mb-4">
onClick={() => changeMonth(-1)} <button
className="p-1" onClick={() => changeMonth(-1)}
> className="p-1"
<ChevronLeft size={18} /> >
</button> <ChevronLeft size={18} />
<button </button>
onClick={() => setShowYearMonth(true)} <button
className="flex items-center gap-1 font-semibold text-sm hover:text-primary transition-colors" onClick={() => setShowYearMonth(true)}
> className="flex items-center gap-1 font-semibold text-sm hover:text-primary transition-colors"
{year} {month + 1} >
<ChevronDown size={16} /> {year} {month + 1}
</button> <ChevronDown size={16} />
<button </button>
onClick={() => changeMonth(1)} <button
className="p-1" onClick={() => changeMonth(1)}
> className="p-1"
<ChevronRight size={18} /> >
</button> <ChevronRight size={18} />
</div> </button>
</div>
)}
{/* 달력 (터치 스와이프 지원) */} {/* 달력 (터치 스와이프 지원) */}
{renderMonth(currentMonthDays)} {renderMonth(currentMonthDays)}