feat(schedule-mobile): 날짜 미정 일정 렌더링 (B안)
모바일 일정 페이지에 date_precision='month' 일정을 점선 "N월 중" 카드(UndatedScheduleListCard)로 표시. 선택 날짜와 무관하게 해당 달이면 확정 일정 아래 "날짜 미정" 구분선과 함께 배치. 캘린더/날짜 점은 1일에 찍지 않도록 PC·모바일 dot 목록에서 제외. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
39aadf50e6
commit
39a6225897
4 changed files with 125 additions and 5 deletions
|
|
@ -0,0 +1,86 @@
|
|||
import { memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link2 } from 'lucide-react';
|
||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo } from '@/utils';
|
||||
|
||||
/**
|
||||
* Mobile용 날짜 미정(월만 확정) 일정 카드
|
||||
* date_precision === 'month' 인 일정에 사용. 시간 대신 "N월 중"을 표시하고
|
||||
* 점선 테두리로 확정 일정과 구분한다.
|
||||
*/
|
||||
const UndatedScheduleListCard = memo(function UndatedScheduleListCard({
|
||||
schedule,
|
||||
onClick,
|
||||
delay = 0,
|
||||
className = '',
|
||||
}) {
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const displayMembers = getDisplayMembers(schedule);
|
||||
const sourceName = schedule.source?.name;
|
||||
|
||||
// date는 해당 월 1일(YYYY-MM-01)로 저장됨 → 월만 추출
|
||||
const month = new Date(schedule.date).getMonth() + 1;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer ${className}`}
|
||||
>
|
||||
{/* 카드 본체 (점선 테두리) */}
|
||||
<div className="relative bg-white rounded-xl border-[1.5px] border-dashed border-gray-300 overflow-hidden active:bg-gray-50 transition-colors">
|
||||
<div className="p-4">
|
||||
{/* "N월 중" 및 카테고리 뱃지 */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div
|
||||
className="px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
>
|
||||
{month}월 중
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${categoryInfo.color}15`,
|
||||
color: categoryInfo.color,
|
||||
}}
|
||||
>
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="font-bold text-[15px] text-gray-800 leading-snug">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
|
||||
{/* 출처 */}
|
||||
{sourceName && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
|
||||
<Link2 size={11} />
|
||||
<span>{sourceName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 멤버 */}
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
|
||||
{displayMembers.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default UndatedScheduleListCard;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export { default as Calendar } from './Calendar';
|
||||
export { default as ScheduleCard } from './ScheduleCard';
|
||||
export { default as ScheduleListCard } from './ScheduleListCard';
|
||||
export { default as UndatedScheduleListCard } from './UndatedScheduleListCard';
|
||||
export { default as ScheduleSearchCard } from './ScheduleSearchCard';
|
||||
export { default as BirthdayCard } from './BirthdayCard';
|
||||
export { default as DebutCard } from './DebutCard';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { MIN_YEAR, SEARCH_LIMIT } from '@/constants';
|
|||
import {
|
||||
Calendar as MobileCalendar,
|
||||
ScheduleListCard as MobileScheduleListCard,
|
||||
UndatedScheduleListCard as MobileUndatedScheduleListCard,
|
||||
ScheduleSearchCard as MobileScheduleSearchCard,
|
||||
BirthdayCard as MobileBirthdayCard,
|
||||
DebutCard as MobileDebutCard,
|
||||
|
|
@ -354,11 +355,20 @@ function MobileSchedule() {
|
|||
const dateStr = `${year}-${month}-${day}`;
|
||||
// 백엔드에서 이미 정렬된 상태로 전달됨 (특수 일정 우선)
|
||||
return schedules.filter((s) => {
|
||||
if (s.datePrecision === 'month') return false; // 날짜 미정은 별도 처리
|
||||
if (s.date.split('T')[0] !== dateStr) return false;
|
||||
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
||||
});
|
||||
}, [schedules, selectedDate, selectedCategories]);
|
||||
|
||||
// 날짜 미정(월만 확정) 일정 — 선택 날짜와 무관하게 해당 달이면 항상 하단에 표시
|
||||
const undatedSchedules = useMemo(() => {
|
||||
return schedules.filter((s) => {
|
||||
if (s.datePrecision !== 'month') return false;
|
||||
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
||||
});
|
||||
}, [schedules, selectedCategories]);
|
||||
|
||||
// 해당 달 카테고리 목록 (카운트 포함, 날짜 점 필터용 calendarSchedules도 생성)
|
||||
const monthCategories = useMemo(() => {
|
||||
const map = new Map();
|
||||
|
|
@ -376,9 +386,12 @@ function MobileSchedule() {
|
|||
}, [schedules]);
|
||||
|
||||
// 날짜 점 표시용 (카테고리 필터 반영, 날짜는 전체 달 유지)
|
||||
// 날짜 미정 일정은 점을 찍지 않음 (특정 날짜에 속하지 않으므로)
|
||||
const dotSchedules = useMemo(() => {
|
||||
if (selectedCategories.length === 0) return schedules;
|
||||
return schedules.filter((s) => selectedCategories.includes(s.category_id));
|
||||
return schedules.filter((s) => {
|
||||
if (s.datePrecision === 'month') return false;
|
||||
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
||||
});
|
||||
}, [schedules, selectedCategories]);
|
||||
|
||||
// 요일 이름
|
||||
|
|
@ -848,7 +861,7 @@ function MobileSchedule() {
|
|||
<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>
|
||||
) : selectedDateSchedules.length === 0 ? (
|
||||
) : selectedDateSchedules.length === 0 && undatedSchedules.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-10 h-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -895,6 +908,23 @@ function MobileSchedule() {
|
|||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 날짜 미정 일정 — 우선순위 낮춰 기존 일정 아래 배치 */}
|
||||
{undatedSchedules.length > 0 && selectedDateSchedules.length > 0 && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<div className="flex-1 border-t border-dashed border-gray-200" />
|
||||
<span className="text-xs font-medium text-gray-400">날짜 미정</span>
|
||||
<div className="flex-1 border-t border-dashed border-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
{undatedSchedules.map((schedule, index) => (
|
||||
<MobileUndatedScheduleListCard
|
||||
key={`undated-${schedule.id}`}
|
||||
schedule={schedule}
|
||||
delay={(selectedDateSchedules.length + index) * 0.05}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -275,9 +275,12 @@ function PCSchedule() {
|
|||
}, [schedules, currentYearMonth, selectedCategories, isSearchMode]);
|
||||
|
||||
// 달력 점 표시용 (카테고리만 필터링, 날짜는 한 달 전체 유지)
|
||||
// 날짜 미정 일정은 점을 찍지 않음 (특정 날짜에 속하지 않으므로)
|
||||
const calendarSchedules = useMemo(() => {
|
||||
if (selectedCategories.length === 0) return schedules;
|
||||
return schedules.filter((s) => selectedCategories.includes(s.category_id));
|
||||
return schedules.filter((s) => {
|
||||
if (s.datePrecision === 'month') return false;
|
||||
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
||||
});
|
||||
}, [schedules, selectedCategories]);
|
||||
|
||||
// 가상 스크롤
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue