feat(mobile-schedule): 카테고리 필터 추가 + 리스트 카드 플랫화
- 일정 리스트 위에 카테고리 필터 칩 추가 (해당 달 전체 카테고리) - 스크롤 방향 감지 자동 숨김 (내리면 숨고 올리면 보임), 상단 여백 일관 - 카테고리 선택 시 일정 목록 + 날짜 점 필터링 (공개 PC와 store 공유) - 일정 리스트 카드: 그림자 제거, 1.5px 테두리로 플랫하게 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
381461c25e
commit
9b2e4e190d
2 changed files with 96 additions and 7 deletions
|
|
@ -27,8 +27,8 @@ const ScheduleListCard = memo(function ScheduleListCard({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`cursor-pointer ${className}`}
|
className={`cursor-pointer ${className}`}
|
||||||
>
|
>
|
||||||
{/* 카드 본체 */}
|
{/* 카드 본체 (플랫 테두리) */}
|
||||||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
<div className="relative bg-white rounded-xl border-[1.5px] border-gray-100 overflow-hidden active:bg-gray-50 transition-colors">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* 시간 및 카테고리 뱃지 */}
|
{/* 시간 및 카테고리 뱃지 */}
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ function MobileSchedule() {
|
||||||
const {
|
const {
|
||||||
selectedDate: storedSelectedDate,
|
selectedDate: storedSelectedDate,
|
||||||
setSelectedDate: setStoredSelectedDate,
|
setSelectedDate: setStoredSelectedDate,
|
||||||
|
selectedCategories,
|
||||||
|
setSelectedCategories,
|
||||||
|
toggleCategory,
|
||||||
} = useScheduleStore();
|
} = useScheduleStore();
|
||||||
|
|
||||||
// 선택된 날짜 (store에 없으면 오늘 날짜)
|
// 선택된 날짜 (store에 없으면 오늘 날짜)
|
||||||
|
|
@ -343,15 +346,58 @@ function MobileSchedule() {
|
||||||
return days;
|
return days;
|
||||||
}, [selectedDate]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
// 선택된 날짜의 일정 (생일 우선)
|
// 선택된 날짜의 일정 (생일 우선) — 카테고리 필터 반영
|
||||||
const selectedDateSchedules = useMemo(() => {
|
const selectedDateSchedules = useMemo(() => {
|
||||||
const year = selectedDate.getFullYear();
|
const year = selectedDate.getFullYear();
|
||||||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(selectedDate.getDate()).padStart(2, '0');
|
const day = String(selectedDate.getDate()).padStart(2, '0');
|
||||||
const dateStr = `${year}-${month}-${day}`;
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
// 백엔드에서 이미 정렬된 상태로 전달됨 (특수 일정 우선)
|
// 백엔드에서 이미 정렬된 상태로 전달됨 (특수 일정 우선)
|
||||||
return schedules.filter((s) => s.date.split('T')[0] === dateStr);
|
return schedules.filter((s) => {
|
||||||
}, [schedules, selectedDate]);
|
if (s.date.split('T')[0] !== dateStr) return false;
|
||||||
|
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
||||||
|
});
|
||||||
|
}, [schedules, selectedDate, selectedCategories]);
|
||||||
|
|
||||||
|
// 해당 달 카테고리 목록 (카운트 포함, 날짜 점 필터용 calendarSchedules도 생성)
|
||||||
|
const monthCategories = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
schedules.forEach((s) => {
|
||||||
|
if (!s.category_id) return;
|
||||||
|
const existing = map.get(s.category_id);
|
||||||
|
if (existing) existing.count += 1;
|
||||||
|
else map.set(s.category_id, { id: s.category_id, name: s.category_name, color: s.category_color, count: 1 });
|
||||||
|
});
|
||||||
|
return Array.from(map.values()).sort((a, b) => {
|
||||||
|
if (a.name === '기타') return 1;
|
||||||
|
if (b.name === '기타') return -1;
|
||||||
|
return b.count - a.count;
|
||||||
|
});
|
||||||
|
}, [schedules]);
|
||||||
|
|
||||||
|
// 날짜 점 표시용 (카테고리 필터 반영, 날짜는 전체 달 유지)
|
||||||
|
const dotSchedules = useMemo(() => {
|
||||||
|
if (selectedCategories.length === 0) return schedules;
|
||||||
|
return schedules.filter((s) => selectedCategories.includes(s.category_id));
|
||||||
|
}, [schedules, selectedCategories]);
|
||||||
|
|
||||||
|
// 카테고리 칩 자동 숨김 (스크롤 방향 감지: 내리면 숨김, 올리면 보임)
|
||||||
|
const [chipsVisible, setChipsVisible] = useState(true);
|
||||||
|
const lastScrollYRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const container = document.querySelector('.mobile-content');
|
||||||
|
if (!container) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
const y = container.scrollTop;
|
||||||
|
const last = lastScrollYRef.current;
|
||||||
|
if (y < 16) setChipsVisible(true);
|
||||||
|
else if (y > last + 6) setChipsVisible(false);
|
||||||
|
else if (y < last - 6) setChipsVisible(true);
|
||||||
|
lastScrollYRef.current = y;
|
||||||
|
};
|
||||||
|
container.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => container.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 요일 이름
|
// 요일 이름
|
||||||
const getDayName = (date) => ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
|
const getDayName = (date) => ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
|
||||||
|
|
@ -588,7 +634,7 @@ function MobileSchedule() {
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
const dateStr = `${year}-${month}-${day}`;
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
const daySchedules = schedules
|
const daySchedules = dotSchedules
|
||||||
.filter((s) => s.date?.split('T')[0] === dateStr)
|
.filter((s) => s.date?.split('T')[0] === dateStr)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
|
|
||||||
|
|
@ -645,6 +691,7 @@ function MobileSchedule() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 달력 팝업 */}
|
{/* 달력 팝업 */}
|
||||||
|
|
@ -778,7 +825,47 @@ function MobileSchedule() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : loading ? (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 카테고리 필터 칩 (스크롤 방향 감지 자동 숨김) */}
|
||||||
|
{monthCategories.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-20 bg-white -mx-4 px-4 -mt-4 pt-4 pb-3 transition-transform duration-300 ease-out"
|
||||||
|
style={{ transform: chipsVisible ? 'translateY(0)' : 'translateY(-130%)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex overflow-x-auto scrollbar-hide gap-1.5"
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategories([])}
|
||||||
|
className={`flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-semibold transition-colors ${
|
||||||
|
selectedCategories.length === 0 ? 'bg-primary text-white' : 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체
|
||||||
|
</button>
|
||||||
|
{monthCategories.map((cat) => {
|
||||||
|
const active = selectedCategories.includes(cat.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => toggleCategory(cat.id)}
|
||||||
|
className={`flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-colors ${
|
||||||
|
active ? 'text-white' : 'bg-gray-100 text-gray-600'
|
||||||
|
}`}
|
||||||
|
style={active ? { backgroundColor: cat.color } : undefined}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: active ? '#fff' : cat.color }} />
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<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>
|
||||||
|
|
@ -830,6 +917,8 @@ function MobileSchedule() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue