mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 08:16:23 +00:00
- 이벤트 기간 계산 함수에 상세 디버그 로그 추가 - 차트 데이터 생성 함수에 필터링 과정 로그 추가 - Timeline dataPoints 구조 확인을 위한 콘솔 출력 추가 - ROI 필드 매핑 검증을 위한 로그 추가
992 lines
40 KiB
TypeScript
992 lines
40 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import {
|
||
Box,
|
||
Container,
|
||
Typography,
|
||
Card,
|
||
CardContent,
|
||
Grid,
|
||
CircularProgress,
|
||
IconButton,
|
||
Tooltip,
|
||
} from '@mui/material';
|
||
import {
|
||
PieChart as PieChartIcon,
|
||
ShowChart as ShowChartIcon,
|
||
Payments,
|
||
People,
|
||
Refresh as RefreshIcon,
|
||
} from '@mui/icons-material';
|
||
import {
|
||
Chart as ChartJS,
|
||
ArcElement,
|
||
CategoryScale,
|
||
LinearScale,
|
||
PointElement,
|
||
LineElement,
|
||
Title,
|
||
Tooltip as ChartTooltip,
|
||
Legend,
|
||
} from 'chart.js';
|
||
import { Pie, Line } from 'react-chartjs-2';
|
||
import Header from '@/shared/ui/Header';
|
||
import {
|
||
cardStyles,
|
||
colors,
|
||
responsiveText,
|
||
} from '@/shared/lib/button-styles';
|
||
import { useAuthContext } from '@/features/auth';
|
||
import { analyticsApi } from '@/entities/analytics';
|
||
import type {
|
||
UserAnalyticsDashboardResponse,
|
||
UserTimelineAnalyticsResponse,
|
||
UserRoiAnalyticsResponse,
|
||
} from '@/entities/analytics';
|
||
|
||
// Chart.js 등록
|
||
ChartJS.register(
|
||
ArcElement,
|
||
CategoryScale,
|
||
LinearScale,
|
||
PointElement,
|
||
LineElement,
|
||
Title,
|
||
ChartTooltip,
|
||
Legend
|
||
);
|
||
|
||
export default function AnalyticsPage() {
|
||
const { user } = useAuthContext();
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [dashboardData, setDashboardData] = useState<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);
|
||
|
||
let text;
|
||
if (diff < 60) {
|
||
text = '방금 전';
|
||
} else if (diff < 3600) {
|
||
text = `${Math.floor(diff / 60)}분 전`;
|
||
} else {
|
||
text = `${Math.floor(diff / 3600)}시간 전`;
|
||
}
|
||
|
||
setUpdateText(text);
|
||
}, 30000); // 30초마다 갱신
|
||
|
||
return () => {
|
||
clearInterval(updateInterval);
|
||
};
|
||
}, [lastUpdate]);
|
||
|
||
// 로딩 중 표시
|
||
if (loading) {
|
||
return (
|
||
<>
|
||
<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 (
|
||
<>
|
||
<Header title="성과 분석" showBack={true} showMenu={false} showProfile={true} />
|
||
<Box
|
||
sx={{
|
||
pt: { xs: 7, sm: 8 },
|
||
pb: 10,
|
||
bgcolor: colors.gray[50],
|
||
minHeight: '100vh',
|
||
}}
|
||
>
|
||
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 6 }, px: { xs: 3, sm: 6, md: 10 } }}>
|
||
{/* Title with Real-time Indicator */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
mb: { xs: 4, sm: 10 },
|
||
}}
|
||
>
|
||
<Typography variant="h5" sx={{ ...responsiveText.h3 }}>
|
||
📊 요약 (실시간)
|
||
</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>
|
||
|
||
{/* Summary KPI Cards */}
|
||
<Grid container spacing={{ xs: 2, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
...cardStyles.default,
|
||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
||
borderColor: 'transparent',
|
||
}}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||
<Typography
|
||
variant="body2"
|
||
sx={{
|
||
mb: { xs: 1, sm: 2 },
|
||
color: 'rgba(255, 255, 255, 0.9)',
|
||
fontWeight: 500,
|
||
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||
}}
|
||
>
|
||
참여자 수
|
||
</Typography>
|
||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '1.5rem', sm: '2.5rem' } }}>
|
||
{summary.participants}
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ color: 'rgba(255, 255, 255, 0.8)', fontWeight: 600, fontSize: { xs: '0.6875rem', sm: '0.875rem' } }}
|
||
>
|
||
↑ {summary.participantsDelta}명
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
...cardStyles.default,
|
||
background: `linear-gradient(135deg, ${colors.orange} 0%, ${colors.orangeLight} 100%)`,
|
||
borderColor: 'transparent',
|
||
}}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||
<Typography
|
||
variant="body2"
|
||
sx={{
|
||
mb: { xs: 1, sm: 2 },
|
||
color: 'rgba(255, 255, 255, 0.9)',
|
||
fontWeight: 500,
|
||
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||
}}
|
||
>
|
||
총 비용
|
||
</Typography>
|
||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '1.5rem', sm: '2.5rem' } }}>
|
||
{Math.floor(summary.totalCost / 10000)}만
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ color: 'rgba(255, 255, 255, 0.8)', fontWeight: 600, fontSize: { xs: '0.6875rem', sm: '0.875rem' } }}
|
||
>
|
||
경품+채널
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
...cardStyles.default,
|
||
background: `linear-gradient(135deg, ${colors.mint} 0%, ${colors.mintLight} 100%)`,
|
||
borderColor: 'transparent',
|
||
}}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||
<Typography
|
||
variant="body2"
|
||
sx={{
|
||
mb: { xs: 1, sm: 2 },
|
||
color: 'rgba(255, 255, 255, 0.9)',
|
||
fontWeight: 500,
|
||
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||
}}
|
||
>
|
||
예상 수익
|
||
</Typography>
|
||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '1.5rem', sm: '2.5rem' } }}>
|
||
{Math.floor(summary.expectedRevenue / 10000)}만
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ color: 'rgba(255, 255, 255, 0.8)', fontWeight: 600, fontSize: { xs: '0.6875rem', sm: '0.875rem' } }}
|
||
>
|
||
매출+LTV
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
...cardStyles.default,
|
||
background: `linear-gradient(135deg, ${colors.blue} 0%, ${colors.blueLight} 100%)`,
|
||
borderColor: 'transparent',
|
||
}}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||
<Typography
|
||
variant="body2"
|
||
sx={{
|
||
mb: { xs: 1, sm: 2 },
|
||
color: 'rgba(255, 255, 255, 0.9)',
|
||
fontWeight: 500,
|
||
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||
}}
|
||
>
|
||
ROI
|
||
</Typography>
|
||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '1.5rem', sm: '2.5rem' } }}>
|
||
{summary.roi}%
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ color: 'rgba(255, 255, 255, 0.8)', fontWeight: 600, fontSize: { xs: '0.6875rem', sm: '0.875rem' } }}
|
||
>
|
||
목표 {summary.targetRoi}%
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
|
||
{/* Charts Grid */}
|
||
<Grid container spacing={{ xs: 3, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||
{/* Channel Performance */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card elevation={0} sx={{ ...cardStyles.default, height: '100%' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6, md: 8 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, mb: { xs: 3, sm: 6 } }}>
|
||
<Box
|
||
sx={{
|
||
width: { xs: 32, sm: 40 },
|
||
height: { xs: 32, sm: 40 },
|
||
borderRadius: '12px',
|
||
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.pinkLight} 100%)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<PieChartIcon sx={{ fontSize: { xs: 20, sm: 24 }, color: 'white' }} />
|
||
</Box>
|
||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||
채널별 성과
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Pie Chart */}
|
||
<Box
|
||
sx={{
|
||
width: '100%',
|
||
maxWidth: { xs: 200, sm: 300 },
|
||
mx: 'auto',
|
||
mb: { xs: 2, sm: 3 },
|
||
flex: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{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 */}
|
||
{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>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* Time Trend */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card elevation={0} sx={{ ...cardStyles.default, height: '100%' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6, md: 8 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, mb: { xs: 3, sm: 6 } }}>
|
||
<Box
|
||
sx={{
|
||
width: { xs: 32, sm: 40 },
|
||
height: { xs: 32, sm: 40 },
|
||
borderRadius: '12px',
|
||
background: `linear-gradient(135deg, ${colors.blue} 0%, ${colors.blueLight} 100%)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<ShowChartIcon sx={{ fontSize: { xs: 20, sm: 24 }, color: 'white' }} />
|
||
</Box>
|
||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||
시간대별 참여 추이
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Line Chart */}
|
||
<Box
|
||
sx={{
|
||
width: '100%',
|
||
mb: { xs: 2, sm: 3 },
|
||
flex: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
minHeight: { xs: 150, sm: 200 },
|
||
}}
|
||
>
|
||
<Line
|
||
data={{
|
||
labels: hourlyData.map((item) => `${item.hour}시`),
|
||
datasets: [
|
||
{
|
||
label: '참여자 수',
|
||
data: hourlyData.map((item) => item.participants),
|
||
borderColor: colors.blue,
|
||
backgroundColor: `${colors.blue}33`,
|
||
fill: true,
|
||
tension: 0.4,
|
||
pointBackgroundColor: colors.blue,
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
pointRadius: 4,
|
||
pointHoverRadius: 6,
|
||
},
|
||
],
|
||
}}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
backgroundColor: colors.gray[900],
|
||
padding: 12,
|
||
displayColors: false,
|
||
callbacks: {
|
||
label: function (context) {
|
||
return `${context.parsed.y}명`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
color: colors.gray[600],
|
||
},
|
||
grid: {
|
||
color: colors.gray[200],
|
||
},
|
||
},
|
||
x: {
|
||
ticks: {
|
||
color: colors.gray[600],
|
||
},
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
|
||
{/* Stats */}
|
||
<Box>
|
||
<Typography variant="body2" sx={{ mb: 0.5, color: colors.gray[600], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
|
||
피크 시간: {timePerformance.peakTime} ({timePerformance.peakParticipants}명)
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ color: colors.gray[600], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
|
||
평균 시간당: {timePerformance.avgPerHour}명
|
||
</Typography>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
|
||
{/* ROI Detail & Participant Profile */}
|
||
<Grid container spacing={{ xs: 3, sm: 6 }}>
|
||
{/* ROI Detail */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card elevation={0} sx={{ ...cardStyles.default, height: '100%' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6, md: 8 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, mb: { xs: 3, sm: 6 } }}>
|
||
<Box
|
||
sx={{
|
||
width: { xs: 32, sm: 40 },
|
||
height: { xs: 32, sm: 40 },
|
||
borderRadius: '12px',
|
||
background: `linear-gradient(135deg, ${colors.orange} 0%, ${colors.orangeLight} 100%)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Payments sx={{ fontSize: { xs: 20, sm: 24 }, color: 'white' }} />
|
||
</Box>
|
||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||
ROI 상세
|
||
</Typography>
|
||
</Box>
|
||
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 2, sm: 3 } }}>
|
||
<Box>
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: { xs: 1, sm: 1.5 }, color: colors.gray[900], fontSize: { xs: '0.9375rem', sm: '1rem' } }}>
|
||
총 비용: {Math.floor(roiDetail.totalCost / 10000)}만원
|
||
</Typography>
|
||
<Box sx={{ pl: { xs: 1.5, sm: 2 }, display: 'flex', flexDirection: 'column', gap: { xs: 0.75, sm: 1 } }}>
|
||
<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.prizeCost / 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.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>
|
||
|
||
<Box>
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: { xs: 1, sm: 1.5 }, color: colors.gray[900], fontSize: { xs: '0.9375rem', sm: '1rem' } }}>
|
||
예상 수익: {Math.floor(roiDetail.expectedRevenue / 10000)}만원
|
||
</Typography>
|
||
<Box sx={{ pl: { xs: 1.5, sm: 2 }, display: 'flex', flexDirection: 'column', gap: { xs: 0.75, sm: 1 } }}>
|
||
<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.mint, fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
|
||
{Math.floor(roiDetail.salesIncrease / 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' } }}>
|
||
• 신규 고객 LTV
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ fontWeight: 600, color: colors.mint, fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
|
||
{Math.floor(roiDetail.newCustomerLTV / 10000)}만원
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box>
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: { xs: 1, sm: 1.5 }, color: colors.mint, fontSize: { xs: '0.9375rem', sm: '1rem' } }}>
|
||
투자대비수익률
|
||
</Typography>
|
||
<Box
|
||
sx={{
|
||
p: { xs: 2, sm: 2.5 },
|
||
bgcolor: colors.gray[100],
|
||
borderRadius: 2,
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<Typography variant="body2" sx={{ mb: 0.5, color: colors.gray[700], fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
(수익 - 비용) ÷ 비용 × 100
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ mb: { xs: 1, sm: 1.5 }, color: colors.gray[600], fontSize: { xs: '0.6875rem', sm: '0.875rem' } }}>
|
||
({Math.floor(roiDetail.expectedRevenue / 10000)}만 -{' '}
|
||
{Math.floor(roiDetail.totalCost / 10000)}만) ÷{' '}
|
||
{Math.floor(roiDetail.totalCost / 10000)}만 × 100
|
||
</Typography>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, color: colors.mint, fontSize: { xs: '1.25rem', sm: '1.5rem' } }}>
|
||
= {summary.roi}%
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
{/* Participant Profile */}
|
||
<Grid item xs={12} md={6}>
|
||
<Card elevation={0} sx={{ ...cardStyles.default, height: '100%' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6, md: 8 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, mb: { xs: 3, sm: 6 } }}>
|
||
<Box
|
||
sx={{
|
||
width: { xs: 32, sm: 40 },
|
||
height: { xs: 32, sm: 40 },
|
||
borderRadius: '12px',
|
||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<People sx={{ fontSize: { xs: 20, sm: 24 }, color: 'white' }} />
|
||
</Box>
|
||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||
참여자 프로필
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Age Distribution */}
|
||
<Box sx={{ mb: { xs: 3, sm: 4 } }}>
|
||
<Typography variant="body1" sx={{ fontWeight: 600, mb: { xs: 1.5, sm: 2 }, color: colors.gray[900], fontSize: { xs: '0.9375rem', sm: '1rem' } }}>
|
||
연령별
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 1.25, sm: 1.5 } }}>
|
||
{participantProfile.age.map((item) => (
|
||
<Box key={item.label}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 1.5 } }}>
|
||
<Typography variant="body2" sx={{ minWidth: { xs: 45, sm: 60 }, color: colors.gray[700], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
|
||
{item.label}
|
||
</Typography>
|
||
<Box
|
||
sx={{
|
||
flex: 1,
|
||
height: { xs: 24, sm: 28 },
|
||
bgcolor: colors.gray[200],
|
||
borderRadius: 1.5,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
width: `${item.percentage}%`,
|
||
height: '100%',
|
||
background: `linear-gradient(135deg, ${colors.blue} 0%, ${colors.blueLight} 100%)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'flex-end',
|
||
pr: { xs: 1, sm: 1.5 },
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ color: 'white', fontWeight: 600, fontSize: { xs: 10, sm: 12 } }}
|
||
>
|
||
{item.percentage}%
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Gender Distribution */}
|
||
<Box>
|
||
<Typography variant="body1" sx={{ fontWeight: 600, mb: { xs: 1.5, sm: 2 }, color: colors.gray[900], fontSize: { xs: '0.9375rem', sm: '1rem' } }}>
|
||
성별
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 1.25, sm: 1.5 } }}>
|
||
{participantProfile.gender.map((item) => (
|
||
<Box key={item.label}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 1.5 } }}>
|
||
<Typography variant="body2" sx={{ minWidth: { xs: 45, sm: 60 }, color: colors.gray[700], fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}>
|
||
{item.label}
|
||
</Typography>
|
||
<Box
|
||
sx={{
|
||
flex: 1,
|
||
height: { xs: 24, sm: 28 },
|
||
bgcolor: colors.gray[200],
|
||
borderRadius: 1.5,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
width: `${item.percentage}%`,
|
||
height: '100%',
|
||
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.pinkLight} 100%)`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'flex-end',
|
||
pr: { xs: 1, sm: 1.5 },
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ color: 'white', fontWeight: 600, fontSize: { xs: 10, sm: 12 } }}
|
||
>
|
||
{item.percentage}%
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
</Container>
|
||
</Box>
|
||
</>
|
||
);
|
||
}
|