import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { authenticatedFetch, API_URL } from '../../utils/api'; import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, CartesianGrid, Tooltip, } from 'recharts'; // 환경변수에서 테스트 모드 확인 const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; // ===================================================== // Types // ===================================================== interface ContentMetric { id: string; label: string; value: number; // 원시 숫자값 (단위: unit 참조) unit: string; // "count" | "hours" | "minutes" trend: number; trendDirection: 'up' | 'down' | '-'; } interface MonthlyData { month: string; thisYear: number; lastYear: number; } interface DailyData { date: string; thisPeriod: number; lastPeriod: number; } // interface PlatformMetric { // 미사용 — platform_data 기능 예정 // id: string; // label: string; // value: string; // unit?: string; // trend: number; // trendDirection: 'up' | 'down'; // } // interface PlatformData { // 미사용 — platform_data 기능 예정 // platform: 'youtube' | 'instagram'; // displayName: string; // metrics: PlatformMetric[]; // } interface TopContent { id: string; title: string; thumbnail: string; platform: 'youtube' | 'instagram'; views: number; // 원시 정수 (포맷팅은 formatNumber로 처리) engagement: string; date: string; } interface AudienceData { ageGroups: { label: string; percentage: number }[]; gender: { male: number; female: number }; topRegions: { region: string; percentage: number }[]; } interface DashboardResponse { contentMetrics: ContentMetric[]; monthlyData: MonthlyData[]; dailyData: DailyData[]; topContent: TopContent[]; audienceData: AudienceData; hasUploads: boolean; // 업로드 영상 존재 여부 (false 시 mock 데이터 + 안내 메시지 표시) // platformData: PlatformData[]; // 미사용 } interface ConnectedAccount { id: number; platform: string; platformUserId: string; platformUsername?: string | null; channelTitle?: string | null; connectedAt: string; isActive: boolean; } // ===================================================== // Mock Data (연동 전 샘플 데이터) // ===================================================== const MOCK_CONTENT_METRICS: ContentMetric[] = [ { id: 'total-views', label: '조회수', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' }, { id: 'total-watch-time', label: '시청시간', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' }, { id: 'avg-view-duration',label: '평균 시청시간', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' }, { id: 'new-subscribers', label: '신규 구독자', value: 483, unit: 'count', trend: 50, trendDirection: 'up' }, { id: 'likes', label: '좋아요', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' }, { id: 'comments', label: '댓글', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' }, { id: 'shares', label: '공유', value: 840, unit: 'count', trend: 15, trendDirection: 'up' }, { id: 'uploaded-videos', label: '업로드 영상', value: 17, unit: 'count', trend: 4, trendDirection: 'up' }, ]; const MOCK_MONTHLY_DATA: MonthlyData[] = [ { month: '1월', thisYear: 18000, lastYear: 14500 }, { month: '2월', thisYear: 19500, lastYear: 15800 }, { month: '3월', thisYear: 21000, lastYear: 17200 }, { month: '4월', thisYear: 18500, lastYear: 16800 }, { month: '5월', thisYear: 24000, lastYear: 19500 }, { month: '6월', thisYear: 27500, lastYear: 21000 }, { month: '7월', thisYear: 32000, lastYear: 23500 }, { month: '8월', thisYear: 29500, lastYear: 24800 }, { month: '9월', thisYear: 31000, lastYear: 26200 }, { month: '10월', thisYear: 28500, lastYear: 25500 }, { month: '11월', thisYear: 34000, lastYear: 27800 }, { month: '12월', thisYear: 38000, lastYear: 29500 }, ]; const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => { const month = i <= 11 ? 1 : 2; const day = i <= 11 ? i + 20 : i - 11; const thisPeriod = [820, 910, 780, 1020, 890, 1100, 950, 870, 1010, 980, 1120, 1050, 930, 860, 1080, 1150, 970, 1030, 800, 990, 1180, 1070, 920, 880, 1040, 1110, 960, 1000, 850, 940][i]; const lastPeriod = [680, 720, 650, 800, 700, 800, 770, 710, 800, 790, 900, 860, 760, 700, 870, 920, 790, 840, 690, 800, 940, 870, 700, 720, 850, 890, 780, 810, 690, 760][i]; return { date: `${month}/${day}`, thisPeriod, lastPeriod }; }); const MOCK_TOP_CONTENT: TopContent[] = [ { id: '1', title: '겨울 펜션 프로모션 영상', thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: 125400, engagement: '8.2%', date: '2025.01.15' }, { id: '2', title: '스테이 머뭄 소개 릴스', thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: 89200, engagement: '12.5%', date: '2025.01.22' }, { id: '3', title: '신년 특가 이벤트 안내', thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: 67800, engagement: '6.4%', date: '2025.01.08' }, { id: '4', title: '펜션 야경 타임랩스', thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: 54300, engagement: '15.8%', date: '2025.01.28' }, ]; const MOCK_AUDIENCE_DATA: AudienceData = { ageGroups: [ { label: '13-24', percentage: 12 }, { label: '25-34', percentage: 35 }, { label: '35-44', percentage: 28 }, { label: '45-54', percentage: 18 }, { label: '55+', percentage: 7 }, ], gender: { male: 42, female: 58 }, topRegions: [ { region: '한국', percentage: 77 }, { region: '일본', percentage: 5 }, { region: '중국', percentage: 4 }, { region: '미국', percentage: 2 }, { region: '인도', percentage: 1 }, ], }; // ===================================================== // Animation Components // ===================================================== interface AnimatedItemProps { children: React.ReactNode; index: number; baseDelay?: number; className?: string; } const AnimatedItem: React.FC = ({ children, index, baseDelay = 0, className = '' }) => { const [isVisible, setIsVisible] = useState(false); const delay = baseDelay + index * 80; useEffect(() => { const timer = setTimeout(() => setIsVisible(true), delay); return () => clearTimeout(timer); }, [delay]); return (
{children}
); }; interface AnimatedSectionProps { children: React.ReactNode; delay?: number; className?: string; } const AnimatedSection: React.FC = ({ children, delay = 0, className = '' }) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { const timer = setTimeout(() => setIsVisible(true), delay); return () => clearTimeout(timer); }, [delay]); return (
{children}
); }; const formatNumber = (num: number): string => { if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; return num.toString(); }; // unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리) const formatValue = (value: number, unit: string): string => { if (unit === 'hours') return value.toFixed(1) + '시간'; if (unit === 'minutes') return Math.round(value) + '분'; return formatNumber(Math.round(value)); }; // unit에 따라 trend 절댓값을 표시용 문자열로 변환 const formatTrend = (trend: number, unit: string): string => { const abs = Math.abs(trend); if (unit === 'hours') return abs.toFixed(1) + '시간'; if (unit === 'minutes') return Math.round(abs) + '분'; return formatNumber(Math.round(abs)); }; // ===================================================== // UI Components // ===================================================== const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => ( {direction === 'up' ? ( ) : ( )} ); const StatCard: React.FC = ({ label, value, unit, trend, trendDirection }) => { const isNeutral = trend === 0 || trendDirection === '-'; const isUp = trendDirection === 'up'; return (
{label}

{formatValue(value, unit)}

{isNeutral ? ( ) : ( {isUp ? '+' : '-'}{formatTrend(trend, unit)} )}
); }; // ===================================================== // Chart Component with Tooltip // ===================================================== // 차트에 전달하는 통합 데이터 포인트 타입 // MonthlyData / DailyData 모두 이 형식으로 변환하여 사용 interface ChartDataPoint { label: string; // X축 레이블 (월별: "1월", 일별: "1/20") current: number; // 현재 기간 (thisYear 또는 thisPeriod) previous: number; // 이전 기간 (lastYear 또는 lastPeriod) } const YearOverYearChart: React.FC<{ data: ChartDataPoint[]; currentLabel: string; previousLabel: string; mode: 'day' | 'month'; }> = ({ data, currentLabel, previousLabel, mode }) => { if (data.length === 0) { return (
이 기간에 데이터가 없습니다.
); } const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) => { if (!active || !payload?.length) return null; const current = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'current')?.value ?? 0; const previous = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'previous')?.value ?? 0; const isEqual = current === previous; const isUp = current > previous; const diff = Math.abs(current - previous); const changeClass = isEqual ? ' neutral' : isUp ? '' : ' down'; return (
{label}
{currentLabel} {formatNumber(current)}
{previousLabel} {formatNumber(previous)}
{isEqual ? '─' : `${isUp ? '↑' : '↓'} ${formatNumber(diff)}`}
); }; return (
} />
); }; // ===================================================== // Icon Components // ===================================================== const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => ( ); const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => ( ); // ===================================================== // Content Components // ===================================================== const TopContentItem: React.FC = ({ title, thumbnail, platform, views, engagement, date }) => (
{title}
{platform === 'youtube' ? : }

{title}

{formatNumber(views)} {engagement}
{date}
); const AudienceBarChart: React.FC<{ data: { label: string; percentage: number }[]; delay?: number }> = ({ data, delay = 0 }) => { const [isAnimated, setIsAnimated] = useState(false); useEffect(() => { const timer = setTimeout(() => setIsAnimated(true), delay); return () => clearTimeout(timer); }, [delay]); return (
{data.map((item, index) => (
{item.label}
{item.percentage}%
))}
); }; const GenderChart: React.FC<{ male: number; female: number; delay?: number }> = ({ male, female, delay = 0 }) => { const { t } = useTranslation(); const [isAnimated, setIsAnimated] = useState(false); useEffect(() => { const timer = setTimeout(() => setIsAnimated(true), delay); return () => clearTimeout(timer); }, [delay]); return (
{male}%
{female}%
{t('dashboard.male')} {t('dashboard.female')}
); }; // ===================================================== // Main Component // ===================================================== interface DashboardContentProps { onNavigate?: (id: string) => void; } const DashboardContent: React.FC = ({ onNavigate }) => { const { t } = useTranslation(); const [mode, setMode] = useState<'day' | 'month'>('month'); const [dashboardData, setDashboardData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [showMockData, setShowMockData] = useState(false); // 계정 관련 state const [accounts, setAccounts] = useState([]); const [selectedAccountId, setSelectedAccountId] = useState(() => { const stored = localStorage.getItem('castad_selected_account_id'); return stored ? Number(stored) : null; }); const [accountsLoaded, setAccountsLoaded] = useState(false); const [accountDropdownOpen, setAccountDropdownOpen] = useState(false); const accountDropdownRef = React.useRef(null); // 드롭다운 외부 클릭 시 닫기 useEffect(() => { if (!accountDropdownOpen) return; const handleClickOutside = (e: MouseEvent) => { if (accountDropdownRef.current && !accountDropdownRef.current.contains(e.target as Node)) { setAccountDropdownOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [accountDropdownOpen]); // 연결된 YouTube 계정 목록 fetch (마운트 시 1회) useEffect(() => { const fetchAccounts = async () => { try { const response = await authenticatedFetch(`${API_URL}/dashboard/accounts`, { method: 'GET' }); if (!response.ok) return; const data = await response.json(); // snake_case(Python 백엔드) / camelCase 모두 처리 const list: ConnectedAccount[] = (data.accounts ?? []).map((acc: Record) => ({ id: acc.id as number, platform: acc.platform as string, platformUserId: (acc.platformUserId ?? acc.platform_user_id ?? null) as string | null, platformUsername: (acc.platformUsername ?? acc.platform_username ?? null) as string | null, channelTitle: (acc.channelTitle ?? acc.channel_title ?? null) as string | null, connectedAt: (acc.connectedAt ?? acc.connected_at ?? '') as string, isActive: (acc.isActive ?? acc.is_active ?? true) as boolean, })); setAccounts(list); // localStorage 값이 현재 계정 목록에 없으면 초기화 const storedId = localStorage.getItem('castad_selected_account_id'); if (storedId && !list.some((a: ConnectedAccount) => a.id === Number(storedId))) { localStorage.removeItem('castad_selected_account_id'); setSelectedAccountId(null); } if (list.length === 1) { setSelectedAccountId(list[0].id); localStorage.setItem('castad_selected_account_id', String(list[0].id)); } } catch (err) { console.error('Failed to fetch accounts:', err); } finally { setAccountsLoaded(true); } }; fetchAccounts(); }, []); useEffect(() => { if (!accountsLoaded) return; if (accounts.length > 1 && selectedAccountId === null) { setIsLoading(false); // 계정 선택 전 로딩 종료 → 드롭다운 표시 return; } const fetchDashboardData = async () => { try { setIsLoading(true); setError(null); const params = new URLSearchParams({ mode }); // selectedAccountId로 계정을 찾아 platformUserId를 API 파라미터로 전달 if (selectedAccountId !== null) { const selectedAccount = accounts.find((a: ConnectedAccount) => a.id === selectedAccountId); if (selectedAccount?.platformUserId) { params.set('platform_user_id', selectedAccount.platformUserId); } } const response = await authenticatedFetch(`${API_URL}/dashboard/stats?${params}`, { method: 'GET' }); if (!response.ok) { const errorData = await response.json(); if (errorData.code === 'YOUTUBE_NOT_CONNECTED') { setError('YouTube 계정을 연동하여 데이터를 확인하세요.'); setDashboardData(null); return; } if (errorData.code === 'YOUTUBE_ACCOUNT_SELECTION_REQUIRED') { setDashboardData(null); return; } throw new Error(errorData.detail || `API Error: ${response.status}`); } const data: DashboardResponse = await response.json(); setDashboardData(data); setError(null); } catch (err) { console.error('Dashboard API Error:', err); setError(err instanceof Error ? err.message : 'Unknown error'); setDashboardData(null); } finally { setIsLoading(false); } }; fetchDashboardData(); }, [mode, selectedAccountId, accountsLoaded]); if (isLoading) { return (
{t('데이터 로딩 중...')}
); } // hasUploads === false: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너 const isEmptyState = dashboardData?.hasUploads === false; // 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData; // API 데이터 우선 사용, 없거나 영상 없음(isEmptyState) 시 Mock 데이터로 폴백 const contentMetrics = (!isEmptyState && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS; const topContent = (!isEmptyState && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT; const hasRealAgeGroups = !isEmptyState && !!dashboardData?.audienceData?.ageGroups?.some(g => g.percentage > 0); const hasRealGender = !isEmptyState && ((dashboardData?.audienceData?.gender?.male ?? 0) + (dashboardData?.audienceData?.gender?.female ?? 0)) > 0; const hasRealTopRegions = !isEmptyState && !!dashboardData?.audienceData?.topRegions?.some(r => r.percentage > 0); const hasRealAudienceData = hasRealAgeGroups && hasRealGender && hasRealTopRegions; const audienceData = hasRealAudienceData ? dashboardData!.audienceData : MOCK_AUDIENCE_DATA; // mode별 차트 데이터를 ChartDataPoint 통합 형식으로 변환 const chartData: ChartDataPoint[] = mode === 'month' ? ((!isEmptyState && dashboardData?.monthlyData?.length) ? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear })) : MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear }))) : ((!isEmptyState && dashboardData?.dailyData?.length) ? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })) : MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))); // mode별 차트 레이블 const chartCurrentLabel = mode === 'month' ? '올해' : '이번달'; const chartPreviousLabel = mode === 'month' ? '작년' : '지난달'; const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : '전월 대비 성장'; const lastUpdated = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', }); return (
{/* 업로드 영상 없음 배너 */} {isEmptyState && (

아직 업로드된 영상이 없습니다.

ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다.

)} {/* 에러 배너 */} {error && (

실제 데이터를 보려면 계정을 연동하세요.

{error.includes('YouTube') && ( )}
)} {/* Header */}

{t('dashboard.title')}

{/* 계정 드롭다운: 연결된 계정이 2개 이상일 때만 표시 */} {accounts.length > 1 && (
{/* 트리거 버튼 */} {/* 드롭다운 목록 */} {accountDropdownOpen && (
{accounts.map((acc: ConnectedAccount) => (
{ setSelectedAccountId(acc.id); localStorage.setItem('castad_selected_account_id', String(acc.id)); setAccountDropdownOpen(false); }} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 12px', cursor: 'pointer', fontSize: '13px', background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent', color: 'rgba(255,255,255,0.85)', }} onMouseEnter={(e: React.MouseEvent) => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; }} onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.background = acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent'; }} > {acc.platform === 'youtube' ? : } {acc.channelTitle ?? acc.platformUsername ?? acc.platformUserId}
))}
)}
)}

{t('dashboard.description')}

{t('dashboard.lastUpdated')} {lastUpdated}
{/* Content Performance Section */}

{t('dashboard.contentPerformance')}

ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.

{contentMetrics.map((metric: ContentMetric, index: number) => ( ))}
{/* Two Column Layout */}
{/* 추이 차트 섹션 (mode=month: 연간, mode=day: 일별) */}

{chartSectionTitle}

{chartCurrentLabel}
{chartPreviousLabel}
{/* Top Content Section */}

{t('dashboard.popularContent')}

{topContent.map((content, index) => ( ))}
{/* Audience Insights Section */}

{t('dashboard.audienceInsights')}

선택한 채널의 통계가 표시됩니다.

{t('dashboard.ageDistribution')}

{t('dashboard.genderDistribution')}

{t('dashboard.topRegions')}

({ label: r.region, percentage: r.percentage }))} delay={1400} />
{dashboardData && !isEmptyState && !hasRealAudienceData && (

누적 데이터가 부족하여 실제 시청자 정보가 없습니다.

)}
{/* 개발자 모드 전용: mock 데이터 블러 해제 버튼 */} {isTestPage && isEmptyState && ( )}
); }; export default DashboardContent;