o2o-castad-frontend/src/pages/Dashboard/ContentCalendarContent.tsx

803 lines
31 KiB
TypeScript

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<TabType, 'all' | 'completed' | 'scheduled' | 'failed'> = {
'전체': '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 <img src={src} alt={platform} style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0 }} />;
};
// 상태 도트 색상
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<ContentCalendarContentProps> = ({ onNavigate }) => {
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [activeTab, setActiveTab] = useState<TabType>('전체');
const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT);
const [sheetOpen, setSheetOpen] = useState(false);
// allItems: 캘린더 도트용 (전체)
// panelItems: 오른쪽 패널용 (탭 필터)
const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]);
const [panelItems, setPanelItems] = useState<UploadHistoryItem[]>([]);
const [loading, setLoading] = useState(false);
const [panelLoading, setPanelLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const dateRefs = useRef<Record<string, HTMLDivElement | null>>({});
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<string, DaySummary> = {};
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<string, UploadHistoryItem[]> = {};
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 (
<div
style={{
border: '1px solid #379599',
borderRadius: 12,
overflow: 'hidden',
display: 'grid',
gridTemplateRows: '28px repeat(6, minmax(0, 1fr))',
gridTemplateColumns: 'repeat(7, minmax(0, 1fr))',
flex: 1,
minHeight: isMobile ? 360 : 0,
}}
>
{/* 요일 헤더 */}
{dayLabels.map((day, idx) => (
<div
key={day}
style={{
backgroundColor: '#01393b',
borderRight: idx < 6 ? '1px solid #379599' : 'none',
borderBottom: '1px solid #379599',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
boxSizing: 'border-box',
}}
>
<span style={{
flex: 1,
fontFamily: 'Pretendard, sans-serif', fontWeight: 500,
fontSize: 12, color: '#cee5e6',
letterSpacing: '-0.072px', textAlign: 'right',
}}>
{day}
</span>
</div>
))}
{/* 날짜 셀 */}
{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 (
<div
key={globalIdx}
onClick={() => !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 ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', flexShrink: 0 }}>
<div style={{
width: 24, height: 24,
backgroundColor: '#e15252', borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500,
fontSize: 12, color: '#ffffff', letterSpacing: '-0.072px', lineHeight: 1,
}}>
{day.date}
</span>
</div>
</div>
) : (
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500,
fontSize: 12,
color: isOtherMonth ? '#067c80' : (isSelected ? '#e5f1f2' : '#01191a'),
letterSpacing: '-0.072px', textAlign: 'right', lineHeight: 1, flexShrink: 0,
}}>
{getDateLabel(day)}
</span>
)}
{/* 상태 도트 요약 */}
{summary && !isOtherMonth && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{summary.completed > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{summary.completed}
</span>
</div>
)}
{summary.scheduled > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{summary.scheduled}
</span>
</div>
)}
{summary.failed > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{summary.failed}
</span>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
};
// ── 탭 바 ────────────────────────────────────────────────────
const renderTabs = () => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{tabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '6px 16px', borderRadius: 999, border: 'none', cursor: 'pointer',
backgroundColor: activeTab === tab ? '#067c80' : 'transparent',
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 16,
color: activeTab === tab ? '#ffffff' : '#9bcacc',
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
}}
>
{tab}
</button>
))}
</div>
);
// ── 통계 바 ────────────────────────────────────────────────
const renderStats = () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{[
{ label: '예약', value: totalStats.scheduled },
{ label: '완료', value: totalStats.completed },
{ label: '실패', value: totalStats.failed },
].map((item, idx) => (
<React.Fragment key={item.label}>
{idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 14, color: '#9bcacc', lineHeight: '18px' }}>
{item.label}
</span>
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 14, color: '#ffffff', lineHeight: '18px' }}>
{item.value}
</span>
</div>
</React.Fragment>
))}
</div>
);
// ── 월 네비게이션 ────────────────────────────────────────────
const renderMonthNav = () => (
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
<button
onClick={handlePrevMonth}
style={{
width: 34, height: 34, borderRadius: 8,
backgroundColor: 'rgba(148,251,224,0.11)',
border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 8, flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15L7.5 10L12.5 5" stroke="#e5f1f2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 16,
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
margin: 0, whiteSpace: 'nowrap',
}}>
{year} {monthNames[month]}
</p>
<button
onClick={handleNextMonth}
style={{
width: 34, height: 34, borderRadius: 8,
backgroundColor: 'rgba(148,251,224,0.11)',
border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 8, flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M7.5 5L12.5 10L7.5 15" stroke="#e5f1f2" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
);
// ── 업로드 아이템 카드 ────────────────────────────────────────
const renderItem = (item: UploadHistoryItem) => {
const isScheduled = item.status !== 'completed' && item.status !== 'failed';
const isFailed = item.status === 'failed';
return (
<div
key={item.upload_id}
style={{
backgroundColor: isFailed ? 'rgba(225,82,82,0.08)' : 'rgba(55,149,153,0.12)',
borderRadius: 12,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 8,
borderLeft: isFailed ? '3px solid #e15252' : isScheduled ? '3px solid #2563eb' : '3px solid #1ba64f',
}}
>
{/* 상단: 상태 + 시간 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: statusColor(item.status as UploadStatus), flexShrink: 0,
}} />
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: statusColor(item.status as UploadStatus), lineHeight: 1,
}}>
{statusLabel(item.status as UploadStatus)}
</span>
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
color: '#9bcacc', lineHeight: 1, marginLeft: 'auto',
}}>
{formatTime(item)}
</span>
</div>
{/* 채널 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlatformIcon platform={item.platform} size={18} />
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#e5f1f2', lineHeight: 1.4,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
}}>
{item.title}
</span>
</div>
{/* 실패 메시지 */}
{isFailed && item.error_message && (
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 12,
color: '#e15252', lineHeight: 1.5, margin: 0,
}}>
{item.error_message}
</p>
)}
{/* 액션 버튼 */}
{(isScheduled || isFailed) && (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{isScheduled && (
<button
onClick={() => handleCancel(item.upload_id)}
style={{
height: 30, padding: '0 12px', borderRadius: 8,
border: '1px solid #379599', backgroundColor: 'transparent',
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: '#9bcacc', cursor: 'pointer',
}}
>
</button>
)}
{isFailed && (
<button
onClick={() => handleRetry(item.upload_id)}
style={{
height: 30, padding: '0 12px', borderRadius: 8,
border: 'none', backgroundColor: '#a65eff',
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: '#ffffff', cursor: 'pointer',
}}
>
</button>
)}
</div>
)}
</div>
);
};
// ── 패널 콘텐츠 ────────────────────────────────────────────
const renderPanelContent = () => {
if (panelLoading) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div>
);
}
if (sortedDateKeys.length === 0) {
return (
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16,
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0,
}}>
</p>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
color: '#9bcacc', letterSpacing: '-0.072px', lineHeight: 1, margin: 0,
}}>
</p>
</div>
<button
onClick={() => onNavigate?.('ADO2 콘텐츠')}
style={{
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
backgroundColor: '#a65eff', cursor: 'pointer',
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#ffffff',
}}
>
ADO2
</button>
</div>
);
}
return (
<div
ref={panelRef}
className="calendar-panel-scroll"
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }}
>
{sortedDateKeys.map(dateKey => (
<div
key={dateKey}
ref={el => { dateRefs.current[dateKey] = el; }}
>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 14,
color: '#9bcacc', margin: '0 0 8px 0', letterSpacing: '-0.084px',
}}>
{formatDateLabel(dateKey)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{groupedByDate[dateKey].map(item => renderItem(item))}
</div>
</div>
))}
</div>
);
};
// ════════════════════════════════════════════════════════════════
// 모바일 레이아웃
// ════════════════════════════════════════════════════════════════
if (isMobile) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
paddingTop: 24, paddingBottom: 20, width: '100%',
boxSizing: 'border-box', position: 'relative',
}}>
<div style={{ width: '100%', paddingBottom: 24, paddingLeft: 20, paddingRight: 20, boxSizing: 'border-box' }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
</p>
</div>
<div style={{
display: 'flex', flexDirection: 'column', gap: 16,
width: '100%', paddingLeft: 20, paddingRight: 20,
marginBottom: 16, boxSizing: 'border-box',
}}>
{renderMonthNav()}
{renderStats()}
</div>
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 360 }}>
{renderCalendarGrid()}
</div>
{/* 바텀시트 */}
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}>
<div style={{
backgroundColor: '#01393b',
borderTop: '1px solid #046266',
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
}}>
<div
onClick={() => setSheetOpen(o => !o)}
style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }}
>
<div style={{ width: 49, height: 4, backgroundColor: '#9bcacc', borderRadius: 999 }} />
</div>
<div style={{
borderBottom: sheetOpen ? '1px solid #046266' : 'none',
height: 50, display: 'flex', alignItems: 'center', padding: '0 16px',
}}>
{renderTabs()}
</div>
{sheetOpen && (
<div style={{ height: 400, overflowY: 'auto', backgroundColor: '#01393b', display: 'flex', flexDirection: 'column' }}>
{renderPanelContent()}
</div>
)}
</div>
</div>
<div style={{ height: sheetOpen ? 516 : 116 }} />
</div>
);
}
// ════════════════════════════════════════════════════════════════
// 데스크탑 레이아웃
// ════════════════════════════════════════════════════════════════
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '32px',
width: '100%',
minWidth: 1400,
height: '100%', boxSizing: 'border-box',
}}>
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
</p>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 340px',
gap: 16,
width: '100%', maxWidth: 1440,
flex: 1, minHeight: 0,
}}>
{/* 캘린더 영역 */}
<div className="calendar-grid-area" style={{
gridColumn: '1 / span 8',
backgroundColor: '#01393b',
borderRadius: 20, padding: 16,
display: 'flex', flexDirection: 'column', gap: 24,
overflow: 'hidden',
}}>
<div style={{
display: 'flex', alignItems: 'center',
justifyContent: 'space-between', flexShrink: 0, width: '100%',
}}>
{renderMonthNav()}
{renderStats()}
</div>
{loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div>
) : renderCalendarGrid()}
</div>
{/* 오른쪽 패널 */}
<div className="calendar-side-panel" style={{
gridColumn: '9 / span 1',
backgroundColor: '#01393b', borderRadius: 20,
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{
borderBottom: '1px solid #046266',
height: 66, display: 'flex', alignItems: 'center',
padding: '0 16px', flexShrink: 0,
}}>
{renderTabs()}
</div>
{renderPanelContent()}
</div>
</div>
</div>
);
};
export default ContentCalendarContent;