From abceae6e2a03e4658df3dd4dfdf191c8f338f38a Mon Sep 17 00:00:00 2001 From: Hyowon Yang Date: Wed, 29 Oct 2025 19:33:19 +0900 Subject: [PATCH] =?UTF-8?q?Analytics=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=94=94=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 기간 계산 함수에 상세 디버그 로그 추가 - 차트 데이터 생성 함수에 필터링 과정 로그 추가 - Timeline dataPoints 구조 확인을 위한 콘솔 출력 추가 - ROI 필드 매핑 검증을 위한 로그 추가 --- src/app/(main)/analytics/page.tsx | 506 ++++++++--- src/app/(main)/events/[eventId]/page.tsx | 319 +++++-- .../(main)/test/analytics/[eventId]/page.tsx | 846 ++++++++++++++++++ src/entities/analytics/api/analyticsApi.ts | 142 +++ src/entities/analytics/api/analyticsClient.ts | 67 ++ src/entities/analytics/api/index.ts | 3 + src/entities/analytics/index.ts | 2 + src/entities/analytics/model/types.ts | 295 ++++++ 8 files changed, 1968 insertions(+), 212 deletions(-) create mode 100644 src/app/(main)/test/analytics/[eventId]/page.tsx create mode 100644 src/entities/analytics/api/analyticsApi.ts create mode 100644 src/entities/analytics/api/analyticsClient.ts create mode 100644 src/entities/analytics/api/index.ts create mode 100644 src/entities/analytics/index.ts create mode 100644 src/entities/analytics/model/types.ts diff --git a/src/app/(main)/analytics/page.tsx b/src/app/(main)/analytics/page.tsx index 0703425..ac54075 100644 --- a/src/app/(main)/analytics/page.tsx +++ b/src/app/(main)/analytics/page.tsx @@ -8,12 +8,16 @@ import { 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, @@ -23,7 +27,7 @@ import { PointElement, LineElement, Title, - Tooltip, + Tooltip as ChartTooltip, Legend, } from 'chart.js'; import { Pie, Line } from 'react-chartjs-2'; @@ -33,6 +37,13 @@ import { 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( @@ -42,57 +53,69 @@ ChartJS.register( PointElement, LineElement, Title, - Tooltip, + ChartTooltip, Legend ); -// Mock 데이터 -const mockAnalyticsData = { - summary: { - participants: 128, - participantsDelta: 12, - totalCost: 300000, - expectedRevenue: 1350000, - roi: 450, - targetRoi: 300, - }, - channelPerformance: [ - { channel: '우리동네TV', participants: 58, percentage: 45, color: '#F472B6' }, - { channel: '링고비즈', participants: 38, percentage: 30, color: '#60A5FA' }, - { channel: 'SNS', participants: 32, percentage: 25, color: '#FB923C' }, - ], - timePerformance: { - peakTime: '오후 2-4시', - peakParticipants: 35, - avgPerHour: 8, - }, - roiDetail: { - totalCost: 300000, - prizeCost: 250000, - channelCost: 50000, - expectedRevenue: 1350000, - salesIncrease: 1000000, - newCustomerLTV: 350000, - }, - participantProfile: { - age: [ - { label: '20대', percentage: 35 }, - { label: '30대', percentage: 40 }, - { label: '40대', percentage: 25 }, - ], - gender: [ - { label: '여성', percentage: 60 }, - { label: '남성', percentage: 40 }, - ], - }, -}; - 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); @@ -109,20 +132,193 @@ export default function AnalyticsPage() { setUpdateText(text); }, 30000); // 30초마다 갱신 - // 5분마다 데이터 업데이트 시뮬레이션 - const dataUpdateInterval = setInterval(() => { - setLastUpdate(new Date()); - setUpdateText('방금 전'); - }, 300000); // 5분 - return () => { clearInterval(updateInterval); - clearInterval(dataUpdateInterval); }; }, [lastUpdate]); - const { summary, channelPerformance, timePerformance, roiDetail, participantProfile } = - mockAnalyticsData; + // 로딩 중 표시 + 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 ( <> @@ -148,23 +344,53 @@ export default function AnalyticsPage() { 📊 요약 (실시간) - - - - {updateText} - + + + + + {updateText} + + + + + + + @@ -342,67 +568,80 @@ export default function AnalyticsPage() { justifyContent: 'center', }} > - 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 = ((value / total) * 100).toFixed(1); - return `${label}: ${value}명 (${percentage}%)`; + {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 */} - - {channelPerformance.map((item) => ( - - - - - {item.channel} + {hasChannelData && ( + + {channelPerformance.map((item) => ( + + + + + {item.channel} + + + + {item.percentage}% ({item.participants}명) - - {item.percentage}% ({item.participants}명) - - - ))} - + ))} + + )} @@ -443,20 +682,11 @@ export default function AnalyticsPage() { > `${item.hour}시`), datasets: [ { label: '참여자 수', - data: [3, 2, 5, 12, 28, 35, 22, 15], + data: hourlyData.map((item) => item.participants), borderColor: colors.blue, backgroundColor: `${colors.blue}33`, fill: true, @@ -571,6 +801,14 @@ export default function AnalyticsPage() { {Math.floor(roiDetail.channelCost / 10000)}만원 + + + • 그 외 + + + {Math.floor(roiDetail.otherCost / 10000)}만원 + + diff --git a/src/app/(main)/events/[eventId]/page.tsx b/src/app/(main)/events/[eventId]/page.tsx index fed6ccd..bf8cdf6 100644 --- a/src/app/(main)/events/[eventId]/page.tsx +++ b/src/app/(main)/events/[eventId]/page.tsx @@ -16,6 +16,9 @@ import { MenuItem, Divider, LinearProgress, + CircularProgress, + Alert, + Tooltip as MuiTooltip, } from '@mui/material'; import { MoreVert, @@ -36,6 +39,7 @@ import { LocalFireDepartment, Star, NewReleases, + Refresh as RefreshIcon, } from '@mui/icons-material'; import { Line, Bar } from 'react-chartjs-2'; import { @@ -46,10 +50,11 @@ import { LineElement, BarElement, Title, - Tooltip, + Tooltip as ChartTooltip, Legend, Filler, } from 'chart.js'; +import { analyticsApi } from '@/entities/analytics/api/analyticsApi'; // Chart.js 등록 ChartJS.register( @@ -59,7 +64,7 @@ ChartJS.register( LineElement, BarElement, Title, - Tooltip, + ChartTooltip, Legend, Filler ); @@ -113,62 +118,6 @@ const recentParticipants = [ { name: '정*희', phone: '010-****-7890', time: '2시간 전' }, ]; -// 차트 데이터 생성 함수 -const generateParticipationTrendData = (period: '7d' | '30d' | 'all') => { - const labels = - period === '7d' - ? ['1/20', '1/21', '1/22', '1/23', '1/24', '1/25', '1/26'] - : period === '30d' - ? Array.from({ length: 30 }, (_, i) => `1/${i + 1}`) - : Array.from({ length: 31 }, (_, i) => `1/${i + 1}`); - - const data = - period === '7d' - ? [12, 19, 15, 25, 22, 30, 28] - : period === '30d' - ? Array.from({ length: 30 }, () => Math.floor(Math.random() * 30) + 10) - : Array.from({ length: 31 }, () => Math.floor(Math.random() * 30) + 10); - - return { - labels, - datasets: [ - { - label: '일별 참여자', - data, - borderColor: colors.blue, - backgroundColor: `${colors.blue}40`, - fill: true, - tension: 0.4, - }, - ], - }; -}; - -const channelPerformanceData = { - labels: ['우리동네TV', '링고비즈', 'SNS'], - datasets: [ - { - label: '참여자 수', - data: [58, 38, 32], - backgroundColor: [colors.pink, colors.blue, colors.orange], - borderRadius: 8, - }, - ], -}; - -const roiTrendData = { - labels: ['1주차', '2주차', '3주차', '4주차'], - datasets: [ - { - label: 'ROI (%)', - data: [150, 280, 380, 450], - borderColor: colors.mint, - backgroundColor: `${colors.mint}40`, - fill: true, - tension: 0.4, - }, - ], -}; // 헬퍼 함수 const getMethodIcon = (method: string) => { @@ -201,23 +150,77 @@ export default function EventDetailPage() { const [event, setEvent] = useState(mockEventData); const [anchorEl, setAnchorEl] = useState(null); const [chartPeriod, setChartPeriod] = useState<'7d' | '30d' | 'all'>('7d'); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [analyticsData, setAnalyticsData] = useState(null); - // 실시간 업데이트 시뮬레이션 - useEffect(() => { - if (event.status === 'active') { - const interval = setInterval(() => { - const increase = Math.floor(Math.random() * 3); - if (increase > 0) { - setEvent((prev) => ({ - ...prev, - participants: prev.participants + increase, - })); - } - }, 5000); + // Analytics API 호출 + const fetchAnalytics = async (forceRefresh = false) => { + try { + if (forceRefresh) { + console.log('🔄 데이터 새로고침 시작...'); + setRefreshing(true); + } else { + console.log('📊 Analytics 데이터 로딩 시작...'); + setLoading(true); + } + setError(null); - return () => clearInterval(interval); + // Event Analytics API 병렬 호출 + const [dashboard, timeline, roi, channels] = await Promise.all([ + analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }), + analyticsApi.getEventTimelineAnalytics(eventId, { + interval: chartPeriod === '7d' ? 'daily' : chartPeriod === '30d' ? 'daily' : 'daily', + refresh: forceRefresh + }), + analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true, refresh: forceRefresh }), + analyticsApi.getEventChannelAnalytics(eventId, { refresh: forceRefresh }), + ]); + + console.log('✅ Dashboard 데이터:', dashboard); + console.log('✅ Timeline 데이터:', timeline); + console.log('✅ ROI 데이터:', roi); + console.log('✅ Channel 데이터:', channels); + + // Analytics 데이터 저장 + setAnalyticsData({ + dashboard, + timeline, + roi, + channels, + }); + + // Event 기본 정보 업데이트 + setEvent(prev => ({ + ...prev, + participants: dashboard.summary.participants, + views: dashboard.summary.totalViews, + roi: Math.round(dashboard.roi.roi), + conversion: Math.round(dashboard.summary.conversionRate * 100), + })); + + console.log('✅ Analytics 데이터 로딩 완료'); + } catch (err: any) { + console.error('❌ Analytics 데이터 로딩 실패:', err); + setError(err.message || 'Analytics 데이터를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + setRefreshing(false); } - }, [event.status]); + }; + + // 초기 데이터 로드 + useEffect(() => { + fetchAnalytics(); + }, [eventId]); + + // 차트 기간 변경 시 Timeline 데이터 다시 로드 + useEffect(() => { + if (analyticsData) { + fetchAnalytics(); + } + }, [chartPeriod]); const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -227,6 +230,131 @@ export default function EventDetailPage() { setAnchorEl(null); }; + const handleRefresh = () => { + fetchAnalytics(true); + }; + + // 차트 데이터 생성 함수 + const generateParticipationTrendData = () => { + if (!analyticsData?.timeline) { + return { + labels: [], + datasets: [{ + label: '일별 참여자', + data: [], + borderColor: colors.blue, + backgroundColor: `${colors.blue}40`, + fill: true, + tension: 0.4, + }], + }; + } + + const timelineData = analyticsData.timeline; + const dataPoints = timelineData.dataPoints || []; + + // 데이터 포인트를 날짜별로 그룹화 + const dailyData = new Map(); + dataPoints.forEach((point: any) => { + const date = new Date(point.timestamp); + const dateKey = `${date.getMonth() + 1}/${date.getDate()}`; + dailyData.set(dateKey, (dailyData.get(dateKey) || 0) + point.participants); + }); + + const labels = Array.from(dailyData.keys()); + const data = Array.from(dailyData.values()); + + return { + labels, + datasets: [{ + label: '일별 참여자', + data, + borderColor: colors.blue, + backgroundColor: `${colors.blue}40`, + fill: true, + tension: 0.4, + }], + }; + }; + + const generateChannelPerformanceData = () => { + if (!analyticsData?.channels?.channels) { + return { + labels: [], + datasets: [{ + label: '참여자 수', + data: [], + backgroundColor: [], + borderRadius: 8, + }], + }; + } + + const channelColors = [colors.pink, colors.blue, colors.orange, colors.purple, colors.mint, colors.yellow]; + const channels = analyticsData.channels.channels; + + const labels = channels.map((ch: any) => { + let channelName = ch.channelName || ch.channelType || '알 수 없음'; + // 채널명 디코딩 처리 + if (channelName.includes('%')) { + try { + channelName = decodeURIComponent(channelName); + } catch (e) { + console.warn('⚠️ 채널명 디코딩 실패:', channelName); + } + } + return channelName; + }); + + const data = channels.map((ch: any) => ch.metrics?.participants || 0); + const backgroundColor = channels.map((_: any, idx: number) => channelColors[idx % channelColors.length]); + + return { + labels, + datasets: [{ + label: '참여자 수', + data, + backgroundColor, + borderRadius: 8, + }], + }; + }; + + const generateRoiTrendData = () => { + // ROI는 현재 시점의 값만 있으므로 간단한 추이를 표시 + if (!analyticsData?.roi) { + return { + labels: ['현재'], + datasets: [{ + label: 'ROI (%)', + data: [0], + borderColor: colors.mint, + backgroundColor: `${colors.mint}40`, + fill: true, + tension: 0.4, + }], + }; + } + + const currentRoi = analyticsData.roi.roi?.roiPercentage || 0; + + // 단순 추정: 초기 0에서 현재 ROI까지의 추이 + const labels = ['시작', '1주차', '2주차', '3주차', '현재']; + const data = [0, currentRoi * 0.3, currentRoi * 0.5, currentRoi * 0.75, currentRoi]; + + return { + labels, + datasets: [{ + label: 'ROI (%)', + data, + borderColor: colors.mint, + backgroundColor: `${colors.mint}40`, + fill: true, + tension: 0.4, + }], + }; + }; + const getStatusColor = (status: string) => { switch (status) { case 'active': @@ -253,6 +381,26 @@ export default function EventDetailPage() { } }; + // 로딩 중 + if (loading) { + return ( + + + + ); + } + + // 에러 발생 + if (error) { + return ( + + + {error} + + + ); + } + return ( @@ -262,9 +410,24 @@ export default function EventDetailPage() { {event.title} - - - + + + + + + + + + + 이벤트 수정 @@ -547,7 +710,7 @@ export default function EventDetailPage() { ('7d'); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [analyticsData, setAnalyticsData] = useState(null); + + // Analytics API 호출 + const fetchAnalytics = async (forceRefresh = false) => { + try { + if (forceRefresh) { + console.log('🔄 데이터 새로고침 시작...'); + setRefreshing(true); + } else { + console.log('📊 Analytics 데이터 로딩 시작...'); + setLoading(true); + } + setError(null); + + // Event Analytics API 병렬 호출 + const [dashboard, timeline, roi, channels] = await Promise.all([ + analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }), + analyticsApi.getEventTimelineAnalytics(eventId, { + interval: 'daily', // 항상 일별 데이터를 받아서 프론트엔드에서 필터링 + refresh: forceRefresh + }), + analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true, refresh: forceRefresh }), + analyticsApi.getEventChannelAnalytics(eventId, { refresh: forceRefresh }), + ]); + + console.log('✅ Dashboard 데이터:', dashboard); + console.log('✅ Timeline 데이터:', timeline); + console.log('✅ ROI 데이터:', roi); + console.log('✅ Channel 데이터:', channels); + + // ROI 상세 데이터 구조 확인 + console.log('💰 === ROI 상세 데이터 확인 ==='); + console.log('💰 investment 전체:', roi?.investment); + console.log('💰 investment.total:', roi?.investment?.total); + console.log('💰 investment.prizeCost:', roi?.investment?.prizeCost); + console.log('💰 investment.distribution:', roi?.investment?.distribution); + console.log('💰 investment.contentCreation:', roi?.investment?.contentCreation); + console.log('💰 investment.operation:', roi?.investment?.operation); + console.log('💰 revenue 전체:', roi?.revenue); + console.log('💰 revenue.total:', roi?.revenue?.total); + console.log('💰 revenue.newCustomerRevenue:', roi?.revenue?.newCustomerRevenue); + console.log('💰 =============================='); + + // Analytics 데이터 저장 + setAnalyticsData({ + dashboard, + timeline, + roi, + channels, + }); + + console.log('✅ Analytics 데이터 로딩 완료'); + } catch (err: any) { + console.error('❌ Analytics 데이터 로딩 실패:', err); + setError(err.message || 'Analytics 데이터를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + // 초기 데이터 로드 + useEffect(() => { + fetchAnalytics(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventId]); + + const handleRefresh = () => { + fetchAnalytics(true); + }; + + // 날짜 포맷 함수 (년월일만) + const formatDate = (dateString: string) => { + if (!dateString) return ''; + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + // 실제 이벤트 참여 기간 계산 (Timeline 데이터 기반) + const getActualEventPeriod = () => { + console.log('📅 === 이벤트 기간 계산 디버그 ==='); + console.log('📅 analyticsData:', analyticsData); + console.log('📅 analyticsData?.timeline:', analyticsData?.timeline); + console.log('📅 analyticsData?.timeline?.dataPoints:', analyticsData?.timeline?.dataPoints); + + if (!analyticsData?.timeline?.dataPoints || analyticsData.timeline.dataPoints.length === 0) { + console.log('❌ dataPoints가 없거나 비어있음'); + return null; + } + + const dataPoints = analyticsData.timeline.dataPoints; + console.log('📅 dataPoints 개수:', dataPoints.length); + console.log('📅 첫번째 dataPoint:', dataPoints[0]); + + const timestamps = dataPoints.map((point: any) => new Date(point.timestamp).getTime()); + console.log('📅 timestamps:', timestamps); + + const firstTimestamp = Math.min(...timestamps); + const lastTimestamp = Math.max(...timestamps); + + console.log('📅 첫 참여:', new Date(firstTimestamp).toISOString()); + console.log('📅 마지막 참여:', new Date(lastTimestamp).toISOString()); + console.log('📅 =============================='); + + return { + startDate: new Date(firstTimestamp), + endDate: new Date(lastTimestamp), + }; + }; + + // 이벤트 종료 여부 확인 + const isEventEnded = () => { + const period = getActualEventPeriod(); + if (!period) return false; + + const now = new Date(); + const daysSinceLastActivity = (now.getTime() - period.endDate.getTime()) / (1000 * 60 * 60 * 24); + + // 마지막 참여로부터 1일 이상 지났으면 종료로 간주 + return daysSinceLastActivity > 1; + }; + + // 날짜 표시 문자열 생성 + const getDateDisplayString = () => { + const period = getActualEventPeriod(); + if (!period) return ''; + + const startDate = formatDate(period.startDate.toISOString()); + const endDate = formatDate(period.endDate.toISOString()); + + if (isEventEnded()) { + return `${startDate} ~ ${endDate}`; + } else { + return `${startDate} ~ 진행중`; + } + }; + + // 날짜+시간 포맷 함수 (YYYY-MM-DD HH:MM:SS) + const formatDateTime = (dateString: string) => { + if (!dateString) return 'Unknown'; + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + }; + + // 차트 데이터 생성 함수 + const generateParticipationTrendData = () => { + console.log('📊 === 차트 데이터 생성 디버그 ==='); + console.log('📊 chartPeriod:', chartPeriod); + console.log('📊 analyticsData?.timeline:', analyticsData?.timeline); + + if (!analyticsData?.timeline) { + console.log('❌ timeline 데이터 없음'); + return { + labels: [], + datasets: [{ + label: '일별 참여자', + data: [], + borderColor: colors.blue, + backgroundColor: `${colors.blue}40`, + fill: true, + tension: 0.4, + }], + }; + } + + const timelineData = analyticsData.timeline; + const dataPoints = timelineData.dataPoints || []; + console.log('📊 dataPoints:', dataPoints); + console.log('📊 dataPoints.length:', dataPoints.length); + + if (dataPoints.length === 0) { + console.log('❌ dataPoints가 비어있음'); + return { + labels: [], + datasets: [{ + label: '일별 참여자', + data: [], + borderColor: colors.blue, + backgroundColor: `${colors.blue}40`, + fill: true, + tension: 0.4, + }], + }; + } + + // 마지막 참여 시점 찾기 (가장 최근 timestamp) + const timestamps = dataPoints.map((point: any) => new Date(point.timestamp).getTime()); + const lastTimestamp = Math.max(...timestamps); + const lastDate = new Date(lastTimestamp); + + console.log(`📅 마지막 참여 시점: ${lastDate.toISOString()}`); + + // 마지막 참여 시점 기준으로 필터링 기간 계산 + let cutoffDate: Date | null = null; + + if (chartPeriod === '7d') { + cutoffDate = new Date(lastDate); + cutoffDate.setDate(lastDate.getDate() - 7); + } else if (chartPeriod === '30d') { + cutoffDate = new Date(lastDate); + cutoffDate.setDate(lastDate.getDate() - 30); + } + // 'all'인 경우 cutoffDate는 null (필터링 안 함) + + console.log(`📊 필터링 시작일: ${cutoffDate ? cutoffDate.toISOString() : '전체'}`); + + // 기간 필터링된 데이터 포인트 + const filteredPoints = cutoffDate + ? dataPoints.filter((point: any) => { + const pointDate = new Date(point.timestamp); + return pointDate >= cutoffDate && pointDate <= lastDate; + }) + : dataPoints; + + console.log('📊 필터링된 포인트 개수:', filteredPoints.length); + + // 데이터 포인트를 날짜별로 그룹화 + const dailyData = new Map(); + filteredPoints.forEach((point: any) => { + const date = new Date(point.timestamp); + const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + + if (!dailyData.has(dateKey)) { + dailyData.set(dateKey, { date, participants: 0 }); + } + const current = dailyData.get(dateKey)!; + current.participants += point.participants; + }); + + console.log('📊 일별 그룹화된 데이터:', Array.from(dailyData.entries())); + + // 날짜순으로 정렬 + const sortedEntries = Array.from(dailyData.entries()).sort((a, b) => + a[1].date.getTime() - b[1].date.getTime() + ); + + // 라벨과 데이터 생성 (MM/DD 형식) + const labels = sortedEntries.map(([_, value]) => { + const date = value.date; + return `${date.getMonth() + 1}/${date.getDate()}`; + }); + const data = sortedEntries.map(([_, value]) => value.participants); + + console.log(`📊 차트 기간: ${chartPeriod}, 데이터 포인트: ${data.length}개`); + console.log('📊 차트 labels:', labels); + console.log('📊 차트 data:', data); + console.log('📊 =============================='); + + return { + labels, + datasets: [{ + label: '일별 참여자', + data, + borderColor: colors.blue, + backgroundColor: `${colors.blue}40`, + fill: true, + tension: 0.4, + }], + }; + }; + + const generateChannelPerformanceData = () => { + if (!analyticsData?.channels?.channels) { + return { + labels: [], + datasets: [{ + label: '참여자 수', + data: [], + backgroundColor: [], + borderRadius: 8, + }], + }; + } + + const channelColors = [colors.pink, colors.blue, colors.orange, colors.purple, colors.mint, colors.yellow]; + const channels = analyticsData.channels.channels; + + const labels = channels.map((ch: any) => { + let channelName = ch.channelName || ch.channelType || '알 수 없음'; + // 채널명 디코딩 처리 + if (channelName.includes('%')) { + try { + channelName = decodeURIComponent(channelName); + } catch (e) { + console.warn('⚠️ 채널명 디코딩 실패:', channelName); + } + } + return channelName; + }); + + const data = channels.map((ch: any) => ch.metrics?.participants || 0); + const backgroundColor = channels.map((_: any, idx: number) => channelColors[idx % channelColors.length]); + + return { + labels, + datasets: [{ + label: '참여자 수', + data, + backgroundColor, + borderRadius: 8, + }], + }; + }; + + const generateRoiTrendData = () => { + // ROI는 현재 시점의 값만 있으므로 간단한 추이를 표시 + if (!analyticsData?.roi) { + return { + labels: ['현재'], + datasets: [{ + label: 'ROI (%)', + data: [0], + borderColor: colors.mint, + backgroundColor: `${colors.mint}40`, + fill: true, + tension: 0.4, + }], + }; + } + + const currentRoi = analyticsData.roi.roi?.roiPercentage || 0; + + // 단순 추정: 초기 0에서 현재 ROI까지의 추이 + const labels = ['시작', '1주차', '2주차', '3주차', '현재']; + const data = [0, currentRoi * 0.3, currentRoi * 0.5, currentRoi * 0.75, currentRoi]; + + return { + labels, + datasets: [{ + label: 'ROI (%)', + data, + borderColor: colors.mint, + backgroundColor: `${colors.mint}40`, + fill: true, + tension: 0.4, + }], + }; + }; + + // 로딩 중 + if (loading) { + return ( + + + + + Analytics 데이터 로딩 중... + + + + ); + } + + // 에러 발생 + if (error) { + return ( + + + Analytics 데이터 로딩 실패 + {error} + + + + + + ); + } + + const dashboard = analyticsData?.dashboard; + + return ( + + + {/* Header */} + + + + router.back()}> + + + + + + {dashboard?.eventTitle || 'Analytics 테스트 페이지'} + + + + + + + + + + + + 📅 {getDateDisplayString()} + + + + {/* Real-time KPIs */} + + + 실시간 현황 + + + + + + + + + 참여자 + + + {dashboard?.summary?.participants?.toLocaleString() || 0}명 + + + + + + + + + + 조회수 + + + {dashboard?.summary?.totalViews?.toLocaleString() || 0} + + + + + + + + + + ROI + + + {Math.round(dashboard?.roi?.roi || 0)}% + + + + + + + + + + 전환율 + + + {Math.round((dashboard?.summary?.conversionRate || 0) * 100)}% + + + + + + + + {/* Chart Section - 참여 추이 */} + + + 📈 참여 추이 + + + + + + + + + + + + + + + + + {/* Chart Section - 채널별 성과 & ROI 추이 */} + + + + 📊 채널별 참여자 + + + + + + + + + + + + + 💰 ROI 추이 + + + + + + + + + + + + {/* ROI 상세 정보 */} + + + 💰 ROI 상세 분석 + + + + + + 투자 비용 + + 총 비용 + + {analyticsData?.roi?.investment?.total?.toLocaleString() || 0}원 + + + + 경품 비용 + + {analyticsData?.roi?.investment?.prizeCost?.toLocaleString() || 0}원 + + + + 채널 비용 + + {analyticsData?.roi?.investment?.distribution?.toLocaleString() || 0}원 + + + + 기타 비용 + + {((analyticsData?.roi?.investment?.contentCreation || 0) + + (analyticsData?.roi?.investment?.operation || 0)).toLocaleString()}원 + + + + + + + + + 예상 수익 + + 매출 증가 + + {analyticsData?.roi?.revenue?.total?.toLocaleString() || 0}원 + + + + 신규고객 LTV + + {analyticsData?.roi?.revenue?.newCustomerRevenue?.toLocaleString() || 0}원 + + + + + + + + + {/* 마지막 업데이트 정보 */} + + + + 🕐 마지막 업데이트: {formatDateTime(analyticsData?.dashboard?.lastUpdatedAt || '')} + + + + + + ); +} diff --git a/src/entities/analytics/api/analyticsApi.ts b/src/entities/analytics/api/analyticsApi.ts new file mode 100644 index 0000000..702339e --- /dev/null +++ b/src/entities/analytics/api/analyticsApi.ts @@ -0,0 +1,142 @@ +import { analyticsClient } from './analyticsClient'; +import type { + ApiResponse, + UserAnalyticsDashboardResponse, + AnalyticsDashboardResponse, + UserTimelineAnalyticsResponse, + TimelineAnalyticsResponse, + UserRoiAnalyticsResponse, + RoiAnalyticsResponse, + UserChannelAnalyticsResponse, + ChannelAnalyticsResponse, + AnalyticsQueryParams, + TimelineQueryParams, + ChannelQueryParams, + RoiQueryParams, +} from '../model/types'; + +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1'; + +/** + * Analytics API Service + * 실시간 효과 측정 및 통합 대시보드 API + */ +export const analyticsApi = { + // ============= User Analytics (사용자 전체 이벤트 통합) ============= + + /** + * 사용자 전체 성과 대시보드 조회 + */ + getUserAnalytics: async ( + userId: string, + params?: AnalyticsQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/users/${userId}/analytics`, + { params } + ); + return response.data.data; + }, + + /** + * 사용자 전체 시간대별 참여 추이 + */ + getUserTimelineAnalytics: async ( + userId: string, + params?: TimelineQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/users/${userId}/analytics/timeline`, + { params } + ); + return response.data.data; + }, + + /** + * 사용자 전체 ROI 상세 분석 + */ + getUserRoiAnalytics: async ( + userId: string, + params?: AnalyticsQueryParams & RoiQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/users/${userId}/analytics/roi`, + { params } + ); + return response.data.data; + }, + + /** + * 사용자 전체 채널별 성과 분석 + */ + getUserChannelAnalytics: async ( + userId: string, + params?: ChannelQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/users/${userId}/analytics/channels`, + { params } + ); + return response.data.data; + }, + + // ============= Event Analytics (특정 이벤트 분석) ============= + + /** + * 이벤트 성과 대시보드 조회 + */ + getEventAnalytics: async ( + eventId: string, + params?: AnalyticsQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/events/${eventId}/analytics`, + { params } + ); + return response.data.data; + }, + + /** + * 이벤트 시간대별 참여 추이 + */ + getEventTimelineAnalytics: async ( + eventId: string, + params?: TimelineQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/events/${eventId}/analytics/timeline`, + { params } + ); + return response.data.data; + }, + + /** + * 이벤트 ROI 상세 분석 + */ + getEventRoiAnalytics: async ( + eventId: string, + params?: RoiQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/events/${eventId}/analytics/roi`, + { params } + ); + return response.data.data; + }, + + /** + * 이벤트 채널별 성과 분석 + */ + getEventChannelAnalytics: async ( + eventId: string, + params?: ChannelQueryParams + ): Promise => { + const response = await analyticsClient.get>( + `/api/${API_VERSION}/events/${eventId}/analytics/channels`, + { params } + ); + return response.data.data; + }, +}; + +export default analyticsApi; diff --git a/src/entities/analytics/api/analyticsClient.ts b/src/entities/analytics/api/analyticsClient.ts new file mode 100644 index 0000000..0304c2b --- /dev/null +++ b/src/entities/analytics/api/analyticsClient.ts @@ -0,0 +1,67 @@ +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; + +const ANALYTICS_HOST = + process.env.NEXT_PUBLIC_ANALYTICS_HOST || 'http://localhost:8086'; + +export const analyticsClient: AxiosInstance = axios.create({ + baseURL: ANALYTICS_HOST, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - JWT 토큰 추가 +analyticsClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + console.log('🚀 Analytics API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + params: config.params, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + console.log('🔑 Token added to analytics request'); + } + return config; + }, + (error: AxiosError) => { + console.error('❌ Analytics Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor - 에러 처리 +analyticsClient.interceptors.response.use( + (response) => { + console.log('✅ Analytics API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error: AxiosError) => { + console.error('❌ Analytics API Error:', { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + data: error.response?.data, + }); + + if (error.response?.status === 401) { + console.warn('🔒 401 Unauthorized - Redirecting to login'); + localStorage.removeItem('accessToken'); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + +export default analyticsClient; diff --git a/src/entities/analytics/api/index.ts b/src/entities/analytics/api/index.ts new file mode 100644 index 0000000..e4a750e --- /dev/null +++ b/src/entities/analytics/api/index.ts @@ -0,0 +1,3 @@ +export { analyticsApi } from './analyticsApi'; +export { analyticsClient } from './analyticsClient'; +export * from '../model/types'; diff --git a/src/entities/analytics/index.ts b/src/entities/analytics/index.ts new file mode 100644 index 0000000..36d9687 --- /dev/null +++ b/src/entities/analytics/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export * from './model/types'; diff --git a/src/entities/analytics/model/types.ts b/src/entities/analytics/model/types.ts new file mode 100644 index 0000000..d9d4a89 --- /dev/null +++ b/src/entities/analytics/model/types.ts @@ -0,0 +1,295 @@ +/** + * Analytics API Types + * Based on Analytics Service OpenAPI Specification + */ + +// ============= Common Types ============= +export interface PeriodInfo { + startDate: string; + endDate: string; + durationDays: number; +} + +export interface SocialInteractionStats { + likes: number; + comments: number; + shares: number; +} + +// ============= Summary Types ============= +export interface AnalyticsSummary { + participants: number; + participantsDelta: number; + totalViews: number; + totalReach: number; + engagementRate: number; + conversionRate: number; + averageEngagementTime: number; + targetRoi: number; + socialInteractions?: SocialInteractionStats; +} + +export interface ChannelSummary { + channel: string; + views: number; + participants: number; + engagementRate: number; + conversionRate: number; + roi: number; +} + +export interface RoiSummary { + totalCost: number; + expectedRevenue: number; + netProfit: number; + roi: number; + costPerAcquisition: number; +} + +export interface EventPerformanceSummary { + eventId: string; + eventTitle: string; + participants: number; + views: number; + roi: number; + status: string; +} + +// ============= Timeline Types ============= +export interface TimelineDataPoint { + timestamp: string; + participants: number; + views: number; + engagement: number; + conversions: number; + cumulativeParticipants: number; +} + +export interface PeakTimeInfo { + timestamp: string; + metric: string; + value: number; + description: string; +} + +export interface TrendAnalysis { + overallTrend: string; + growthRate: number; + projectedParticipants: number; + peakPeriod: string; +} + +// ============= ROI Types ============= +export interface InvestmentDetails { + prizeCost: number; + contentCreation: number; + distribution: number; + operation: number; + total: number; + breakdown?: Record[]; +} + +export interface RevenueDetails { + directSales: number; + expectedSales: number; + brandValue: number; + newCustomerRevenue: number; + total: number; +} + +export interface RoiCalculation { + netProfit: number; + roiPercentage: number; + breakEvenPoint?: string; + paybackPeriod: number; +} + +export interface CostEfficiency { + costPerParticipant: number; + costPerConversion: number; + costPerView: number; + revenuePerParticipant: number; +} + +export interface RevenueProjection { + currentRevenue: number; + projectedFinalRevenue: number; + confidenceLevel: number; + basedOn: string; +} + +// ============= Channel Types ============= +export interface VoiceCallStats { + totalCalls: number; + completedCalls: number; + averageDuration: number; + completionRate: number; +} + +export interface ChannelMetrics { + impressions: number; + views: number; + clicks: number; + participants: number; + conversions: number; + socialInteractions?: SocialInteractionStats; + voiceCallStats?: VoiceCallStats; +} + +export interface ChannelPerformance { + clickThroughRate: number; + engagementRate: number; + conversionRate: number; + averageEngagementTime: number; + bounceRate: number; +} + +export interface ChannelCosts { + distributionCost: number; + costPerView: number; + costPerClick: number; + costPerAcquisition: number; + roi: number; +} + +export interface ChannelAnalytics { + channelName: string; + channelType: string; + metrics: ChannelMetrics; + performance: ChannelPerformance; + costs: ChannelCosts; + externalApiStatus?: string; +} + +export interface ChannelComparison { + bestPerforming: Record; + averageMetrics: Record; +} + +// ============= Dashboard Response Types ============= +export interface UserAnalyticsDashboardResponse { + userId: string; + period: PeriodInfo; + totalEvents: number; + activeEvents: number; + overallSummary: AnalyticsSummary; + channelPerformance: ChannelSummary[]; + overallRoi: RoiSummary; + eventPerformances: EventPerformanceSummary[]; + lastUpdatedAt: string; + dataSource: string; +} + +export interface AnalyticsDashboardResponse { + eventId: string; + eventTitle: string; + period: PeriodInfo; + summary: AnalyticsSummary; + channelPerformance: ChannelSummary[]; + roi: RoiSummary; + lastUpdatedAt: string; + dataSource: string; +} + +export interface TimelineAnalyticsResponse { + eventId: string; + interval: string; + dataPoints: TimelineDataPoint[]; + trends: TrendAnalysis; + peakTimes: PeakTimeInfo[]; + lastUpdatedAt: string; +} + +export interface UserTimelineAnalyticsResponse { + userId: string; + period: PeriodInfo; + totalEvents: number; + interval: string; + dataPoints: TimelineDataPoint[]; + trend: TrendAnalysis; + peakTime: PeakTimeInfo; + lastUpdatedAt: string; + dataSource: string; +} + +export interface RoiAnalyticsResponse { + eventId: string; + investment: InvestmentDetails; + revenue: RevenueDetails; + roi: RoiCalculation; + costEfficiency: CostEfficiency; + projection: RevenueProjection; + lastUpdatedAt: string; +} + +export interface UserRoiAnalyticsResponse { + userId: string; + period: PeriodInfo; + totalEvents: number; + overallInvestment: InvestmentDetails; + overallRevenue: RevenueDetails; + overallRoi: RoiCalculation; + costEfficiency: CostEfficiency; + projection: RevenueProjection; + eventRois: EventRoiSummary[]; + lastUpdatedAt: string; + dataSource: string; +} + +export interface EventRoiSummary { + eventId: string; + eventTitle: string; + totalInvestment: number; + expectedRevenue: number; + roi: number; + status: string; +} + +export interface ChannelAnalyticsResponse { + eventId: string; + channels: ChannelAnalytics[]; + comparison: ChannelComparison; + lastUpdatedAt: string; +} + +export interface UserChannelAnalyticsResponse { + userId: string; + period: PeriodInfo; + totalEvents: number; + channels: ChannelAnalytics[]; + comparison: ChannelComparison; + lastUpdatedAt: string; + dataSource: string; +} + +// ============= API Response Wrapper ============= +export interface ApiResponse { + success: boolean; + data: T; + errorCode?: string; + message?: string; + timestamp: string; +} + +// ============= API Request Types ============= +export interface AnalyticsQueryParams { + startDate?: string; + endDate?: string; + refresh?: boolean; +} + +export interface TimelineQueryParams extends AnalyticsQueryParams { + interval?: 'hourly' | 'daily' | 'weekly' | 'monthly'; + metrics?: string; +} + +export interface ChannelQueryParams extends AnalyticsQueryParams { + channels?: string; + sortBy?: 'views' | 'participants' | 'engagement_rate' | 'conversion_rate' | 'roi'; + order?: 'asc' | 'desc'; +} + +export interface RoiQueryParams { + includeProjection?: boolean; + refresh?: boolean; +}