'use client'; import { useState, useEffect } from 'react'; import { Box, Container, Typography, Card, CardContent, Grid, CircularProgress, IconButton, Tooltip, } from '@mui/material'; import { PieChart as PieChartIcon, ShowChart as ShowChartIcon, Payments, People, Refresh as RefreshIcon, } from '@mui/icons-material'; import { Chart as ChartJS, ArcElement, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip as ChartTooltip, Legend, } from 'chart.js'; import { Pie, Line } from 'react-chartjs-2'; import Header from '@/shared/ui/Header'; import { cardStyles, colors, responsiveText, } from '@/shared/lib/button-styles'; import { useAuthContext } from '@/features/auth'; import { analyticsApi } from '@/entities/analytics'; import type { UserAnalyticsDashboardResponse, UserTimelineAnalyticsResponse, UserRoiAnalyticsResponse, } from '@/entities/analytics'; // Chart.js 등록 ChartJS.register( ArcElement, CategoryScale, LinearScale, PointElement, LineElement, Title, ChartTooltip, Legend ); export default function AnalyticsPage() { const { user } = useAuthContext(); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [dashboardData, setDashboardData] = useState(null); const [timelineData, setTimelineData] = useState(null); const [roiData, setRoiData] = useState(null); const [lastUpdate, setLastUpdate] = useState(new Date()); const [updateText, setUpdateText] = useState('방금 전'); // Analytics 데이터 로드 함수 const fetchAnalytics = async (forceRefresh = false) => { try { if (forceRefresh) { setRefreshing(true); } else { setLoading(true); } // 로그인하지 않은 경우 테스트용 userId 사용 (로컬 테스트용) const userId = user?.userId ? String(user.userId) : 'store_001'; console.log('📊 Analytics 데이터 로드 시작:', { userId, isLoggedIn: !!user, refresh: forceRefresh }); // 병렬로 모든 Analytics API 호출 const [dashboard, timeline, roi] = await Promise.all([ analyticsApi.getUserAnalytics(userId, { refresh: forceRefresh }), analyticsApi.getUserTimelineAnalytics(userId, { interval: 'hourly', refresh: forceRefresh }), analyticsApi.getUserRoiAnalytics(userId, { includeProjection: true, refresh: forceRefresh }), ]); console.log('✅ Dashboard 데이터:', dashboard); console.log('✅ Timeline 데이터:', timeline); console.log('✅ ROI 데이터:', roi); setDashboardData(dashboard); setTimelineData(timeline); setRoiData(roi); setLastUpdate(new Date()); } catch (error: any) { console.error('❌ Analytics 데이터 로드 실패:', error); // 에러 발생 시에도 로딩 상태 해제 } finally { setLoading(false); setRefreshing(false); } }; // 새로고침 핸들러 const handleRefresh = () => { fetchAnalytics(true); }; // 초기 데이터 로드 useEffect(() => { fetchAnalytics(false); }, [user?.userId]); // 업데이트 시간 표시 갱신 useEffect(() => { const updateInterval = setInterval(() => { const now = new Date(); const diff = Math.floor((now.getTime() - lastUpdate.getTime()) / 1000); let text; if (diff < 60) { text = '방금 전'; } else if (diff < 3600) { text = `${Math.floor(diff / 60)}분 전`; } else { text = `${Math.floor(diff / 3600)}시간 전`; } setUpdateText(text); }, 30000); // 30초마다 갱신 return () => { clearInterval(updateInterval); }; }, [lastUpdate]); // 로딩 중 표시 if (loading) { return ( <>
); } // 데이터 없음 표시 if (!dashboardData || !timelineData || !roiData) { return ( <>
Analytics 데이터를 불러올 수 없습니다. ); } // API 데이터에서 필요한 값 추출 console.log('📊 === 데이터 추출 디버깅 시작 ==='); console.log('📊 원본 dashboardData.overallSummary:', dashboardData.overallSummary); console.log('📊 원본 roiData.overallInvestment:', roiData.overallInvestment); console.log('📊 원본 roiData.overallRevenue:', roiData.overallRevenue); console.log('📊 원본 roiData.overallRoi:', roiData.overallRoi); const summary = { participants: dashboardData.overallSummary.participants, participantsDelta: dashboardData.overallSummary.participantsDelta, totalCost: roiData.overallInvestment.total, expectedRevenue: roiData.overallRevenue.total, roi: roiData.overallRoi.roiPercentage, targetRoi: dashboardData.overallSummary.targetRoi, }; console.log('📊 최종 summary 객체:', summary); console.log('📊 === 데이터 추출 디버깅 종료 ==='); // 채널별 성과 데이터 변환 console.log('🔍 원본 channelPerformance 데이터:', dashboardData.channelPerformance); const channelColors = ['#F472B6', '#60A5FA', '#FB923C', '#A78BFA', '#34D399']; const channelPerformance = dashboardData.channelPerformance.map((channel, index) => { const totalParticipants = dashboardData.overallSummary.participants; const percentage = totalParticipants > 0 ? Math.round((channel.participants / totalParticipants) * 100) : 0; // 채널명 정리 - 안전한 방식으로 처리 let cleanChannelName = channel.channel; // 백엔드에서 UTF-8로 전달되는 경우 그대로 사용 // URL 인코딩된 경우에만 디코딩 시도 if (cleanChannelName && cleanChannelName.includes('%')) { try { cleanChannelName = decodeURIComponent(cleanChannelName); } catch (e) { // 디코딩 실패 시 원본 사용 console.warn('⚠️ 채널명 디코딩 실패, 원본 사용:', channel.channel); } } const result = { channel: cleanChannelName || '알 수 없는 채널', participants: channel.participants, percentage, color: channelColors[index % channelColors.length], }; console.log('🔍 변환된 채널 데이터:', result); return result; }); console.log('🔍 최종 channelPerformance:', channelPerformance); // 채널 데이터 유효성 확인 const hasChannelData = channelPerformance.length > 0 && channelPerformance.some(ch => ch.participants > 0); // 시간대별 데이터 집계 (0시~23시, 날짜별 평균) console.log('🔍 원본 timelineData.dataPoints:', timelineData.dataPoints); // 0시~23시까지 24개 시간대 초기화 (합계와 카운트 추적) const hourlyData = Array.from({ length: 24 }, (_, hour) => ({ hour, totalParticipants: 0, count: 0, participants: 0, // 최종 평균값 })); // 각 데이터 포인트를 시간대별로 집계 timelineData.dataPoints.forEach((point) => { const date = new Date(point.timestamp); const hour = date.getHours(); if (hour >= 0 && hour < 24) { hourlyData[hour].totalParticipants += point.participants; hourlyData[hour].count += 1; } }); // 시간대별 평균 계산 hourlyData.forEach((data) => { data.participants = data.count > 0 ? Math.round(data.totalParticipants / data.count) : 0; }); console.log('🔍 시간대별 집계 데이터 (평균):', hourlyData); // 피크 시간 찾기 (hourlyData에서 최대 참여자 수를 가진 시간대) const peakHour = hourlyData.reduce((max, current) => current.participants > max.participants ? current : max , hourlyData[0]); console.log('🔍 피크 시간 데이터:', peakHour); // 시간대별 성과 데이터 (피크 시간 정보) const timePerformance = { peakTime: `${peakHour.hour}시`, peakParticipants: peakHour.participants, avgPerHour: Math.round( hourlyData.reduce((sum, data) => sum + data.participants, 0) / 24 ), }; // ROI 상세 데이터 console.log('💰 === ROI 상세 데이터 생성 시작 ==='); console.log('💰 overallInvestment 전체:', roiData.overallInvestment); console.log('💰 breakdown 데이터:', roiData.overallInvestment.breakdown); const roiDetail = { totalCost: roiData.overallInvestment.total, prizeCost: roiData.overallInvestment.prizeCost, // ✅ 백엔드 prizeCost 필드 사용 channelCost: roiData.overallInvestment.distribution, otherCost: roiData.overallInvestment.contentCreation + roiData.overallInvestment.operation, // ✅ 그 외 비용 expectedRevenue: roiData.overallRevenue.total, salesIncrease: roiData.overallRevenue.total, // ✅ 변경: total 사용 newCustomerLTV: roiData.overallRevenue.newCustomerRevenue, // ✅ 변경: newCustomerRevenue 사용 }; console.log('💰 최종 roiDetail 객체:', roiDetail); console.log('💰 === ROI 상세 데이터 생성 종료 ==='); // 참여자 프로필 데이터 (임시로 Mock 데이터 사용 - API에 없음) const participantProfile = { age: [ { label: '20대', percentage: 35 }, { label: '30대', percentage: 40 }, { label: '40대', percentage: 25 }, ], gender: [ { label: '여성', percentage: 60 }, { label: '남성', percentage: 40 }, ], }; return ( <>
{/* Title with Real-time Indicator */} 📊 요약 (실시간) {updateText} {/* Summary KPI Cards */} 참여자 수 {summary.participants} ↑ {summary.participantsDelta}명 총 비용 {Math.floor(summary.totalCost / 10000)}만 경품+채널 예상 수익 {Math.floor(summary.expectedRevenue / 10000)}만 매출+LTV ROI {summary.roi}% 목표 {summary.targetRoi}% {/* Charts Grid */} {/* Channel Performance */} 채널별 성과 {/* Pie Chart */} {hasChannelData ? ( item.channel), datasets: [ { data: channelPerformance.map((item) => item.participants), backgroundColor: channelPerformance.map((item) => item.color), borderColor: '#fff', borderWidth: 2, }, ], }} options={{ responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false, }, tooltip: { callbacks: { label: function (context) { const label = context.label || ''; const value = context.parsed || 0; const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0); const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0'; return `${label}: ${value}명 (${percentage}%)`; }, }, }, }, }} /> ) : ( 채널별 참여 데이터가 없습니다. 참여자가 발생하면 차트가 표시됩니다. )} {/* Legend */} {hasChannelData && ( {channelPerformance.map((item) => ( {item.channel} {item.percentage}% ({item.participants}명) ))} )} {/* Time Trend */} 시간대별 참여 추이 {/* Line Chart */} `${item.hour}시`), datasets: [ { label: '참여자 수', data: hourlyData.map((item) => item.participants), borderColor: colors.blue, backgroundColor: `${colors.blue}33`, fill: true, tension: 0.4, pointBackgroundColor: colors.blue, pointBorderColor: '#fff', pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6, }, ], }} options={{ responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false, }, tooltip: { backgroundColor: colors.gray[900], padding: 12, displayColors: false, callbacks: { label: function (context) { return `${context.parsed.y}명`; }, }, }, }, scales: { y: { beginAtZero: true, ticks: { color: colors.gray[600], }, grid: { color: colors.gray[200], }, }, x: { ticks: { color: colors.gray[600], }, grid: { display: false, }, }, }, }} /> {/* Stats */} 피크 시간: {timePerformance.peakTime} ({timePerformance.peakParticipants}명) 평균 시간당: {timePerformance.avgPerHour}명 {/* ROI Detail & Participant Profile */} {/* ROI Detail */} ROI 상세 총 비용: {Math.floor(roiDetail.totalCost / 10000)}만원 • 경품 비용 {Math.floor(roiDetail.prizeCost / 10000)}만원 • 채널 비용 {Math.floor(roiDetail.channelCost / 10000)}만원 • 그 외 {Math.floor(roiDetail.otherCost / 10000)}만원 예상 수익: {Math.floor(roiDetail.expectedRevenue / 10000)}만원 • 매출 증가 {Math.floor(roiDetail.salesIncrease / 10000)}만원 • 신규 고객 LTV {Math.floor(roiDetail.newCustomerLTV / 10000)}만원 투자대비수익률 (수익 - 비용) ÷ 비용 × 100 ({Math.floor(roiDetail.expectedRevenue / 10000)}만 -{' '} {Math.floor(roiDetail.totalCost / 10000)}만) ÷{' '} {Math.floor(roiDetail.totalCost / 10000)}만 × 100 = {summary.roi}% {/* Participant Profile */} 참여자 프로필 {/* Age Distribution */} 연령별 {participantProfile.age.map((item) => ( {item.label} {item.percentage}% ))} {/* Gender Distribution */} 성별 {participantProfile.gender.map((item) => ( {item.label} {item.percentage}% ))} ); }