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

330 lines
11 KiB
React
Raw Normal View History

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, setCurrentDate, selectedDate, setSelectedDate } = useScheduleStore();
// 초기값 설정
const today = getTodayKST();
const actualSelectedDate = selectedDate || 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;