feat(schedule-pc): 날짜 미정 일정 렌더링 (B안)
date_precision='month' 일정을 점선 "N월 중" 카드(UndatedScheduleCard)로 표시. 선택 날짜와 무관하게 해당 월이면 확정 일정 아래에 배치하고 사이에 "날짜 미정" 구분선 추가. 카운트에도 포함. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
44d30d48f6
commit
39aadf50e6
3 changed files with 119 additions and 18 deletions
|
|
@ -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;
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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 UndatedScheduleCard } from './UndatedScheduleCard';
|
||||||
export { default as BirthdayCard } from './BirthdayCard';
|
export { default as BirthdayCard } from './BirthdayCard';
|
||||||
export { default as DebutCard } from './DebutCard';
|
export { default as DebutCard } from './DebutCard';
|
||||||
export { default as CategoryFilter } from './CategoryFilter';
|
export { default as CategoryFilter } from './CategoryFilter';
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Calendar,
|
Calendar,
|
||||||
CategoryFilter,
|
CategoryFilter,
|
||||||
ScheduleCard,
|
ScheduleCard,
|
||||||
|
UndatedScheduleCard,
|
||||||
BirthdayCard,
|
BirthdayCard,
|
||||||
DebutCard,
|
DebutCard,
|
||||||
} from '@/components/pc/public';
|
} from '@/components/pc/public';
|
||||||
|
|
@ -255,12 +256,24 @@ function PCSchedule() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return schedules.filter((s) => {
|
return schedules.filter((s) => {
|
||||||
|
if (s.datePrecision === 'month') return false; // 날짜 미정은 별도 처리
|
||||||
const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth);
|
const matchesDate = selectedDate ? s.date === selectedDate : s.date?.startsWith(currentYearMonth);
|
||||||
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
|
||||||
return matchesDate && matchesCategory;
|
return matchesDate && matchesCategory;
|
||||||
});
|
});
|
||||||
}, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]);
|
}, [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(() => {
|
const calendarSchedules = useMemo(() => {
|
||||||
if (selectedCategories.length === 0) return schedules;
|
if (selectedCategories.length === 0) return schedules;
|
||||||
|
|
@ -538,7 +551,7 @@ function PCSchedule() {
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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">
|
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto space-y-4 py-2 pr-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-full flex items-center justify-center text-gray-500">로딩 중...</div>
|
<div className="h-full flex items-center justify-center text-gray-500">로딩 중...</div>
|
||||||
) : filteredSchedules.length > 0 ? (
|
) : filteredSchedules.length > 0 || undatedSchedules.length > 0 ? (
|
||||||
isSearchMode && searchTerm ? (
|
isSearchMode && searchTerm ? (
|
||||||
<>
|
<>
|
||||||
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
|
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
|
||||||
|
|
@ -593,7 +606,8 @@ function PCSchedule() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
filteredSchedules.map((schedule, index) => (
|
<>
|
||||||
|
{filteredSchedules.map((schedule, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`${schedule.id}-${selectedDate || 'all'}`}
|
key={`${schedule.id}-${selectedDate || 'all'}`}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|
@ -608,7 +622,27 @@ function PCSchedule() {
|
||||||
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
<ScheduleCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</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 ? (
|
) : isSearchMode && searchTerm ? (
|
||||||
isSearchLoading ? (
|
isSearchLoading ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue