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:
caadiq 2026-06-02 20:27:07 +09:00
parent 39aadf50e6
commit 39a6225897
4 changed files with 125 additions and 5 deletions

View file

@ -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;

View file

@ -1,6 +1,7 @@
export { default as Calendar } from './Calendar'; export { default as Calendar } from './Calendar';
export { default as ScheduleCard } from './ScheduleCard'; export { default as ScheduleCard } from './ScheduleCard';
export { default as ScheduleListCard } from './ScheduleListCard'; export { default as ScheduleListCard } from './ScheduleListCard';
export { default as UndatedScheduleListCard } from './UndatedScheduleListCard';
export { default as ScheduleSearchCard } from './ScheduleSearchCard'; export { default as ScheduleSearchCard } from './ScheduleSearchCard';
export { default as BirthdayCard } from './BirthdayCard'; export { default as BirthdayCard } from './BirthdayCard';
export { default as DebutCard } from './DebutCard'; export { default as DebutCard } from './DebutCard';

View file

@ -13,6 +13,7 @@ import { MIN_YEAR, SEARCH_LIMIT } from '@/constants';
import { import {
Calendar as MobileCalendar, Calendar as MobileCalendar,
ScheduleListCard as MobileScheduleListCard, ScheduleListCard as MobileScheduleListCard,
UndatedScheduleListCard as MobileUndatedScheduleListCard,
ScheduleSearchCard as MobileScheduleSearchCard, ScheduleSearchCard as MobileScheduleSearchCard,
BirthdayCard as MobileBirthdayCard, BirthdayCard as MobileBirthdayCard,
DebutCard as MobileDebutCard, DebutCard as MobileDebutCard,
@ -354,11 +355,20 @@ function MobileSchedule() {
const dateStr = `${year}-${month}-${day}`; const dateStr = `${year}-${month}-${day}`;
// ( ) // ( )
return schedules.filter((s) => { return schedules.filter((s) => {
if (s.datePrecision === 'month') return false; //
if (s.date.split('T')[0] !== dateStr) return false; if (s.date.split('T')[0] !== dateStr) return false;
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id); return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
}); });
}, [schedules, selectedDate, selectedCategories]); }, [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 ) // ( , calendarSchedules )
const monthCategories = useMemo(() => { const monthCategories = useMemo(() => {
const map = new Map(); const map = new Map();
@ -376,9 +386,12 @@ function MobileSchedule() {
}, [schedules]); }, [schedules]);
// ( , ) // ( , )
// ( )
const dotSchedules = useMemo(() => { const dotSchedules = useMemo(() => {
if (selectedCategories.length === 0) return schedules; return schedules.filter((s) => {
return schedules.filter((s) => selectedCategories.includes(s.category_id)); if (s.datePrecision === 'month') return false;
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
});
}, [schedules, selectedCategories]); }, [schedules, selectedCategories]);
// //
@ -848,7 +861,7 @@ function MobileSchedule() {
<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>
) : selectedDateSchedules.length === 0 ? ( ) : selectedDateSchedules.length === 0 && undatedSchedules.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center"> <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"> <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"> <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> </div>
)} )}
</> </>

View file

@ -275,9 +275,12 @@ function PCSchedule() {
}, [schedules, currentYearMonth, selectedCategories, isSearchMode]); }, [schedules, currentYearMonth, selectedCategories, isSearchMode]);
// ( , ) // ( , )
// ( )
const calendarSchedules = useMemo(() => { const calendarSchedules = useMemo(() => {
if (selectedCategories.length === 0) return schedules; return schedules.filter((s) => {
return schedules.filter((s) => selectedCategories.includes(s.category_id)); if (s.datePrecision === 'month') return false;
return selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
});
}, [schedules, selectedCategories]); }, [schedules, selectedCategories]);
// //