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:
caadiq 2026-01-21 20:40:07 +09:00
parent 9b96c475a7
commit 0255b35616
8 changed files with 408 additions and 50 deletions

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

View file

@ -1,8 +1,10 @@
import { Clock, Tag, Link2 } from 'lucide-react';
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
/**
* Mobile 일정 카드 컴포넌트
* , 스케줄 페이지 등에서 공통으로 사용
* Mobile 일정 카드 컴포넌트 (홈용)
* 페이지의 다가오는 일정 섹션에서 사용
* 간결한 레이아웃
*/
function MobileScheduleCard({ schedule, onClick, className = '' }) {
const scheduleDate = new Date(schedule.date);
@ -15,17 +17,13 @@ function MobileScheduleCard({ schedule, onClick, className = '' }) {
const isCurrentYear = scheduleYear === currentYear;
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
//
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 categoryName = schedule.category_name || schedule.category?.name;
const categoryInfo = getCategoryInfo(schedule);
const timeStr = getScheduleTime(schedule);
const displayMembers = getDisplayMembers(schedule);
const sourceName = schedule.source?.name;
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
return (
<div
onClick={onClick}
@ -47,7 +45,7 @@ function MobileScheduleCard({ schedule, onClick, className = '' }) {
{scheduleDate.getDate()}
</span>
<span className="text-xs text-gray-400 font-medium">
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}
{dayNames[scheduleDate.getDay()]}
</span>
</div>
@ -57,20 +55,20 @@ function MobileScheduleCard({ schedule, onClick, className = '' }) {
{/* 내용 영역 */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
{schedule.title}
{decodeHtmlEntities(schedule.title)}
</p>
{/* 시간 + 카테고리 + 소스 */}
<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">
<Clock size={12} />
{schedule.time.slice(0, 5)}
{timeStr}
</span>
)}
{categoryName && (
{categoryInfo.name && (
<span className="flex items-center gap-1">
<Tag size={12} />
{categoryName}
{categoryInfo.name}
</span>
)}
{sourceName && (

View file

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

View file

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

View file

@ -1,8 +1,9 @@
import { Clock, Tag, Link2 } from 'lucide-react';
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
/**
* PC 일정 카드 컴포넌트
* , 스케줄 페이지 에서 공통으로 사용
* PC 일정 카드 컴포넌트 (일반용)
* , 스케줄 페이지에서 공통으로 사용
*/
function ScheduleCard({ schedule, onClick, className = '' }) {
const scheduleDate = new Date(schedule.date);
@ -15,22 +16,13 @@ function ScheduleCard({ schedule, onClick, className = '' }) {
const isCurrentYear = scheduleYear === currentYear;
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
const day = scheduleDate.getDate();
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
const weekday = weekdays[scheduleDate.getDay()];
//
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 categoryInfo = getCategoryInfo(schedule);
const timeStr = getScheduleTime(schedule);
const displayMembers = getDisplayMembers(schedule);
const sourceName = schedule.source?.name;
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
return (
<div
onClick={onClick}
@ -39,36 +31,40 @@ function ScheduleCard({ schedule, onClick, className = '' }) {
{/* 날짜 영역 */}
<div
className="w-24 flex flex-col items-center justify-center text-white py-6"
style={{ backgroundColor: categoryColor }}
style={{ backgroundColor: categoryInfo.color }}
>
{!isCurrentYear && (
<span className="text-xs font-medium opacity-70">
<span className="text-xs font-medium opacity-60">
{scheduleYear}.{scheduleMonth + 1}
</span>
)}
{isCurrentYear && !isCurrentMonth && (
<span className="text-xs font-medium opacity-70">
<span className="text-xs font-medium opacity-60">
{scheduleMonth + 1}
</span>
)}
<span className="text-3xl font-bold">{day}</span>
<span className="text-sm font-medium opacity-80">{weekday}</span>
<span className="text-3xl font-bold">{scheduleDate.getDate()}</span>
<span className="text-sm font-medium opacity-80">
{dayNames[scheduleDate.getDay()]}
</span>
</div>
{/* 내용 영역 */}
{/* 스케줄 내용 */}
<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">
{schedule.time && (
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={16} className="opacity-60" />
{schedule.time.slice(0, 5)}
{timeStr}
</span>
)}
{categoryName && (
{categoryInfo.name && (
<span className="flex items-center gap-1">
<Tag size={16} className="opacity-60" />
{categoryName}
{categoryInfo.name}
</span>
)}
{sourceName && (

View file

@ -1,2 +1,8 @@
// PC 컴포넌트
export { default as ScheduleCard } from './ScheduleCard';
export { default as AdminScheduleCard } from './AdminScheduleCard';
// Mobile 컴포넌트
export { default as MobileScheduleCard } from './MobileScheduleCard';
export { default as MobileScheduleListCard } from './MobileScheduleListCard';
export { default as MobileScheduleSearchCard } from './MobileScheduleSearchCard';

View file

@ -40,6 +40,7 @@ export {
getScheduleDate,
getScheduleTime,
getMemberList,
getDisplayMembers,
isBirthdaySchedule,
groupSchedulesByDate,
countByCategory,

View file

@ -57,23 +57,40 @@ export function getScheduleTime(schedule) {
}
/**
* 스케줄에서 멤버 목록 추출
* 다양한 형식 처리 (문자열 배열, 객체 배열)
* 스케줄에서 멤버 이름 목록 추출
* 다양한 형식 처리 (member_names 문자열, 문자열 배열, 객체 배열)
* @param {object} schedule - 스케줄 객체
* @returns {Array<{ id: number, name: string }>}
* @returns {string[]} 멤버 이름 배열
*/
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 (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;
}
/**