- frontend 폴더를 새로 리팩토링된 frontend-temp로 교체 - docs/architecture.md: 현재 프로젝트 구조 반영 - docs/development.md: API 클라이언트 구조 업데이트 - docs/frontend-improvement.md 삭제 (완료된 개선 계획) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
172 lines
5.7 KiB
JavaScript
172 lines
5.7 KiB
JavaScript
/**
|
|
* 일정 아이템 컴포넌트
|
|
* - 일정 목록에서 사용되는 개별 아이템
|
|
* - 일반 모드와 검색 모드에서 공통 사용
|
|
*/
|
|
import { memo } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Edit2, Trash2, ExternalLink, Clock, Tag, Link2 } from 'lucide-react';
|
|
import { decodeHtmlEntities } from '@/utils';
|
|
import {
|
|
getMemberList,
|
|
getScheduleDate,
|
|
getScheduleTime,
|
|
getCategoryInfo,
|
|
} from '@/utils/schedule';
|
|
|
|
/**
|
|
* 카테고리별 수정 경로 반환
|
|
*/
|
|
export const getEditPath = (scheduleId, categoryName) => {
|
|
switch (categoryName) {
|
|
case '유튜브':
|
|
return `/admin/schedule/${scheduleId}/edit/youtube`;
|
|
case 'X':
|
|
return `/admin/schedule/${scheduleId}/edit/x`;
|
|
default:
|
|
return `/admin/schedule/${scheduleId}/edit`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지
|
|
* @param {Object} props
|
|
* @param {Object} props.schedule - 일정 데이터
|
|
* @param {number} props.index - 목록 인덱스 (애니메이션 지연용)
|
|
* @param {string} props.selectedDate - 선택된 날짜
|
|
* @param {Function} props.getColorStyle - 색상 스타일 함수
|
|
* @param {Function} props.navigate - 네비게이션 함수
|
|
* @param {Function} props.openDeleteDialog - 삭제 다이얼로그 열기 함수
|
|
* @param {boolean} props.showYear - 연도 표시 여부 (검색 모드용)
|
|
* @param {boolean} props.animated - 애니메이션 적용 여부 (기본: true)
|
|
* @param {string} props.className - 추가 클래스명
|
|
*/
|
|
const ScheduleItem = memo(function ScheduleItem({
|
|
schedule,
|
|
index = 0,
|
|
selectedDate,
|
|
getColorStyle,
|
|
navigate,
|
|
openDeleteDialog,
|
|
showYear = false,
|
|
animated = true,
|
|
className = '',
|
|
}) {
|
|
const scheduleDate = new Date(getScheduleDate(schedule));
|
|
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
|
const categoryInfo = getCategoryInfo(schedule);
|
|
const categoryColor =
|
|
getColorStyle(categoryInfo.color)?.style?.backgroundColor || categoryInfo.color || '#6b7280';
|
|
const memberList = getMemberList(schedule);
|
|
const timeStr = getScheduleTime(schedule);
|
|
|
|
const content = (
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-20 text-center flex-shrink-0">
|
|
{showYear && (
|
|
<div className="text-xs text-gray-400 mb-0.5">
|
|
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
|
|
</div>
|
|
)}
|
|
<div className="text-2xl font-bold text-gray-900">{scheduleDate.getDate()}</div>
|
|
<div className="text-sm text-gray-500">
|
|
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
|
style={{ backgroundColor: categoryColor }}
|
|
/>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-gray-900">{decodeHtmlEntities(schedule.title)}</h3>
|
|
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
|
|
{timeStr && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock size={14} />
|
|
{timeStr}
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1">
|
|
<Tag size={14} />
|
|
{categoryInfo.name}
|
|
</span>
|
|
{schedule.source?.name && (
|
|
<span className="flex items-center gap-1">
|
|
<Link2 size={14} />
|
|
{schedule.source?.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{memberList.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
{memberList.length >= 5 ? (
|
|
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
|
프로미스나인
|
|
</span>
|
|
) : (
|
|
memberList.map((name, i) => (
|
|
<span
|
|
key={i}
|
|
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
|
>
|
|
{name.trim()}
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 생일 일정은 수정/삭제 불가 */}
|
|
{!isBirthday && (
|
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{schedule.source?.url && (
|
|
<a
|
|
href={schedule.source?.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
|
>
|
|
<ExternalLink size={18} />
|
|
</a>
|
|
)}
|
|
<button
|
|
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
|
>
|
|
<Edit2 size={18} />
|
|
</button>
|
|
<button
|
|
onClick={() => openDeleteDialog(schedule)}
|
|
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const baseClassName = `${showYear ? 'p-5' : 'p-6'} hover:bg-gray-50 transition-colors group ${className}`;
|
|
|
|
if (animated) {
|
|
return (
|
|
<motion.div
|
|
key={`${schedule.id}-${selectedDate || 'all'}`}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: Math.min(index, 10) * 0.03 }}
|
|
className={baseClassName}
|
|
>
|
|
{content}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
return <div className={baseClassName}>{content}</div>;
|
|
});
|
|
|
|
export default ScheduleItem;
|