feat(frontend): 일정 카드 컴포넌트 5종 생성
PC 카드 (2종): - ScheduleCard: 일반 일정 카드 (홈, 스케줄 페이지) - AdminScheduleCard: 관리자 일정 카드 (편집/삭제 버튼 포함) Mobile 카드 (3종): - MobileScheduleCard: 홈 페이지용 (간결한 레이아웃) - MobileScheduleListCard: 스케줄 타임라인용 (날짜 없이 시간/카테고리) - MobileScheduleSearchCard: 검색 결과용 (왼쪽에 날짜 표시) 공통 변경: - decodeHtmlEntities를 사용하여 HTML 엔티티 디코딩 - getDisplayMembers 유틸 추가 (5명 이상일 때 프로미스나인 표시) - getMemberList가 member_names 문자열 처리하도록 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9b96c475a7
commit
0255b35616
8 changed files with 408 additions and 50 deletions
139
frontend-temp/src/components/schedule/AdminScheduleCard.jsx
Normal file
139
frontend-temp/src/components/schedule/AdminScheduleCard.jsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Clock, Tag, Link2, ExternalLink, Edit2, Trash2 } from 'lucide-react';
|
||||||
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 관리자 일정 카드 컴포넌트
|
||||||
|
* 관리자 일정 관리 페이지에서 사용
|
||||||
|
* 편집/삭제 버튼 포함
|
||||||
|
*/
|
||||||
|
function AdminScheduleCard({
|
||||||
|
schedule,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
className = '',
|
||||||
|
}) {
|
||||||
|
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||||
|
const today = new Date();
|
||||||
|
const currentYear = today.getFullYear();
|
||||||
|
const currentMonth = today.getMonth();
|
||||||
|
|
||||||
|
const scheduleYear = scheduleDate.getFullYear();
|
||||||
|
const scheduleMonth = scheduleDate.getMonth();
|
||||||
|
const isCurrentYear = scheduleYear === currentYear;
|
||||||
|
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||||
|
|
||||||
|
const categoryInfo = getCategoryInfo(schedule);
|
||||||
|
const timeStr = getScheduleTime(schedule);
|
||||||
|
const displayMembers = getDisplayMembers(schedule);
|
||||||
|
const sourceName = schedule.source?.name;
|
||||||
|
const sourceUrl = schedule.source?.url;
|
||||||
|
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
|
||||||
|
|
||||||
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-6 hover:bg-gray-50 transition-colors group ${className}`}>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* 날짜 영역 */}
|
||||||
|
<div className="w-20 text-center flex-shrink-0">
|
||||||
|
{!isCurrentYear && (
|
||||||
|
<div className="text-xs text-gray-400 mb-0.5">
|
||||||
|
{scheduleYear}.{scheduleMonth + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCurrentYear && !isCurrentMonth && (
|
||||||
|
<div className="text-xs text-gray-400 mb-0.5">
|
||||||
|
{scheduleMonth + 1}월
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{scheduleDate.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{dayNames[scheduleDate.getDay()]}요일
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 색상 바 */}
|
||||||
|
<div
|
||||||
|
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||||
|
style={{ backgroundColor: categoryInfo.color }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 스케줄 내용 */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{categoryInfo.name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tag size={14} />
|
||||||
|
{categoryInfo.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{sourceName && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Link2 size={14} />
|
||||||
|
{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-xs font-medium rounded-full"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 (생일 일정은 수정/삭제 불가) */}
|
||||||
|
{!isBirthday && (
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{sourceUrl && (
|
||||||
|
<a
|
||||||
|
href={sourceUrl}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(schedule)}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(schedule)}
|
||||||
|
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminScheduleCard;
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Clock, Tag, Link2 } from 'lucide-react';
|
import { Clock, Tag, Link2 } from 'lucide-react';
|
||||||
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile 일정 카드 컴포넌트
|
* Mobile 일정 카드 컴포넌트 (홈용)
|
||||||
* 홈, 스케줄 페이지 등에서 공통으로 사용
|
* 홈 페이지의 다가오는 일정 섹션에서 사용
|
||||||
|
* 간결한 레이아웃
|
||||||
*/
|
*/
|
||||||
function MobileScheduleCard({ schedule, onClick, className = '' }) {
|
function MobileScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
const scheduleDate = new Date(schedule.date);
|
const scheduleDate = new Date(schedule.date);
|
||||||
|
|
@ -15,17 +17,13 @@ function MobileScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
const isCurrentYear = scheduleYear === currentYear;
|
const isCurrentYear = scheduleYear === currentYear;
|
||||||
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||||
|
|
||||||
// 멤버 처리
|
const categoryInfo = getCategoryInfo(schedule);
|
||||||
const memberList = schedule.member_names
|
const timeStr = getScheduleTime(schedule);
|
||||||
? schedule.member_names.split(',').map((n) => n.trim()).filter(Boolean)
|
const displayMembers = getDisplayMembers(schedule);
|
||||||
: schedule.members?.map((m) => m.name) || [];
|
|
||||||
|
|
||||||
// 5명 이상이면 '프로미스나인'으로 표시
|
|
||||||
const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList;
|
|
||||||
|
|
||||||
const categoryName = schedule.category_name || schedule.category?.name;
|
|
||||||
const sourceName = schedule.source?.name;
|
const sourceName = schedule.source?.name;
|
||||||
|
|
||||||
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -47,7 +45,7 @@ function MobileScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
{scheduleDate.getDate()}
|
{scheduleDate.getDate()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 font-medium">
|
<span className="text-xs text-gray-400 font-medium">
|
||||||
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}
|
{dayNames[scheduleDate.getDay()]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -57,20 +55,20 @@ function MobileScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
{/* 내용 영역 */}
|
{/* 내용 영역 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
|
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
|
||||||
{schedule.title}
|
{decodeHtmlEntities(schedule.title)}
|
||||||
</p>
|
</p>
|
||||||
{/* 시간 + 카테고리 + 소스 */}
|
{/* 시간 + 카테고리 + 소스 */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-400">
|
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-400">
|
||||||
{schedule.time && (
|
{timeStr && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock size={12} />
|
<Clock size={12} />
|
||||||
{schedule.time.slice(0, 5)}
|
{timeStr}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{categoryName && (
|
{categoryInfo.name && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Tag size={12} />
|
<Tag size={12} />
|
||||||
{categoryName}
|
{categoryInfo.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{sourceName && (
|
{sourceName && (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Clock, Link2 } from 'lucide-react';
|
||||||
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile 일정 리스트 카드 컴포넌트 (타임라인용)
|
||||||
|
* 스케줄 페이지에서 날짜별 일정 목록에 사용
|
||||||
|
* 날짜가 이미 헤더에 표시되므로 날짜 없이 표시
|
||||||
|
*/
|
||||||
|
function MobileScheduleListCard({
|
||||||
|
schedule,
|
||||||
|
onClick,
|
||||||
|
delay = 0,
|
||||||
|
className = '',
|
||||||
|
}) {
|
||||||
|
const categoryInfo = getCategoryInfo(schedule);
|
||||||
|
const timeStr = getScheduleTime(schedule);
|
||||||
|
const displayMembers = getDisplayMembers(schedule);
|
||||||
|
const sourceName = schedule.source?.name;
|
||||||
|
|
||||||
|
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-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
||||||
|
<div className="p-4">
|
||||||
|
{/* 시간 및 카테고리 뱃지 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
{timeStr && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||||
|
style={{ backgroundColor: categoryInfo.color }}
|
||||||
|
>
|
||||||
|
<Clock size={10} />
|
||||||
|
{timeStr}
|
||||||
|
</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 MobileScheduleListCard;
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Clock, Link2 } from 'lucide-react';
|
||||||
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile 일정 검색 카드 컴포넌트
|
||||||
|
* 스케줄 페이지의 검색 결과에서 사용
|
||||||
|
* 날짜를 왼쪽에 표시하는 레이아웃
|
||||||
|
*/
|
||||||
|
function MobileScheduleSearchCard({
|
||||||
|
schedule,
|
||||||
|
onClick,
|
||||||
|
delay = 0,
|
||||||
|
className = '',
|
||||||
|
}) {
|
||||||
|
const scheduleDate = new Date(schedule.date || schedule.datetime);
|
||||||
|
const categoryInfo = getCategoryInfo(schedule);
|
||||||
|
const timeStr = getScheduleTime(schedule);
|
||||||
|
const displayMembers = getDisplayMembers(schedule);
|
||||||
|
const sourceName = schedule.source?.name;
|
||||||
|
|
||||||
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const isSunday = scheduleDate.getDay() === 0;
|
||||||
|
const isSaturday = scheduleDate.getDay() === 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`cursor-pointer ${className}`}
|
||||||
|
>
|
||||||
|
{/* 카드 본체 */}
|
||||||
|
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex">
|
||||||
|
{/* 왼쪽 날짜 영역 */}
|
||||||
|
<div className="flex-shrink-0 w-16 py-3 px-2 bg-gray-50 border-r border-gray-100 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{scheduleDate.getFullYear()}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-gray-800">
|
||||||
|
{scheduleDate.getMonth() + 1}.{scheduleDate.getDate()}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-medium ${
|
||||||
|
isSunday
|
||||||
|
? 'text-red-500'
|
||||||
|
: isSaturday
|
||||||
|
? 'text-blue-500'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dayNames[scheduleDate.getDay()]}요일
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 p-4 min-w-0">
|
||||||
|
{/* 시간 및 카테고리 뱃지 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
|
||||||
|
{timeStr && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||||
|
style={{ backgroundColor: categoryInfo.color }}
|
||||||
|
>
|
||||||
|
<Clock size={10} />
|
||||||
|
{timeStr}
|
||||||
|
</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 line-clamp-2">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileScheduleSearchCard;
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Clock, Tag, Link2 } from 'lucide-react';
|
import { Clock, Tag, Link2 } from 'lucide-react';
|
||||||
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC 일정 카드 컴포넌트
|
* PC 일정 카드 컴포넌트 (일반용)
|
||||||
* 홈, 스케줄 페이지 등에서 공통으로 사용
|
* 홈, 스케줄 페이지에서 공통으로 사용
|
||||||
*/
|
*/
|
||||||
function ScheduleCard({ schedule, onClick, className = '' }) {
|
function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
const scheduleDate = new Date(schedule.date);
|
const scheduleDate = new Date(schedule.date);
|
||||||
|
|
@ -15,22 +16,13 @@ function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
const isCurrentYear = scheduleYear === currentYear;
|
const isCurrentYear = scheduleYear === currentYear;
|
||||||
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
|
||||||
|
|
||||||
const day = scheduleDate.getDate();
|
const categoryInfo = getCategoryInfo(schedule);
|
||||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
const timeStr = getScheduleTime(schedule);
|
||||||
const weekday = weekdays[scheduleDate.getDay()];
|
const displayMembers = getDisplayMembers(schedule);
|
||||||
|
|
||||||
// 멤버 처리
|
|
||||||
const memberList = schedule.member_names
|
|
||||||
? schedule.member_names.split(',').map((n) => n.trim()).filter(Boolean)
|
|
||||||
: schedule.members?.map((m) => m.name) || [];
|
|
||||||
|
|
||||||
// 5명 이상이면 '프로미스나인'으로 표시
|
|
||||||
const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList;
|
|
||||||
|
|
||||||
const categoryColor = schedule.category_color || '#6366f1';
|
|
||||||
const categoryName = schedule.category_name || schedule.category?.name;
|
|
||||||
const sourceName = schedule.source?.name;
|
const sourceName = schedule.source?.name;
|
||||||
|
|
||||||
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -39,36 +31,40 @@ function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
{/* 날짜 영역 */}
|
{/* 날짜 영역 */}
|
||||||
<div
|
<div
|
||||||
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
||||||
style={{ backgroundColor: categoryColor }}
|
style={{ backgroundColor: categoryInfo.color }}
|
||||||
>
|
>
|
||||||
{!isCurrentYear && (
|
{!isCurrentYear && (
|
||||||
<span className="text-xs font-medium opacity-70">
|
<span className="text-xs font-medium opacity-60">
|
||||||
{scheduleYear}.{scheduleMonth + 1}
|
{scheduleYear}.{scheduleMonth + 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isCurrentYear && !isCurrentMonth && (
|
{isCurrentYear && !isCurrentMonth && (
|
||||||
<span className="text-xs font-medium opacity-70">
|
<span className="text-xs font-medium opacity-60">
|
||||||
{scheduleMonth + 1}월
|
{scheduleMonth + 1}월
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-3xl font-bold">{day}</span>
|
<span className="text-3xl font-bold">{scheduleDate.getDate()}</span>
|
||||||
<span className="text-sm font-medium opacity-80">{weekday}</span>
|
<span className="text-sm font-medium opacity-80">
|
||||||
|
{dayNames[scheduleDate.getDay()]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 내용 영역 */}
|
{/* 스케줄 내용 */}
|
||||||
<div className="flex-1 p-6 flex flex-col justify-center">
|
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||||
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
|
<h3 className="font-bold text-lg mb-2">
|
||||||
|
{decodeHtmlEntities(schedule.title)}
|
||||||
|
</h3>
|
||||||
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||||
{schedule.time && (
|
{timeStr && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock size={16} className="opacity-60" />
|
<Clock size={16} className="opacity-60" />
|
||||||
{schedule.time.slice(0, 5)}
|
{timeStr}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{categoryName && (
|
{categoryInfo.name && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Tag size={16} className="opacity-60" />
|
<Tag size={16} className="opacity-60" />
|
||||||
{categoryName}
|
{categoryInfo.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{sourceName && (
|
{sourceName && (
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,8 @@
|
||||||
|
// PC 컴포넌트
|
||||||
export { default as ScheduleCard } from './ScheduleCard';
|
export { default as ScheduleCard } from './ScheduleCard';
|
||||||
|
export { default as AdminScheduleCard } from './AdminScheduleCard';
|
||||||
|
|
||||||
|
// Mobile 컴포넌트
|
||||||
export { default as MobileScheduleCard } from './MobileScheduleCard';
|
export { default as MobileScheduleCard } from './MobileScheduleCard';
|
||||||
|
export { default as MobileScheduleListCard } from './MobileScheduleListCard';
|
||||||
|
export { default as MobileScheduleSearchCard } from './MobileScheduleSearchCard';
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export {
|
||||||
getScheduleDate,
|
getScheduleDate,
|
||||||
getScheduleTime,
|
getScheduleTime,
|
||||||
getMemberList,
|
getMemberList,
|
||||||
|
getDisplayMembers,
|
||||||
isBirthdaySchedule,
|
isBirthdaySchedule,
|
||||||
groupSchedulesByDate,
|
groupSchedulesByDate,
|
||||||
countByCategory,
|
countByCategory,
|
||||||
|
|
|
||||||
|
|
@ -57,23 +57,40 @@ export function getScheduleTime(schedule) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 스케줄에서 멤버 목록 추출
|
* 스케줄에서 멤버 이름 목록 추출
|
||||||
* 다양한 형식 처리 (문자열 배열, 객체 배열)
|
* 다양한 형식 처리 (member_names 문자열, 문자열 배열, 객체 배열)
|
||||||
* @param {object} schedule - 스케줄 객체
|
* @param {object} schedule - 스케줄 객체
|
||||||
* @returns {Array<{ id: number, name: string }>}
|
* @returns {string[]} 멤버 이름 배열
|
||||||
*/
|
*/
|
||||||
export function getMemberList(schedule) {
|
export function getMemberList(schedule) {
|
||||||
const members = schedule.members || [];
|
// member_names 문자열이 있으면 사용 (쉼표 구분)
|
||||||
|
if (schedule.member_names) {
|
||||||
|
return schedule.member_names.split(',').map((n) => n.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = schedule.members || [];
|
||||||
if (members.length === 0) return [];
|
if (members.length === 0) return [];
|
||||||
|
|
||||||
// 문자열 배열인 경우 (검색 결과)
|
// 문자열 배열인 경우 (검색 결과)
|
||||||
if (typeof members[0] === 'string') {
|
if (typeof members[0] === 'string') {
|
||||||
return members.map((name, idx) => ({ id: idx, name }));
|
return members.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 객체 배열인 경우
|
// 객체 배열인 경우
|
||||||
return members;
|
return members.map((m) => m.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버 표시 이름 가져오기 (5명 이상이면 '프로미스나인')
|
||||||
|
* @param {object} schedule - 스케줄 객체
|
||||||
|
* @returns {string[]} 표시할 멤버 이름 배열
|
||||||
|
*/
|
||||||
|
export function getDisplayMembers(schedule) {
|
||||||
|
const memberList = getMemberList(schedule);
|
||||||
|
if (memberList.length >= 5) {
|
||||||
|
return ['프로미스나인'];
|
||||||
|
}
|
||||||
|
return memberList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue