import React, { useState, useEffect, useCallback, useRef } from 'react'; import { UploadHistoryItem } from '../../types/api'; import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api'; // 날짜 유틸리티 const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate(); const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay(); type TabType = '전체' | '완료' | '예약' | '실패'; type UploadStatus = 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled'; const TAB_TO_API: Record = { '전체': 'all', '완료': 'completed', '예약': 'scheduled', '실패': 'failed', }; interface CalendarDay { date: number; month: 'prev' | 'current' | 'next'; isToday?: boolean; fullDate: Date; } interface DaySummary { completed: number; scheduled: number; failed: number; } const MOBILE_BREAKPOINT = 768; // 플랫폼 아이콘 const PlatformIcon: React.FC<{ platform: string; size?: number }> = ({ platform, size = 20 }) => { const src = platform === 'youtube' ? '/assets/images/social-youtube.png' : platform === 'instagram' ? '/assets/images/social-instagram.png' : '/assets/images/social-youtube.png'; return {platform}; }; // 상태 도트 색상 const statusColor = (status: UploadStatus) => { switch (status) { case 'completed': return '#1ba64f'; case 'scheduled': case 'pending': return '#2563eb'; case 'failed': return '#e15252'; default: return '#9bcacc'; } }; // 상태 라벨 const statusLabel = (status: UploadStatus) => { switch (status) { case 'completed': return '완료'; case 'scheduled': case 'pending': return '예약'; case 'failed': return '실패'; default: return status; } }; interface ContentCalendarContentProps { onNavigate?: (id: string) => void; } const ContentCalendarContent: React.FC = ({ onNavigate }) => { const today = new Date(); const [year, setYear] = useState(today.getFullYear()); const [month, setMonth] = useState(today.getMonth()); const [activeTab, setActiveTab] = useState('전체'); const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT); const [sheetOpen, setSheetOpen] = useState(false); // allItems: 캘린더 도트용 (전체) // panelItems: 오른쪽 패널용 (탭 필터) const [allItems, setAllItems] = useState([]); const [panelItems, setPanelItems] = useState([]); const [loading, setLoading] = useState(false); const [panelLoading, setPanelLoading] = useState(false); const [selectedDate, setSelectedDate] = useState(null); const panelRef = useRef(null); const dateRefs = useRef>({}); useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); const filterCancelled = (items: UploadHistoryItem[]) => items.filter(i => i.status !== 'cancelled'); // 캘린더용 전체 데이터 (year/month 기준) const fetchAllHistory = useCallback(async () => { setLoading(true); try { const res = await getUploadHistory('all', { year, month: month + 1, size: 100 }); if (res.items) setAllItems(filterCancelled(res.items)); } catch { // 에러 무시 } finally { setLoading(false); } }, [year, month]); // 패널용 탭 필터 데이터 (year/month 기준) const fetchPanelHistory = useCallback(async (tab: TabType) => { setPanelLoading(true); try { const res = await getUploadHistory(TAB_TO_API[tab], { year, month: month + 1, size: 100 }); if (res.items) setPanelItems(filterCancelled(res.items)); } catch { // 에러 무시 } finally { setPanelLoading(false); } }, [year, month]); useEffect(() => { fetchAllHistory(); }, [fetchAllHistory]); useEffect(() => { fetchPanelHistory(activeTab); }, [activeTab, fetchPanelHistory]); const refreshAll = useCallback(() => { fetchAllHistory(); fetchPanelHistory(activeTab); }, [fetchAllHistory, fetchPanelHistory, activeTab]); const handlePrevMonth = () => { if (month === 0) { setMonth(11); setYear(y => y - 1); } else setMonth(m => m - 1); }; const handleNextMonth = () => { if (month === 11) { setMonth(0); setYear(y => y + 1); } else setMonth(m => m + 1); }; // 날짜 문자열 (YYYY-MM-DD) const toDateKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; // 아이템 날짜 키 추출 (scheduled_at 또는 created_at) const itemDateKey = (item: UploadHistoryItem) => { const dateStr = item.scheduled_at || item.uploaded_at || item.created_at; return dateStr ? dateStr.slice(0, 10) : ''; }; // 날짜별 요약 맵 (캘린더용 — allItems 전체 기준) const daySummaryMap: Record = {}; allItems.forEach(item => { const key = itemDateKey(item); if (!key) return; if (!daySummaryMap[key]) daySummaryMap[key] = { completed: 0, scheduled: 0, failed: 0 }; if (item.status === 'completed') daySummaryMap[key].completed++; else if (item.status === 'failed') daySummaryMap[key].failed++; else daySummaryMap[key].scheduled++; }); // 전체 통계 (allItems 기준) const totalStats = allItems.reduce( (acc: { scheduled: number; completed: number; failed: number }, item) => { if (item.status === 'completed') acc.completed++; else if (item.status === 'failed') acc.failed++; else acc.scheduled++; return acc; }, { scheduled: 0, completed: 0, failed: 0 } ); // 패널 아이템은 서버에서 탭별로 이미 필터링된 panelItems 사용 // 날짜별로 그룹핑 (내림차순) const groupedByDate: Record = {}; panelItems.forEach(item => { const key = itemDateKey(item); if (!key) return; if (!groupedByDate[key]) groupedByDate[key] = []; groupedByDate[key].push(item); }); const sortedDateKeys = Object.keys(groupedByDate).sort((a, b) => b.localeCompare(a)); // 날짜 클릭 시 패널 스크롤 const handleDateClick = (dateKey: string) => { setSelectedDate(dateKey); // 패널 내부 스크롤: panelRef 기준으로 offsetTop 계산 setTimeout(() => { const el = dateRefs.current[dateKey]; const panel = panelRef.current; if (el && panel) { const panelTop = panel.getBoundingClientRect().top; const elTop = el.getBoundingClientRect().top; const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백 panel.scrollTo({ top: offset, behavior: 'smooth' }); } }, 50); }; // 취소 const handleCancel = async (uploadId: number) => { if (!window.confirm('예약을 취소하시겠습니까?')) return; // 낙관적 업데이트: 먼저 UI에서 제거 setAllItems(prev => prev.filter(i => i.upload_id !== uploadId)); setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId)); try { await cancelUpload(uploadId); } catch { // 실패 시 다시 불러오기 refreshAll(); alert('취소에 실패했습니다.'); } }; // 재시도 const handleRetry = async (uploadId: number) => { try { await retryUpload(uploadId); refreshAll(); } catch { alert('재시도에 실패했습니다.'); } }; // 시간 표시 (HH:MM) const formatTime = (item: UploadHistoryItem) => { const dateStr = item.scheduled_at || item.uploaded_at || item.created_at; if (!dateStr) return ''; const d = new Date(dateStr); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; // 날짜 표시 (M월 D) const formatDateLabel = (key: string) => { const [, m, d] = key.split('-'); return `${parseInt(m)}월 ${parseInt(d)}`; }; const buildCalendarDays = (): CalendarDay[] => { const days: CalendarDay[] = []; const firstDay = getFirstDayOfMonth(year, month); const daysInCurrent = getDaysInMonth(year, month); const daysInPrev = getDaysInMonth(year, month === 0 ? 11 : month - 1); const prevYear = month === 0 ? year - 1 : year; const prevMonth = month === 0 ? 11 : month - 1; const nextYear = month === 11 ? year + 1 : year; const nextMonth = month === 11 ? 0 : month + 1; for (let i = firstDay - 1; i >= 0; i--) { const d = daysInPrev - i; days.push({ date: d, month: 'prev', fullDate: new Date(prevYear, prevMonth, d) }); } for (let d = 1; d <= daysInCurrent; d++) { const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear(); days.push({ date: d, month: 'current', isToday, fullDate: new Date(year, month, d) }); } const remaining = 42 - days.length; for (let d = 1; d <= remaining; d++) { days.push({ date: d, month: 'next', fullDate: new Date(nextYear, nextMonth, d) }); } return days; }; const calendarDays = buildCalendarDays(); const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월']; const dayLabels = ['일','월','화','수','목','금','토']; const tabs: TabType[] = ['전체','완료','예약','실패']; // ── 캘린더 그리드 ────────────────────────────────────────────── const renderCalendarGrid = () => { const getDateLabel = (day: CalendarDay) => { if (day.month === 'prev' && day.date === getDaysInMonth(year, month === 0 ? 11 : month - 1)) { return `${month === 0 ? 12 : month}월 ${day.date}`; } if (day.month === 'next' && day.date === 1) { return `${month === 11 ? 1 : month + 2}월 ${day.date}`; } return `${day.date}`; }; return (
{/* 요일 헤더 */} {dayLabels.map((day, idx) => (
{day}
))} {/* 날짜 셀 */} {calendarDays.map((day, globalIdx) => { const colIdx = globalIdx % 7; const rowIdx = Math.floor(globalIdx / 7); const isLastRow = rowIdx === 5; const isLastCol = colIdx === 6; const isOtherMonth = day.month !== 'current'; const bgColor = isOtherMonth ? '#6ab0b3' : '#9bcacc'; const dateKey = toDateKey(day.fullDate); const summary = daySummaryMap[dateKey]; const isSelected = selectedDate === dateKey; const cellBorder = { borderRight: isLastCol ? 'none' : '1px solid #379599', borderBottom: isLastRow ? 'none' : '1px solid #379599', }; return (
!isOtherMonth && handleDateClick(dateKey)} style={{ backgroundColor: isSelected ? '#046266' : bgColor, ...cellBorder, position: 'relative', overflow: 'hidden', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', padding: 8, gap: 4, cursor: isOtherMonth ? 'default' : 'pointer', }} > {/* 날짜 숫자 */} {day.isToday ? (
{day.date}
) : ( {getDateLabel(day)} )} {/* 상태 도트 요약 */} {summary && !isOtherMonth && (
{summary.completed > 0 && (
완료 {summary.completed}
)} {summary.scheduled > 0 && (
예약 {summary.scheduled}
)} {summary.failed > 0 && (
실패 {summary.failed}
)}
)}
); })}
); }; // ── 탭 바 ──────────────────────────────────────────────────── const renderTabs = () => (
{tabs.map(tab => ( ))}
); // ── 통계 바 ──────────────────────────────────────────────── const renderStats = () => (
{[ { label: '예약', value: totalStats.scheduled }, { label: '완료', value: totalStats.completed }, { label: '실패', value: totalStats.failed }, ].map((item, idx) => ( {idx > 0 &&
}
{item.label} {item.value}
))}
); // ── 월 네비게이션 ──────────────────────────────────────────── const renderMonthNav = () => (

{year}년 {monthNames[month]}

); // ── 업로드 아이템 카드 ──────────────────────────────────────── const renderItem = (item: UploadHistoryItem) => { const isScheduled = item.status !== 'completed' && item.status !== 'failed'; const isFailed = item.status === 'failed'; return (
{/* 상단: 상태 + 시간 */}
{statusLabel(item.status as UploadStatus)} {formatTime(item)}
{/* 채널 아이콘 + 제목 */}
{item.title}
{/* 실패 메시지 */} {isFailed && item.error_message && (

{item.error_message}

)} {/* 액션 버튼 */} {(isScheduled || isFailed) && (
{isScheduled && ( )} {isFailed && ( )}
)}
); }; // ── 패널 콘텐츠 ──────────────────────────────────────────── const renderPanelContent = () => { if (panelLoading) { return (
불러오는 중...
); } if (sortedDateKeys.length === 0) { return (

최근 결과 없음

제작한 콘텐츠를 소셜 채널에 업로드해 보세요

); } return (
{sortedDateKeys.map(dateKey => (
{ dateRefs.current[dateKey] = el; }} >

{formatDateLabel(dateKey)}

{groupedByDate[dateKey].map(item => renderItem(item))}
))}
); }; // ════════════════════════════════════════════════════════════════ // 모바일 레이아웃 // ════════════════════════════════════════════════════════════════ if (isMobile) { return (

콘텐츠 캘린더

{renderMonthNav()} {renderStats()}
{renderCalendarGrid()}
{/* 바텀시트 */}
setSheetOpen(o => !o)} style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }} >
{renderTabs()}
{sheetOpen && (
{renderPanelContent()}
)}
); } // ════════════════════════════════════════════════════════════════ // 데스크탑 레이아웃 // ════════════════════════════════════════════════════════════════ return (

콘텐츠 캘린더

{/* 캘린더 영역 */}
{renderMonthNav()} {renderStats()}
{loading ? (
불러오는 중...
) : renderCalendarGrid()}
{/* 오른쪽 패널 */}
{renderTabs()}
{renderPanelContent()}
); }; export default ContentCalendarContent;