- useCalendar: initialDate가 문자열일 경우 Date 객체로 변환 - useCalendar: days 배열 추가 (캘린더 날짜 목록) - useCalendar: canGoPrev 별칭 추가 - Schedule: currentDate가 Date 객체가 아닐 경우 안전하게 변환 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
import { useState, useMemo, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
|
import { useIsMobile, useScheduleData, useCategories, useCalendar } from '@/hooks';
|
|
import { useScheduleStore } from '@/stores';
|
|
import { Loading, ScheduleCard } from '@/components';
|
|
import { cn, getTodayKST, decodeHtmlEntities } from '@/utils';
|
|
import { WEEKDAYS, MIN_YEAR } from '@/constants';
|
|
|
|
/**
|
|
* PC 캘린더 컴포넌트
|
|
*/
|
|
function PCCalendar({ selectedDate, schedules, categories, onSelectDate, onMonthChange }) {
|
|
const { days, year, month, canGoPrev } = useCalendar(selectedDate);
|
|
|
|
// 날짜별 일정 맵
|
|
const scheduleDates = useMemo(() => {
|
|
const dateMap = {};
|
|
schedules?.forEach((schedule) => {
|
|
const date = schedule.date?.split('T')[0];
|
|
if (!dateMap[date]) dateMap[date] = [];
|
|
const cat = categories?.find((c) => c.id === (schedule.category_id || schedule.category?.id));
|
|
dateMap[date].push(cat?.color || '#6b7280');
|
|
});
|
|
return dateMap;
|
|
}, [schedules, categories]);
|
|
|
|
const isToday = (date) => {
|
|
const today = new Date();
|
|
return (
|
|
date.getDate() === today.getDate() &&
|
|
date.getMonth() === today.getMonth() &&
|
|
date.getFullYear() === today.getFullYear()
|
|
);
|
|
};
|
|
|
|
const isSelected = (date) => {
|
|
const sel = new Date(selectedDate);
|
|
return (
|
|
date.getDate() === sel.getDate() &&
|
|
date.getMonth() === sel.getMonth() &&
|
|
date.getFullYear() === sel.getFullYear()
|
|
);
|
|
};
|
|
|
|
const formatDateStr = (date) => {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-2xl shadow-sm p-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<button
|
|
onClick={() => canGoPrev && onMonthChange(-1)}
|
|
disabled={!canGoPrev}
|
|
className={cn('p-2 rounded-lg hover:bg-gray-100', !canGoPrev && 'opacity-30 cursor-not-allowed')}
|
|
>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<h2 className="text-lg font-bold">
|
|
{year}년 {month + 1}월
|
|
</h2>
|
|
<button onClick={() => onMonthChange(1)} className="p-2 rounded-lg hover:bg-gray-100">
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 요일 헤더 */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{WEEKDAYS.map((day, i) => (
|
|
<div
|
|
key={day}
|
|
className={cn(
|
|
'text-center text-xs font-medium py-2',
|
|
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-500'
|
|
)}
|
|
>
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 날짜 그리드 */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{days.map((item, index) => {
|
|
const dayOfWeek = index % 7;
|
|
const dateStr = formatDateStr(item.date);
|
|
const colors = scheduleDates[dateStr] || [];
|
|
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => onSelectDate(item.date)}
|
|
className="flex flex-col items-center py-2"
|
|
>
|
|
<span
|
|
className={cn(
|
|
'w-9 h-9 flex items-center justify-center text-sm font-medium rounded-full transition-all',
|
|
!item.isCurrentMonth
|
|
? 'text-gray-300'
|
|
: isSelected(item.date)
|
|
? 'bg-primary text-white font-bold'
|
|
: isToday(item.date)
|
|
? 'text-primary font-bold'
|
|
: dayOfWeek === 0
|
|
? 'text-red-500 hover:bg-red-50'
|
|
: dayOfWeek === 6
|
|
? 'text-blue-500 hover:bg-blue-50'
|
|
: 'text-gray-700 hover:bg-gray-100'
|
|
)}
|
|
>
|
|
{item.day}
|
|
</span>
|
|
{/* 일정 점 */}
|
|
{!isSelected(item.date) && colors.length > 0 && (
|
|
<div className="flex gap-0.5 mt-1 h-1.5">
|
|
{colors.slice(0, 3).map((color, i) => (
|
|
<div
|
|
key={i}
|
|
className="w-1 h-1 rounded-full"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 스케줄 페이지 (PC/Mobile 통합)
|
|
*/
|
|
function Schedule() {
|
|
const navigate = useNavigate();
|
|
const isMobile = useIsMobile();
|
|
|
|
// Zustand store
|
|
const { currentDate: storedCurrentDate, setCurrentDate, selectedDate, setSelectedDate } = useScheduleStore();
|
|
|
|
// 초기값 설정 - currentDate가 Date 객체가 아닐 수 있으므로 안전하게 변환
|
|
const today = getTodayKST();
|
|
const actualSelectedDate = selectedDate || today;
|
|
const currentDate = storedCurrentDate instanceof Date ? storedCurrentDate : new Date(storedCurrentDate || today);
|
|
|
|
// 데이터 로드
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth() + 1;
|
|
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(year, month);
|
|
const { data: categories } = useCategories();
|
|
|
|
// 선택된 날짜의 일정
|
|
const selectedDateSchedules = useMemo(() => {
|
|
if (!schedules) return [];
|
|
const sel = new Date(actualSelectedDate);
|
|
const dateStr = `${sel.getFullYear()}-${String(sel.getMonth() + 1).padStart(2, '0')}-${String(sel.getDate()).padStart(2, '0')}`;
|
|
return schedules
|
|
.filter((s) => s.date?.split('T')[0] === dateStr)
|
|
.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;
|
|
});
|
|
}, [schedules, actualSelectedDate]);
|
|
|
|
// 월 변경
|
|
const changeMonth = (delta) => {
|
|
const newDate = new Date(currentDate);
|
|
newDate.setMonth(newDate.getMonth() + delta);
|
|
|
|
// 2017년 1월 이전으로 이동 불가
|
|
if (newDate.getFullYear() < MIN_YEAR || (newDate.getFullYear() === MIN_YEAR && newDate.getMonth() < 0)) {
|
|
return;
|
|
}
|
|
|
|
setCurrentDate(newDate);
|
|
|
|
// 이번 달이면 오늘 날짜, 다른 달이면 1일 선택
|
|
const now = new Date();
|
|
if (newDate.getFullYear() === now.getFullYear() && newDate.getMonth() === now.getMonth()) {
|
|
setSelectedDate(getTodayKST());
|
|
} else {
|
|
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
|
setSelectedDate(firstDay);
|
|
}
|
|
};
|
|
|
|
// 날짜 선택
|
|
const handleSelectDate = (date) => {
|
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
setSelectedDate(dateStr);
|
|
|
|
// 월이 다르면 currentDate도 변경
|
|
if (date.getMonth() !== currentDate.getMonth() || date.getFullYear() !== currentDate.getFullYear()) {
|
|
setCurrentDate(date);
|
|
}
|
|
};
|
|
|
|
// PC 레이아웃
|
|
if (!isMobile) {
|
|
return (
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* 좌측: 캘린더 */}
|
|
<div className="w-80 flex-shrink-0 p-6 border-r overflow-y-auto">
|
|
<PCCalendar
|
|
selectedDate={actualSelectedDate}
|
|
schedules={schedules}
|
|
categories={categories}
|
|
onSelectDate={handleSelectDate}
|
|
onMonthChange={changeMonth}
|
|
/>
|
|
</div>
|
|
|
|
{/* 우측: 일정 목록 */}
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-bold">
|
|
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
|
{new Date(actualSelectedDate).getDate()}일{' '}
|
|
{WEEKDAYS[new Date(actualSelectedDate).getDay()]}요일
|
|
</h2>
|
|
<span className="text-sm text-gray-500">
|
|
{selectedDateSchedules.length}개의 일정
|
|
</span>
|
|
</div>
|
|
<button className="p-2 hover:bg-gray-100 rounded-lg">
|
|
<Search size={20} className="text-gray-500" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 일정 목록 */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{schedulesLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Loading text="일정 로딩 중..." />
|
|
</div>
|
|
) : selectedDateSchedules.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-400">
|
|
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
|
{new Date(actualSelectedDate).getDate()}일 일정이 없습니다
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{selectedDateSchedules.map((schedule) => (
|
|
<ScheduleCard
|
|
key={schedule.id}
|
|
schedule={schedule}
|
|
variant="public"
|
|
showDate={false}
|
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 모바일 레이아웃 (간소화된 버전)
|
|
return (
|
|
<>
|
|
{/* 모바일 툴바 */}
|
|
<div className="mobile-toolbar-schedule shadow-sm z-50">
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<button
|
|
onClick={() => changeMonth(-1)}
|
|
className="p-2"
|
|
disabled={currentDate.getFullYear() === MIN_YEAR && currentDate.getMonth() === 0}
|
|
>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<span className="font-bold">
|
|
{currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => changeMonth(1)} className="p-2">
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
<button className="p-2">
|
|
<Search size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모바일 컨텐츠 */}
|
|
<div className="mobile-content">
|
|
<div className="px-4 py-4">
|
|
{schedulesLoading ? (
|
|
<div className="flex justify-center py-8">
|
|
<Loading size="sm" />
|
|
</div>
|
|
) : selectedDateSchedules.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-400">
|
|
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
|
{new Date(actualSelectedDate).getDate()}일 일정이 없습니다
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{selectedDateSchedules.map((schedule) => (
|
|
<ScheduleCard
|
|
key={schedule.id}
|
|
schedule={schedule}
|
|
variant="public"
|
|
showDate={false}
|
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default Schedule;
|