Merge pull request #10 from ktds-dg0501/feature/analytics

Feature/analytics
This commit is contained in:
Hyowon Yang 2025-10-30 10:01:47 +09:00 committed by GitHub
commit e65ee14d61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1968 additions and 212 deletions

View File

@ -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<UserAnalyticsDashboardResponse | null>(null);
const [timelineData, setTimelineData] = useState<UserTimelineAnalyticsResponse | null>(null);
const [roiData, setRoiData] = useState<UserRoiAnalyticsResponse | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(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 (
<>
<Header title="성과 분석" showBack={true} showMenu={false} showProfile={true} />
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
</>
);
}
// 데이터 없음 표시
if (!dashboardData || !timelineData || !roiData) {
return (
<>
<Header title="성과 분석" showBack={true} showMenu={false} showProfile={true} />
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="body1" color="text.secondary">
Analytics .
</Typography>
</Box>
</>
);
}
// 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() {
<Typography variant="h5" sx={{ ...responsiveText.h3 }}>
📊 ()
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
<Box
sx={{
width: { xs: 6, sm: 8 },
height: { xs: 6, sm: 8 },
borderRadius: '50%',
bgcolor: colors.mint,
animation: 'pulse 2s infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.3 },
},
}}
/>
<Typography variant="caption" sx={{ ...responsiveText.body2 }}>
{updateText}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.5, sm: 1 } }}>
<Box
sx={{
width: { xs: 6, sm: 8 },
height: { xs: 6, sm: 8 },
borderRadius: '50%',
bgcolor: colors.mint,
animation: 'pulse 2s infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.3 },
},
}}
/>
<Typography variant="caption" sx={{ ...responsiveText.body2 }}>
{updateText}
</Typography>
</Box>
<Tooltip title="데이터 새로고침">
<IconButton
onClick={handleRefresh}
disabled={refreshing}
size="small"
sx={{
color: colors.gray[700],
'&:hover': {
bgcolor: colors.gray[100],
color: colors.mint,
},
'&.Mui-disabled': {
color: colors.gray[300],
},
}}
>
<RefreshIcon
sx={{
fontSize: { xs: 20, sm: 24 },
animation: refreshing ? 'spin 1s linear infinite' : 'none',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
</IconButton>
</Tooltip>
</Box>
</Box>
@ -342,67 +568,80 @@ export default function AnalyticsPage() {
justifyContent: 'center',
}}
>
<Pie
data={{
labels: channelPerformance.map((item) => 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 ? (
<Pie
data={{
labels: channelPerformance.map((item) => 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}%)`;
},
},
},
},
},
}}
/>
}}
/>
) : (
<Box sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
.
</Typography>
<Typography variant="caption" color="text.disabled" sx={{ mt: 1, display: 'block', fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
.
</Typography>
</Box>
)}
</Box>
{/* Legend */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 0.75, sm: 1 } }}>
{channelPerformance.map((item) => (
<Box
key={item.channel}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: { xs: 10, sm: 12 },
height: { xs: 10, sm: 12 },
borderRadius: '50%',
bgcolor: item.color,
}}
/>
<Typography variant="body2" sx={{ color: colors.gray[700], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
{item.channel}
{hasChannelData && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 0.75, sm: 1 } }}>
{channelPerformance.map((item) => (
<Box
key={item.channel}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: { xs: 10, sm: 12 },
height: { xs: 10, sm: 12 },
borderRadius: '50%',
bgcolor: item.color,
}}
/>
<Typography variant="body2" sx={{ color: colors.gray[700], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
{item.channel}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.gray[900], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
{item.percentage}% ({item.participants})
</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.gray[900], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
{item.percentage}% ({item.participants})
</Typography>
</Box>
))}
</Box>
))}
</Box>
)}
</CardContent>
</Card>
</Grid>
@ -443,20 +682,11 @@ export default function AnalyticsPage() {
>
<Line
data={{
labels: [
'00시',
'03시',
'06시',
'09시',
'12시',
'15시',
'18시',
'21시',
],
labels: hourlyData.map((item) => `${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)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" sx={{ color: colors.gray[600], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.gray[900], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
{Math.floor(roiDetail.otherCost / 10000)}
</Typography>
</Box>
</Box>
</Box>

View File

@ -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 | HTMLElement>(null);
const [chartPeriod, setChartPeriod] = useState<'7d' | '30d' | 'all'>('7d');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [analyticsData, setAnalyticsData] = useState<any>(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<HTMLElement>) => {
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<string, number>();
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 (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={60} />
</Box>
);
}
// 에러 발생
if (error) {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', p: 3 }}>
<Alert severity="error" sx={{ maxWidth: 600 }}>
{error}
</Alert>
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 5, sm: 10 } }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 3, sm: 6, md: 10 } }}>
@ -262,9 +410,24 @@ export default function EventDetailPage() {
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: { xs: '1.25rem', sm: '2rem' } }}>
{event.title}
</Typography>
<IconButton onClick={handleMenuOpen}>
<MoreVert />
</IconButton>
<Box sx={{ display: 'flex', gap: 1 }}>
<MuiTooltip title="데이터 새로고침">
<IconButton onClick={handleRefresh} disabled={refreshing}>
<RefreshIcon
sx={{
animation: refreshing ? 'spin 1s linear infinite' : 'none',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
</IconButton>
</MuiTooltip>
<IconButton onClick={handleMenuOpen}>
<MoreVert />
</IconButton>
</Box>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
<MenuItem onClick={handleMenuClose}>
<Edit sx={{ mr: 2 }} />
@ -547,7 +710,7 @@ export default function EventDetailPage() {
<Box sx={{ height: { xs: 200, sm: 320 } }}>
<Line
data={generateParticipationTrendData(chartPeriod)}
data={generateParticipationTrendData()}
options={{
responsive: true,
maintainAspectRatio: false,
@ -593,7 +756,7 @@ export default function EventDetailPage() {
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Box sx={{ height: { xs: 200, sm: 320 } }}>
<Bar
data={channelPerformanceData}
data={generateChannelPerformanceData()}
options={{
responsive: true,
maintainAspectRatio: false,
@ -636,7 +799,7 @@ export default function EventDetailPage() {
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Box sx={{ height: { xs: 200, sm: 320 } }}>
<Line
data={roiTrendData}
data={generateRoiTrendData()}
options={{
responsive: true,
maintainAspectRatio: false,

View File

@ -0,0 +1,846 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
Box,
Container,
Typography,
Card,
CardContent,
Chip,
Button,
IconButton,
Grid,
CircularProgress,
Alert,
Tooltip as MuiTooltip,
} from '@mui/material';
import {
Group,
Visibility,
TrendingUp,
ArrowBack,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { Line, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip as ChartTooltip,
Legend,
Filler,
} from 'chart.js';
import { analyticsApi } from '@/entities/analytics/api/analyticsApi';
// Chart.js 등록
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
ChartTooltip,
Legend,
Filler
);
// 디자인 시스템 색상
const colors = {
pink: '#F472B6',
purple: '#C084FC',
purpleLight: '#E9D5FF',
blue: '#60A5FA',
mint: '#34D399',
orange: '#FB923C',
yellow: '#FBBF24',
gray: {
900: '#1A1A1A',
700: '#4A4A4A',
500: '#9E9E9E',
300: '#D9D9D9',
100: '#F5F5F5',
},
};
export default function TestAnalyticsPage() {
const router = useRouter();
const params = useParams();
const eventId = params.eventId as string;
const [chartPeriod, setChartPeriod] = useState<'7d' | '30d' | 'all'>('7d');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [analyticsData, setAnalyticsData] = useState<any>(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<string, { date: Date; participants: number }>();
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 (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress size={60} sx={{ mb: 2 }} />
<Typography variant="body1" color="text.secondary">
Analytics ...
</Typography>
</Box>
</Box>
);
}
// 에러 발생
if (error) {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', p: 3 }}>
<Alert severity="error" sx={{ maxWidth: 600 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Analytics </Typography>
{error}
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={() => fetchAnalytics()}>
</Button>
</Box>
</Alert>
</Box>
);
}
const dashboard = analyticsData?.dashboard;
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 5, sm: 10 } }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 3, sm: 6, md: 10 } }}>
{/* Header */}
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: { xs: 2, sm: 4 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<IconButton onClick={() => router.back()}>
<ArrowBack />
</IconButton>
<Box>
<Chip
label="🧪 TEST"
size="small"
sx={{ bgcolor: colors.yellow, color: colors.gray[900], fontWeight: 600, mb: 1 }}
/>
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: { xs: '1.25rem', sm: '2rem' } }}>
{dashboard?.eventTitle || 'Analytics 테스트 페이지'}
</Typography>
</Box>
</Box>
<MuiTooltip title="데이터 새로고침">
<IconButton onClick={handleRefresh} disabled={refreshing}>
<RefreshIcon
sx={{
animation: refreshing ? 'spin 1s linear infinite' : 'none',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
</IconButton>
</MuiTooltip>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
📅 {getDateDisplayString()}
</Typography>
</Box>
{/* Real-time KPIs */}
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1rem', sm: '1.5rem' } }}>
</Typography>
<Grid container spacing={{ xs: 1.5, sm: 6 }}>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
borderRadius: { xs: 2, sm: 4 },
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
height: '100%',
}}
>
<CardContent sx={{
textAlign: 'center',
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<Group sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' } }}>
{dashboard?.summary?.participants?.toLocaleString() || 0}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
borderRadius: { xs: 2, sm: 4 },
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
height: '100%',
}}
>
<CardContent sx={{
textAlign: 'center',
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<Visibility sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' } }}>
{dashboard?.summary?.totalViews?.toLocaleString() || 0}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
borderRadius: { xs: 2, sm: 4 },
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.mint} 0%, #6EE7B7 100%)`,
height: '100%',
}}
>
<CardContent sx={{
textAlign: 'center',
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
ROI
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' } }}>
{Math.round(dashboard?.roi?.roi || 0)}%
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
borderRadius: { xs: 2, sm: 4 },
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.orange} 0%, #FCD34D 100%)`,
height: '100%',
}}
>
<CardContent sx={{
textAlign: 'center',
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' } }}>
{Math.round((dashboard?.summary?.conversionRate || 0) * 100)}%
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
{/* Chart Section - 참여 추이 */}
<Box sx={{ mb: { xs: 5, sm: 10 } }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
📈
</Typography>
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Box sx={{ display: 'flex', gap: { xs: 1, sm: 2 }, mb: { xs: 3, sm: 6 } }}>
<Button
size="medium"
variant={chartPeriod === '7d' ? 'contained' : 'outlined'}
onClick={() => setChartPeriod('7d')}
sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }}
>
7
</Button>
<Button
size="medium"
variant={chartPeriod === '30d' ? 'contained' : 'outlined'}
onClick={() => setChartPeriod('30d')}
sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }}
>
30
</Button>
<Button
size="medium"
variant={chartPeriod === 'all' ? 'contained' : 'outlined'}
onClick={() => setChartPeriod('all')}
sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }}
>
</Button>
</Box>
<Box sx={{ height: { xs: 200, sm: 320 } }}>
<Line
data={generateParticipationTrendData()}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top' as const,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
},
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
x: {
grid: {
display: false,
},
},
},
}}
/>
</Box>
</CardContent>
</Card>
</Box>
{/* Chart Section - 채널별 성과 & ROI 추이 */}
<Grid container spacing={{ xs: 2, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
<Grid item xs={12} md={6}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
📊
</Typography>
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Box sx={{ height: { xs: 200, sm: 320 } }}>
<Bar
data={generateChannelPerformanceData()}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
},
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
x: {
grid: {
display: false,
},
},
},
}}
/>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
💰 ROI
</Typography>
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Box sx={{ height: { xs: 200, sm: 320 } }}>
<Line
data={generateRoiTrendData()}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top' as const,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: { size: 14, weight: 'bold' },
bodyFont: { size: 13 },
},
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
x: {
grid: {
display: false,
},
},
},
}}
/>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
{/* ROI 상세 정보 */}
<Box sx={{ mb: { xs: 5, sm: 10 } }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
💰 ROI
</Typography>
<Grid container spacing={{ xs: 2, sm: 4 }}>
<Grid item xs={12} md={6}>
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: { xs: 3, sm: 4 } }}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}> </Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary"> </Typography>
<Typography variant="body2" fontWeight={600}>
{analyticsData?.roi?.investment?.total?.toLocaleString() || 0}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary"> </Typography>
<Typography variant="body2">
{analyticsData?.roi?.investment?.prizeCost?.toLocaleString() || 0}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary"> </Typography>
<Typography variant="body2">
{analyticsData?.roi?.investment?.distribution?.toLocaleString() || 0}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary"> </Typography>
<Typography variant="body2">
{((analyticsData?.roi?.investment?.contentCreation || 0) +
(analyticsData?.roi?.investment?.operation || 0)).toLocaleString()}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: { xs: 3, sm: 4 } }}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}> </Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary"> </Typography>
<Typography variant="body2" fontWeight={600}>
{analyticsData?.roi?.revenue?.total?.toLocaleString() || 0}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary"> LTV</Typography>
<Typography variant="body2">
{analyticsData?.roi?.revenue?.newCustomerRevenue?.toLocaleString() || 0}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
{/* 마지막 업데이트 정보 */}
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', bgcolor: colors.purpleLight }}>
<CardContent sx={{ p: { xs: 3, sm: 4 } }}>
<Typography variant="body2" color="text.secondary">
🕐 : {formatDateTime(analyticsData?.dashboard?.lastUpdatedAt || '')}
</Typography>
</CardContent>
</Card>
</Container>
</Box>
);
}

View File

@ -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<UserAnalyticsDashboardResponse> => {
const response = await analyticsClient.get<ApiResponse<UserAnalyticsDashboardResponse>>(
`/api/${API_VERSION}/users/${userId}/analytics`,
{ params }
);
return response.data.data;
},
/**
*
*/
getUserTimelineAnalytics: async (
userId: string,
params?: TimelineQueryParams
): Promise<UserTimelineAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<UserTimelineAnalyticsResponse>>(
`/api/${API_VERSION}/users/${userId}/analytics/timeline`,
{ params }
);
return response.data.data;
},
/**
* ROI
*/
getUserRoiAnalytics: async (
userId: string,
params?: AnalyticsQueryParams & RoiQueryParams
): Promise<UserRoiAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<UserRoiAnalyticsResponse>>(
`/api/${API_VERSION}/users/${userId}/analytics/roi`,
{ params }
);
return response.data.data;
},
/**
*
*/
getUserChannelAnalytics: async (
userId: string,
params?: ChannelQueryParams
): Promise<UserChannelAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<UserChannelAnalyticsResponse>>(
`/api/${API_VERSION}/users/${userId}/analytics/channels`,
{ params }
);
return response.data.data;
},
// ============= Event Analytics (특정 이벤트 분석) =============
/**
*
*/
getEventAnalytics: async (
eventId: string,
params?: AnalyticsQueryParams
): Promise<AnalyticsDashboardResponse> => {
const response = await analyticsClient.get<ApiResponse<AnalyticsDashboardResponse>>(
`/api/${API_VERSION}/events/${eventId}/analytics`,
{ params }
);
return response.data.data;
},
/**
*
*/
getEventTimelineAnalytics: async (
eventId: string,
params?: TimelineQueryParams
): Promise<TimelineAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<TimelineAnalyticsResponse>>(
`/api/${API_VERSION}/events/${eventId}/analytics/timeline`,
{ params }
);
return response.data.data;
},
/**
* ROI
*/
getEventRoiAnalytics: async (
eventId: string,
params?: RoiQueryParams
): Promise<RoiAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<RoiAnalyticsResponse>>(
`/api/${API_VERSION}/events/${eventId}/analytics/roi`,
{ params }
);
return response.data.data;
},
/**
*
*/
getEventChannelAnalytics: async (
eventId: string,
params?: ChannelQueryParams
): Promise<ChannelAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<ChannelAnalyticsResponse>>(
`/api/${API_VERSION}/events/${eventId}/analytics/channels`,
{ params }
);
return response.data.data;
},
};
export default analyticsApi;

View File

@ -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;

View File

@ -0,0 +1,3 @@
export { analyticsApi } from './analyticsApi';
export { analyticsClient } from './analyticsClient';
export * from '../model/types';

View File

@ -0,0 +1,2 @@
export * from './api';
export * from './model/types';

View File

@ -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<string, unknown>[];
}
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<string, string>;
averageMetrics: Record<string, number>;
}
// ============= 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<T> {
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;
}