feat(schedule-pc): 날짜 미정 일정 렌더링 (B안)

date_precision='month' 일정을 점선 "N월 중" 카드(UndatedScheduleCard)로
표시. 선택 날짜와 무관하게 해당 월이면 확정 일정 아래에 배치하고
사이에 "날짜 미정" 구분선 추가. 카운트에도 포함.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-06-02 20:10:26 +09:00
parent 44d30d48f6
commit 39aadf50e6
3 changed files with 119 additions and 18 deletions

View file

@ -0,0 +1,66 @@
import { memo } from 'react';
import { Tag, Link2 } from 'lucide-react';
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo } from '@/utils';
/**
* 날짜 미정(월만 확정) 일정 카드 (PC)
* date_precision === 'month' 일정에 사용. 날짜 대신 "날짜 미정"으로 표시하고
* 점선 테두리로 확정 일정과 구분한다.
*/
const UndatedScheduleCard = memo(function UndatedScheduleCard({ schedule, onClick, 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 (
<div
onClick={onClick}
className={`flex items-stretch bg-white rounded-2xl border-2 border-dashed border-gray-300 hover:border-gray-400 transition-colors overflow-hidden cursor-pointer ${className}`}
>
{/* 월 영역 (연한 카테고리 색) */}
<div
className="w-24 flex flex-col items-center justify-center py-6 leading-tight"
style={{ backgroundColor: `${categoryInfo.color}1a`, color: categoryInfo.color }}
>
<span className="text-2xl font-bold">{month}</span>
<span className="text-xs font-semibold mt-1 opacity-90"></span>
</div>
{/* 내용 */}
<div className="flex-1 p-6 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-2">{decodeHtmlEntities(schedule.title)}</h3>
<div className="flex flex-wrap gap-3 text-base text-gray-500">
{categoryInfo.name && (
<span className="flex items-center gap-1">
<Tag size={16} className="opacity-60" />
{categoryInfo.name}
</span>
)}
{sourceName && (
<span className="flex items-center gap-1">
<Link2 size={16} className="opacity-60" />
{sourceName}
</span>
)}
</div>
{displayMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{displayMembers.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
>
{name}
</span>
))}
</div>
)}
</div>
</div>
);
});
export default UndatedScheduleCard;

View file

@ -1,5 +1,6 @@
export { default as Calendar } from './Calendar';
export { default as ScheduleCard } from './ScheduleCard';
export { default as UndatedScheduleCard } from './UndatedScheduleCard';
export { default as BirthdayCard } from './BirthdayCard';
export { default as DebutCard } from './DebutCard';
export { default as CategoryFilter } from './CategoryFilter';

View file

@ -10,6 +10,7 @@ import {
Calendar,
CategoryFilter,
ScheduleCard,
UndatedScheduleCard,
BirthdayCard,
DebutCard,
} from '@/components/pc/public';
@ -255,12 +256,24 @@ function PCSchedule() {
}
return schedules.filter((s) => {
if (s.datePrecision === 'month') return false; //
const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth);
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
return matchesDate && matchesCategory;
});
}, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]);
// ( )
const undatedSchedules = useMemo(() => {
if (isSearchMode) return [];
return schedules.filter((s) => {
if (s.datePrecision !== 'month') return false;
const matchesMonth = s.date?.startsWith(currentYearMonth);
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
return matchesMonth && matchesCategory;
});
}, [schedules, currentYearMonth, selectedCategories, isSearchMode]);
// ( , )
const calendarSchedules = useMemo(() => {
if (selectedCategories.length === 0) return schedules;
@ -538,7 +551,7 @@ function PCSchedule() {
</AnimatePresence>
</div>
)}
<span className="text-sm text-gray-400">{filteredSchedules.length} 일정</span>
<span className="text-sm text-gray-400">{filteredSchedules.length + undatedSchedules.length} 일정</span>
</motion.div>
)}
</AnimatePresence>
@ -548,7 +561,7 @@ function PCSchedule() {
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto space-y-4 py-2 pr-2">
{loading ? (
<div className="h-full flex items-center justify-center text-gray-500">로딩 ...</div>
) : filteredSchedules.length > 0 ? (
) : filteredSchedules.length > 0 || undatedSchedules.length > 0 ? (
isSearchMode && searchTerm ? (
<>
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
@ -593,22 +606,43 @@ function PCSchedule() {
</div>
</>
) : (
filteredSchedules.map((schedule, index) => (
<motion.div
key={`${schedule.id}-${selectedDate || 'all'}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index, 10) * 0.03 }}
>
{schedule.is_birthday ? (
<BirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
) : schedule.is_debut || schedule.is_anniversary ? (
<DebutCard schedule={schedule} />
) : (
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</motion.div>
))
<>
{filteredSchedules.map((schedule, index) => (
<motion.div
key={`${schedule.id}-${selectedDate || 'all'}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index, 10) * 0.03 }}
>
{schedule.is_birthday ? (
<BirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
) : schedule.is_debut || schedule.is_anniversary ? (
<DebutCard schedule={schedule} />
) : (
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</motion.div>
))}
{/* 날짜 미정 일정 — 우선순위 낮춰 기존 일정 아래 배치 */}
{undatedSchedules.length > 0 && filteredSchedules.length > 0 && (
<div className="flex items-center gap-3 pt-2 text-gray-300">
<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) => (
<motion.div
key={`undated-${schedule.id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(filteredSchedules.length + index, 10) * 0.03 }}
>
<UndatedScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
</motion.div>
))}
</>
)
) : isSearchMode && searchTerm ? (
isSearchLoading ? (