mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 06:56:24 +00:00
충돌 해결: ApprovalStep.tsx 최신 버전으로 병합
This commit is contained in:
commit
8f0d002d82
@ -19,6 +19,11 @@ const nextConfig = {
|
||||
source: '/api/proxy/:path*',
|
||||
destination: 'http://localhost:8084/api/:path*',
|
||||
},
|
||||
// Event Service API Proxy (8080 포트)
|
||||
{
|
||||
source: '/api/v1/events/:path*',
|
||||
destination: 'http://localhost:8080/api/v1/events/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -18,17 +18,43 @@ export interface EventData {
|
||||
eventDraftId?: number;
|
||||
objective?: EventObjective;
|
||||
recommendation?: {
|
||||
budget: BudgetLevel;
|
||||
method: EventMethod;
|
||||
title: string;
|
||||
prize: string;
|
||||
description?: string;
|
||||
industry?: string;
|
||||
location?: string;
|
||||
participationMethod: string;
|
||||
expectedParticipants: number;
|
||||
estimatedCost: number;
|
||||
roi: number;
|
||||
recommendation: {
|
||||
optionNumber: number;
|
||||
concept: string;
|
||||
title: string;
|
||||
description: string;
|
||||
targetAudience: string;
|
||||
duration: {
|
||||
recommendedDays: number;
|
||||
recommendedPeriod?: string;
|
||||
};
|
||||
mechanics: {
|
||||
type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO';
|
||||
details: string;
|
||||
};
|
||||
promotionChannels: string[];
|
||||
estimatedCost: {
|
||||
min: number;
|
||||
max: number;
|
||||
breakdown?: {
|
||||
material?: number;
|
||||
promotion?: number;
|
||||
discount?: number;
|
||||
};
|
||||
};
|
||||
expectedMetrics: {
|
||||
newCustomers: { min: number; max: number };
|
||||
repeatVisits?: { min: number; max: number };
|
||||
revenueIncrease: { min: number; max: number };
|
||||
roi: { min: number; max: number };
|
||||
socialEngagement?: {
|
||||
estimatedPosts: number;
|
||||
estimatedReach: number;
|
||||
};
|
||||
};
|
||||
differentiator: string;
|
||||
};
|
||||
eventId: string;
|
||||
};
|
||||
contentPreview?: {
|
||||
imageStyle: string;
|
||||
@ -96,13 +122,13 @@ export default function EventCreatePage() {
|
||||
if (needsContent) {
|
||||
// localStorage에 이벤트 정보 저장
|
||||
const eventData = {
|
||||
eventDraftId: context.eventDraftId || Date.now(), // 임시 ID 생성
|
||||
eventTitle: context.recommendation?.title || '',
|
||||
eventDescription: context.recommendation?.description || context.recommendation?.participationMethod || '',
|
||||
industry: context.recommendation?.industry || '',
|
||||
location: context.recommendation?.location || '',
|
||||
trends: [], // 필요시 context에서 추가
|
||||
prize: context.recommendation?.prize || '',
|
||||
eventDraftId: context.recommendation?.eventId || String(Date.now()), // eventId 사용
|
||||
eventTitle: context.recommendation?.recommendation.title || '',
|
||||
eventDescription: context.recommendation?.recommendation.description || '',
|
||||
industry: '',
|
||||
location: '',
|
||||
trends: context.recommendation?.recommendation.promotionChannels || [],
|
||||
prize: '',
|
||||
};
|
||||
localStorage.setItem('eventCreationData', JSON.stringify(eventData));
|
||||
|
||||
@ -118,6 +144,9 @@ export default function EventCreatePage() {
|
||||
)}
|
||||
contentPreview={({ context, history }) => (
|
||||
<ContentPreviewStep
|
||||
eventId={context.recommendation?.eventId}
|
||||
eventTitle={context.recommendation?.recommendation.title}
|
||||
eventDescription={context.recommendation?.recommendation.description}
|
||||
onNext={(imageStyle, images) => {
|
||||
history.push('contentEdit', {
|
||||
...context,
|
||||
@ -134,8 +163,8 @@ export default function EventCreatePage() {
|
||||
)}
|
||||
contentEdit={({ context, history }) => (
|
||||
<ContentEditStep
|
||||
initialTitle={context.recommendation?.title || ''}
|
||||
initialPrize={context.recommendation?.prize || ''}
|
||||
initialTitle={context.recommendation?.recommendation.title || ''}
|
||||
initialPrize={''}
|
||||
onNext={(contentEdit) => {
|
||||
history.push('approval', { ...context, contentEdit });
|
||||
}}
|
||||
|
||||
@ -20,6 +20,8 @@ import {
|
||||
import { ArrowBack, CheckCircle, Edit, RocketLaunch, Save, People, AttachMoney, TrendingUp } from '@mui/icons-material';
|
||||
import { EventData } from '../page';
|
||||
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
||||
import { eventApi } from '@/entities/event/api/eventApi';
|
||||
import type { EventObjective } from '@/entities/event/model/types';
|
||||
|
||||
|
||||
interface ApprovalStepProps {
|
||||
@ -41,50 +43,91 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
setIsDeploying(true);
|
||||
|
||||
try {
|
||||
// 1. 이벤트 생성 API 호출
|
||||
console.log('📞 Creating event with objective:', eventData.objective);
|
||||
|
||||
// 채널 매핑: 프론트엔드 채널명 → API 채널명
|
||||
const channelMap: Record<string, string[]> = {
|
||||
uriTV: ['URIDONGNETV'],
|
||||
ringoBiz: ['RINGOBIZ'],
|
||||
genieTV: ['GINITV'],
|
||||
sns: ['INSTAGRAM', 'NAVER', 'KAKAO'],
|
||||
// objective 매핑 (Frontend → Backend)
|
||||
const objectiveMap: Record<string, EventObjective> = {
|
||||
'new_customer': 'CUSTOMER_ACQUISITION',
|
||||
'revisit': 'Customer Retention',
|
||||
'sales': 'Sales Promotion',
|
||||
'awareness': 'awareness',
|
||||
};
|
||||
|
||||
const apiChannels = eventData.channels?.flatMap(ch => channelMap[ch] || []) || [];
|
||||
const backendObjective: EventObjective = (objectiveMap[eventData.objective || 'new_customer'] || 'CUSTOMER_ACQUISITION') as EventObjective;
|
||||
|
||||
const distributionRequest = {
|
||||
eventId: `evt-${eventData.eventDraftId || Date.now()}`,
|
||||
title: eventData.recommendation?.title || '이벤트',
|
||||
description: eventData.recommendation?.description || eventData.recommendation?.participationMethod || '',
|
||||
imageUrl: '', // TODO: 이미지 URL 연동 필요
|
||||
channels: apiChannels,
|
||||
channelSettings: {},
|
||||
};
|
||||
|
||||
console.log('🚀 Distributing event:', distributionRequest);
|
||||
|
||||
const response = await fetch(`${DISTRIBUTION_API_BASE_URL}/api/v1/distribution/distribute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(distributionRequest),
|
||||
const createResponse = await eventApi.createEvent({
|
||||
objective: backendObjective,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '배포 중 오류가 발생했습니다');
|
||||
console.log('✅ Event created:', createResponse);
|
||||
|
||||
if (createResponse.success && createResponse.data) {
|
||||
const eventId = createResponse.data.eventId;
|
||||
console.log('🎯 Event ID:', eventId);
|
||||
|
||||
// 2. 이벤트 상세 정보 업데이트
|
||||
console.log('📞 Updating event details:', eventId);
|
||||
|
||||
// 이벤트명 가져오기 (contentEdit.title 또는 recommendation.title)
|
||||
const eventName = eventData.contentEdit?.title || eventData.recommendation?.recommendation?.title || '이벤트';
|
||||
|
||||
// 날짜 설정 (오늘부터 30일간)
|
||||
const today = new Date();
|
||||
const endDate = new Date(today);
|
||||
endDate.setDate(endDate.getDate() + 30);
|
||||
|
||||
const startDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const endDateStr = endDate.toISOString().split('T')[0];
|
||||
|
||||
await eventApi.updateEvent(eventId, {
|
||||
eventName: eventName,
|
||||
description: eventData.contentEdit?.guide || eventData.recommendation?.recommendation?.description || '',
|
||||
startDate: startDateStr,
|
||||
endDate: endDateStr,
|
||||
});
|
||||
console.log('✅ Event details updated');
|
||||
|
||||
// 3. 배포 채널 선택
|
||||
if (eventData.channels && eventData.channels.length > 0) {
|
||||
console.log('📞 Selecting channels:', eventData.channels);
|
||||
|
||||
// 채널명 매핑 (Frontend → Backend)
|
||||
const channelMap: Record<string, string> = {
|
||||
'uriTV': 'WEBSITE',
|
||||
'ringoBiz': 'EMAIL',
|
||||
'genieTV': 'KAKAO',
|
||||
'sns': 'INSTAGRAM',
|
||||
};
|
||||
|
||||
const backendChannels = eventData.channels.map(ch => channelMap[ch] || ch.toUpperCase());
|
||||
|
||||
await eventApi.selectChannels(eventId, {
|
||||
channels: backendChannels,
|
||||
});
|
||||
console.log('✅ Channels selected');
|
||||
}
|
||||
|
||||
// 4. TODO: 이미지 선택
|
||||
// 현재 frontend에서 selectedImageId를 추적하지 않음
|
||||
// 향후 contentPreview 단계에서 선택된 이미지 ID를 eventData에 저장 필요
|
||||
console.log('⚠️ Image selection skipped - imageId not tracked in frontend');
|
||||
|
||||
// 5. 이벤트 배포 API 호출
|
||||
console.log('📞 Publishing event:', eventId);
|
||||
const publishResponse = await eventApi.publishEvent(eventId);
|
||||
console.log('✅ Event published:', publishResponse);
|
||||
|
||||
// 성공 다이얼로그 표시
|
||||
setIsDeploying(false);
|
||||
setSuccessDialogOpen(true);
|
||||
} else {
|
||||
throw new Error('Event creation failed: No event ID returned');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('✅ Distribution completed:', data);
|
||||
|
||||
setIsDeploying(false);
|
||||
setSuccessDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('❌ Distribution failed:', error);
|
||||
console.error('❌ Event deployment failed:', error);
|
||||
setIsDeploying(false);
|
||||
alert(error instanceof Error ? error.message : '배포 중 오류가 발생했습니다');
|
||||
alert('이벤트 배포에 실패했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
@ -164,7 +207,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{eventData.recommendation?.title || '이벤트 제목'}
|
||||
{eventData.recommendation?.recommendation.title || '이벤트 제목'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -202,7 +245,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{eventData.recommendation?.expectedParticipants || 0}
|
||||
{eventData.recommendation?.recommendation.expectedMetrics.newCustomers.max || 0}
|
||||
<Typography component="span" sx={{
|
||||
fontSize: '1rem',
|
||||
ml: 0.5,
|
||||
@ -248,7 +291,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{((eventData.recommendation?.estimatedCost || 0) / 10000).toFixed(0)}
|
||||
{((eventData.recommendation?.recommendation.estimatedCost.max || 0) / 10000).toFixed(0)}
|
||||
<Typography component="span" sx={{
|
||||
fontSize: '1rem',
|
||||
ml: 0.5,
|
||||
@ -294,7 +337,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{eventData.recommendation?.roi || 0}%
|
||||
{eventData.recommendation?.recommendation.expectedMetrics.roi.max || 0}%
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -314,7 +357,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
이벤트 제목
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||
{eventData.recommendation?.title}
|
||||
{eventData.recommendation?.recommendation.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small">
|
||||
@ -332,7 +375,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
경품
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||
{eventData.recommendation?.prize}
|
||||
{eventData.recommendation?.recommendation.mechanics.details || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small">
|
||||
@ -350,7 +393,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
참여 방법
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||
{eventData.recommendation?.participationMethod}
|
||||
{eventData.recommendation?.recommendation.mechanics.details || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@ -67,13 +67,16 @@ const imageStyles: ImageStyle[] = [
|
||||
];
|
||||
|
||||
interface ContentPreviewStepProps {
|
||||
eventId?: string;
|
||||
eventTitle?: string;
|
||||
eventDescription?: string;
|
||||
onNext: (imageStyle: string, images: ImageInfo[]) => void;
|
||||
onSkip: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface EventCreationData {
|
||||
eventDraftId: string; // Changed from number to string
|
||||
eventDraftId: string;
|
||||
eventTitle: string;
|
||||
eventDescription: string;
|
||||
industry: string;
|
||||
@ -83,6 +86,9 @@ interface EventCreationData {
|
||||
}
|
||||
|
||||
export default function ContentPreviewStep({
|
||||
eventId: propsEventId,
|
||||
eventTitle: propsEventTitle,
|
||||
eventDescription: propsEventDescription,
|
||||
onNext,
|
||||
onSkip,
|
||||
onBack,
|
||||
@ -112,25 +118,35 @@ export default function ContentPreviewStep({
|
||||
handleGenerateImagesAuto(data);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Mock 데이터가 없으면 자동으로 설정
|
||||
const mockData: EventCreationData = {
|
||||
eventDraftId: "1761634317010", // Changed to string
|
||||
eventTitle: "맥주 파티 이벤트",
|
||||
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
|
||||
industry: "음식점",
|
||||
location: "강남",
|
||||
trends: ["파티", "맥주", "생맥주"],
|
||||
prize: "생맥주 1잔"
|
||||
} else if (propsEventId) {
|
||||
// Props에서 받은 이벤트 데이터 사용 (localStorage 없을 때만)
|
||||
console.log('✅ Using event data from props:', propsEventId);
|
||||
const data: EventCreationData = {
|
||||
eventDraftId: propsEventId,
|
||||
eventTitle: propsEventTitle || '',
|
||||
eventDescription: propsEventDescription || '',
|
||||
industry: '',
|
||||
location: '',
|
||||
trends: [],
|
||||
prize: '',
|
||||
};
|
||||
setEventData(data);
|
||||
|
||||
console.log('⚠️ localStorage에 이벤트 데이터가 없습니다. Mock 데이터를 사용합니다.');
|
||||
localStorage.setItem('eventCreationData', JSON.stringify(mockData));
|
||||
setEventData(mockData);
|
||||
loadImages(mockData);
|
||||
// 이미지 조회 시도
|
||||
loadImages(data).then((hasImages) => {
|
||||
if (!hasImages) {
|
||||
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
|
||||
handleGenerateImagesAuto(data);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 이벤트 데이터가 없으면 에러 표시
|
||||
console.error('❌ No event data available. Cannot proceed.');
|
||||
setError('이벤트 정보를 찾을 수 없습니다. 이전 단계로 돌아가 주세요.');
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [propsEventId, propsEventTitle, propsEventDescription]);
|
||||
|
||||
const loadImages = async (data: EventCreationData): Promise<boolean> => {
|
||||
try {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@ -13,11 +15,12 @@ import {
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
|
||||
import { EventObjective, BudgetLevel, EventMethod } from '../page';
|
||||
import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api';
|
||||
|
||||
// 디자인 시스템 색상
|
||||
const colors = {
|
||||
@ -37,130 +40,288 @@ const colors = {
|
||||
},
|
||||
};
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
budget: BudgetLevel;
|
||||
method: EventMethod;
|
||||
title: string;
|
||||
prize: string;
|
||||
participationMethod: string;
|
||||
expectedParticipants: number;
|
||||
estimatedCost: number;
|
||||
roi: number;
|
||||
}
|
||||
|
||||
// Mock 추천 데이터
|
||||
const mockRecommendations: Recommendation[] = [
|
||||
// 저비용
|
||||
{
|
||||
id: 'low-online',
|
||||
budget: 'low',
|
||||
method: 'online',
|
||||
title: 'SNS 팔로우 이벤트',
|
||||
prize: '커피 쿠폰',
|
||||
participationMethod: 'SNS 팔로우',
|
||||
expectedParticipants: 180,
|
||||
estimatedCost: 250000,
|
||||
roi: 520,
|
||||
},
|
||||
{
|
||||
id: 'low-offline',
|
||||
budget: 'low',
|
||||
method: 'offline',
|
||||
title: '전화번호 등록 이벤트',
|
||||
prize: '커피 쿠폰',
|
||||
participationMethod: '방문 시 전화번호 등록',
|
||||
expectedParticipants: 120,
|
||||
estimatedCost: 300000,
|
||||
roi: 380,
|
||||
},
|
||||
// 중비용
|
||||
{
|
||||
id: 'medium-online',
|
||||
budget: 'medium',
|
||||
method: 'online',
|
||||
title: '리뷰 작성 이벤트',
|
||||
prize: '상품권 5만원',
|
||||
participationMethod: '네이버 리뷰 작성',
|
||||
expectedParticipants: 250,
|
||||
estimatedCost: 800000,
|
||||
roi: 450,
|
||||
},
|
||||
{
|
||||
id: 'medium-offline',
|
||||
budget: 'medium',
|
||||
method: 'offline',
|
||||
title: '스탬프 적립 이벤트',
|
||||
prize: '상품권 5만원',
|
||||
participationMethod: '3회 방문 시 스탬프',
|
||||
expectedParticipants: 200,
|
||||
estimatedCost: 1000000,
|
||||
roi: 380,
|
||||
},
|
||||
// 고비용
|
||||
{
|
||||
id: 'high-online',
|
||||
budget: 'high',
|
||||
method: 'online',
|
||||
title: '인플루언서 협업 이벤트',
|
||||
prize: '애플 에어팟',
|
||||
participationMethod: '게시물 공유 및 댓글',
|
||||
expectedParticipants: 500,
|
||||
estimatedCost: 2000000,
|
||||
roi: 380,
|
||||
},
|
||||
{
|
||||
id: 'high-offline',
|
||||
budget: 'high',
|
||||
method: 'offline',
|
||||
title: 'VIP 고객 초대 이벤트',
|
||||
prize: '애플 에어팟',
|
||||
participationMethod: '누적 10회 방문',
|
||||
expectedParticipants: 300,
|
||||
estimatedCost: 2500000,
|
||||
roi: 320,
|
||||
},
|
||||
];
|
||||
|
||||
interface RecommendationStepProps {
|
||||
objective?: EventObjective;
|
||||
onNext: (data: Recommendation) => void;
|
||||
eventId?: string; // 이전 단계에서 생성된 eventId
|
||||
onNext: (data: {
|
||||
recommendation: EventRecommendation;
|
||||
eventId: string;
|
||||
}) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function RecommendationStep({ onNext, onBack }: RecommendationStepProps) {
|
||||
const [selectedBudget, setSelectedBudget] = useState<BudgetLevel>('low');
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [editedData, setEditedData] = useState<Record<string, { title: string; prize: string }>>({});
|
||||
export default function RecommendationStep({
|
||||
objective,
|
||||
eventId: initialEventId,
|
||||
onNext,
|
||||
onBack
|
||||
}: RecommendationStepProps) {
|
||||
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const budgetRecommendations = mockRecommendations.filter((r) => r.budget === selectedBudget);
|
||||
const [aiResult, setAiResult] = useState<AIRecommendationResult | null>(null);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
|
||||
|
||||
const handleNext = () => {
|
||||
const selectedRec = mockRecommendations.find((r) => r.id === selected);
|
||||
if (selectedRec && selected) {
|
||||
const edited = editedData[selected];
|
||||
onNext({
|
||||
...selectedRec,
|
||||
title: edited?.title || selectedRec.title,
|
||||
prize: edited?.prize || selectedRec.prize,
|
||||
});
|
||||
// 컴포넌트 마운트 시 AI 추천 요청
|
||||
useEffect(() => {
|
||||
if (!eventId && objective) {
|
||||
// Step 1: 이벤트 생성
|
||||
createEventAndRequestAI();
|
||||
} else if (eventId) {
|
||||
// 이미 eventId가 있으면 AI 추천 요청
|
||||
requestAIRecommendations(eventId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createEventAndRequestAI = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Step 1: 이벤트 목적 선택 및 생성
|
||||
const eventResponse = await eventApi.selectObjective(objective || '신규 고객 유치');
|
||||
const newEventId = eventResponse.eventId;
|
||||
setEventId(newEventId);
|
||||
|
||||
// Step 2: AI 추천 요청
|
||||
await requestAIRecommendations(newEventId);
|
||||
} catch (err: any) {
|
||||
console.error('이벤트 생성 실패:', err);
|
||||
setError(err.response?.data?.message || err.message || '이벤트 생성에 실패했습니다');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTitle = (id: string, title: string) => {
|
||||
const requestAIRecommendations = async (evtId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 사용자 정보에서 매장 정보 가져오기
|
||||
const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}');
|
||||
const storeInfo = {
|
||||
storeId: userProfile.storeId || '1',
|
||||
storeName: userProfile.storeName || '내 매장',
|
||||
category: userProfile.industry || '음식점',
|
||||
description: userProfile.businessHours || '',
|
||||
};
|
||||
|
||||
// AI 추천 요청
|
||||
const jobResponse = await eventApi.requestAiRecommendations(evtId, storeInfo);
|
||||
setJobId(jobResponse.jobId);
|
||||
|
||||
// Job 폴링 시작
|
||||
pollJobStatus(jobResponse.jobId, evtId);
|
||||
} catch (err: any) {
|
||||
console.error('AI 추천 요청 실패:', err);
|
||||
setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollJobStatus = async (jId: string, evtId: string) => {
|
||||
setPolling(true);
|
||||
const maxAttempts = 60; // 최대 5분 (5초 간격)
|
||||
let attempts = 0;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await eventApi.getJobStatus(jId);
|
||||
console.log('Job 상태:', status);
|
||||
|
||||
if (status.status === 'COMPLETED') {
|
||||
// AI 추천 결과 조회
|
||||
const recommendations = await aiApi.getRecommendations(evtId);
|
||||
setAiResult(recommendations);
|
||||
setLoading(false);
|
||||
setPolling(false);
|
||||
return;
|
||||
} else if (status.status === 'FAILED') {
|
||||
setError(status.errorMessage || 'AI 추천 생성에 실패했습니다');
|
||||
setLoading(false);
|
||||
setPolling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 계속 폴링
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000); // 5초 후 재시도
|
||||
} else {
|
||||
setError('AI 추천 생성 시간이 초과되었습니다');
|
||||
setLoading(false);
|
||||
setPolling(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Job 상태 조회 실패:', err);
|
||||
setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다');
|
||||
setLoading(false);
|
||||
setPolling(false);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (selected === null || !aiResult || !eventId) return;
|
||||
|
||||
const selectedRec = aiResult.recommendations[selected - 1];
|
||||
const edited = editedData[selected];
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// AI 추천 선택 API 호출
|
||||
await eventApi.selectRecommendation(eventId, {
|
||||
recommendationId: `${eventId}-opt${selected}`,
|
||||
customizations: {
|
||||
eventName: edited?.title || selectedRec.title,
|
||||
description: edited?.description || selectedRec.description,
|
||||
},
|
||||
});
|
||||
|
||||
// 다음 단계로 이동
|
||||
onNext({
|
||||
recommendation: {
|
||||
...selectedRec,
|
||||
title: edited?.title || selectedRec.title,
|
||||
description: edited?.description || selectedRec.description,
|
||||
},
|
||||
eventId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('추천 선택 실패:', err);
|
||||
setError(err.response?.data?.message || '추천 선택에 실패했습니다');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTitle = (optionNumber: number, title: string) => {
|
||||
setEditedData((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], title },
|
||||
[optionNumber]: {
|
||||
...prev[optionNumber],
|
||||
title
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditPrize = (id: string, prize: string) => {
|
||||
const handleEditDescription = (optionNumber: number, description: string) => {
|
||||
setEditedData((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], prize },
|
||||
[optionNumber]: {
|
||||
...prev[optionNumber],
|
||||
description
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// 로딩 상태 표시
|
||||
if (loading || polling) {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
||||
AI 이벤트 추천
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, py: 12 }}>
|
||||
<CircularProgress size={60} sx={{ color: colors.purple }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.25rem' }}>
|
||||
AI가 최적의 이벤트를 생성하고 있습니다...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태 표시
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
||||
AI 이벤트 추천
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert severity="error" sx={{ mb: 4 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={onBack}
|
||||
sx={{
|
||||
py: 3,
|
||||
borderRadius: 3,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
if (eventId) {
|
||||
requestAIRecommendations(eventId);
|
||||
} else {
|
||||
createEventAndRequestAI();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
py: 3,
|
||||
borderRadius: 3,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 결과가 없으면 로딩 표시
|
||||
if (!aiResult) {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
@ -195,158 +356,159 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||
📍 업종 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
음식점업 신년 프로모션 트렌드
|
||||
</Typography>
|
||||
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
||||
• {trend.description}
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||
🗺️ 지역 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
강남구 음식점 할인 이벤트 증가
|
||||
</Typography>
|
||||
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
||||
• {trend.description}
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||
☀️ 시즌 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
설 연휴 특수 대비 고객 유치 전략
|
||||
</Typography>
|
||||
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
||||
• {trend.description}
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Budget Selection */}
|
||||
{/* AI Recommendations */}
|
||||
<Box sx={{ mb: 8 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
||||
예산별 추천 이벤트
|
||||
AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션)
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
||||
각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다
|
||||
각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수 있습니다.
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={selectedBudget}
|
||||
onChange={(_, value) => setSelectedBudget(value)}
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 8 }}
|
||||
>
|
||||
<Tab
|
||||
label="💰 저비용"
|
||||
value="low"
|
||||
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
|
||||
/>
|
||||
<Tab
|
||||
label="💰💰 중비용"
|
||||
value="medium"
|
||||
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
|
||||
/>
|
||||
<Tab
|
||||
label="💰💰💰 고비용"
|
||||
value="high"
|
||||
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Recommendations */}
|
||||
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
|
||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
||||
{budgetRecommendations.map((rec) => (
|
||||
<Grid item xs={12} md={6} key={rec.id}>
|
||||
{aiResult.recommendations.map((rec) => (
|
||||
<Grid item xs={12} key={rec.optionNumber}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
border: selected === rec.id ? 2 : 1,
|
||||
borderColor: selected === rec.id ? colors.purple : 'divider',
|
||||
bgcolor: selected === rec.id ? `${colors.purpleLight}40` : 'background.paper',
|
||||
border: selected === rec.optionNumber ? 2 : 1,
|
||||
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
|
||||
bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: selected === rec.id ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
boxShadow: selected === rec.optionNumber ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'&:hover': {
|
||||
borderColor: colors.purple,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
}}
|
||||
onClick={() => setSelected(rec.id)}
|
||||
onClick={() => setSelected(rec.optionNumber)}
|
||||
>
|
||||
<CardContent sx={{ p: 6 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
|
||||
<Chip
|
||||
label={rec.method === 'online' ? '🌐 온라인' : '🏪 오프라인'}
|
||||
color={rec.method === 'online' ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Chip
|
||||
label={`옵션 ${rec.optionNumber}`}
|
||||
color="primary"
|
||||
size="medium"
|
||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.concept}
|
||||
variant="outlined"
|
||||
size="medium"
|
||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
value={rec.optionNumber}
|
||||
control={<Radio />}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
<FormControlLabel value={rec.id} control={<Radio />} label="" sx={{ m: 0 }} />
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editedData[rec.id]?.title || rec.title}
|
||||
onChange={(e) => handleEditTitle(rec.id, e.target.value)}
|
||||
value={editedData[rec.optionNumber]?.title || rec.title}
|
||||
onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{ mb: 4 }}
|
||||
InputProps={{
|
||||
endAdornment: <Edit fontSize="small" color="action" />,
|
||||
sx: { fontSize: '1rem', py: 2 },
|
||||
sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block', fontSize: '0.875rem' }}>
|
||||
경품
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="medium"
|
||||
variant="outlined"
|
||||
value={editedData[rec.id]?.prize || rec.prize}
|
||||
onChange={(e) => handleEditPrize(rec.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
InputProps={{
|
||||
endAdornment: <Edit fontSize="small" color="action" />,
|
||||
sx: { fontSize: '1rem' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
variant="outlined"
|
||||
value={editedData[rec.optionNumber]?.description || rec.description}
|
||||
onChange={(e) => handleEditDescription(rec.optionNumber, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{ mb: 4 }}
|
||||
InputProps={{
|
||||
sx: { fontSize: '1rem' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Grid container spacing={4} sx={{ mt: 4 }}>
|
||||
<Grid item xs={6}>
|
||||
<Grid container spacing={4} sx={{ mt: 2 }}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
참여 방법
|
||||
타겟 고객
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{rec.participationMethod}
|
||||
{rec.targetAudience}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
예상 참여
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{rec.expectedParticipants}명
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
예상 비용
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{(rec.estimatedCost / 10000).toFixed(0)}만원
|
||||
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}만원
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
투자대비수익률
|
||||
예상 신규 고객
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}명
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
ROI
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
|
||||
{rec.roi}%
|
||||
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
차별점
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
|
||||
{rec.differentiator}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@ -381,7 +543,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!selected}
|
||||
disabled={selected === null || loading}
|
||||
onClick={handleNext}
|
||||
sx={{
|
||||
py: 3,
|
||||
@ -398,7 +560,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
|
||||
},
|
||||
}}
|
||||
>
|
||||
다음
|
||||
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
@ -37,78 +37,12 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import Header from '@/shared/ui/Header';
|
||||
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
||||
import { useEvents } from '@/entities/event/model/useEvents';
|
||||
import type { EventStatus as ApiEventStatus } from '@/entities/event/model/types';
|
||||
|
||||
// Mock 데이터
|
||||
const mockEvents = [
|
||||
{
|
||||
id: '1',
|
||||
title: '신규고객 유치 이벤트',
|
||||
status: 'active' as const,
|
||||
daysLeft: 5,
|
||||
participants: 128,
|
||||
targetParticipants: 200,
|
||||
roi: 450,
|
||||
startDate: '2025-11-01',
|
||||
endDate: '2025-11-15',
|
||||
prize: '커피 쿠폰',
|
||||
method: '전화번호 입력',
|
||||
isUrgent: true,
|
||||
isPopular: false,
|
||||
isHighROI: true,
|
||||
isNew: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '재방문 유도 이벤트',
|
||||
status: 'active' as const,
|
||||
daysLeft: 12,
|
||||
participants: 56,
|
||||
targetParticipants: 100,
|
||||
roi: 320,
|
||||
startDate: '2025-11-05',
|
||||
endDate: '2025-11-20',
|
||||
prize: '할인 쿠폰',
|
||||
method: 'SNS 팔로우',
|
||||
isUrgent: false,
|
||||
isPopular: false,
|
||||
isHighROI: false,
|
||||
isNew: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '매출증대 프로모션',
|
||||
status: 'ended' as const,
|
||||
daysLeft: 0,
|
||||
participants: 234,
|
||||
targetParticipants: 150,
|
||||
roi: 580,
|
||||
startDate: '2025-10-15',
|
||||
endDate: '2025-10-31',
|
||||
prize: '상품권',
|
||||
method: '구매 인증',
|
||||
isUrgent: false,
|
||||
isPopular: true,
|
||||
isHighROI: true,
|
||||
isNew: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '봄맞이 특별 이벤트',
|
||||
status: 'scheduled' as const,
|
||||
daysLeft: 30,
|
||||
participants: 0,
|
||||
targetParticipants: 300,
|
||||
roi: 0,
|
||||
startDate: '2025-12-01',
|
||||
endDate: '2025-12-15',
|
||||
prize: '체험권',
|
||||
method: '이메일 등록',
|
||||
isUrgent: false,
|
||||
isPopular: false,
|
||||
isHighROI: false,
|
||||
isNew: true,
|
||||
},
|
||||
];
|
||||
// ==================== API 연동 ====================
|
||||
// Mock 데이터를 실제 API 호출로 교체
|
||||
// 백업 파일: page.tsx.backup
|
||||
|
||||
type EventStatus = 'all' | 'active' | 'scheduled' | 'ended';
|
||||
type Period = '1month' | '3months' | '6months' | '1year' | 'all';
|
||||
@ -123,8 +57,57 @@ export default function EventsPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// API 데이터 가져오기
|
||||
const { events: apiEvents, loading, error, pageInfo, refetch } = useEvents({
|
||||
page: currentPage - 1,
|
||||
size: itemsPerPage,
|
||||
sort: 'createdAt',
|
||||
order: 'desc'
|
||||
});
|
||||
|
||||
// API 상태를 UI 상태로 매핑
|
||||
const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => {
|
||||
switch (apiStatus) {
|
||||
case 'PUBLISHED':
|
||||
return 'active';
|
||||
case 'DRAFT':
|
||||
return 'scheduled';
|
||||
case 'ENDED':
|
||||
return 'ended';
|
||||
default:
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
// API 이벤트를 UI 형식으로 변환
|
||||
const transformedEvents = apiEvents.map(event => ({
|
||||
id: event.eventId,
|
||||
title: event.eventName || '제목 없음',
|
||||
status: mapApiStatus(event.status),
|
||||
startDate: event.startDate ? new Date(event.startDate).toLocaleDateString('ko-KR') : '-',
|
||||
endDate: event.endDate ? new Date(event.endDate).toLocaleDateString('ko-KR') : '-',
|
||||
prize: event.aiRecommendations[0]?.reward || '경품 정보 없음',
|
||||
method: event.aiRecommendations[0]?.participationMethod || '참여 방법 없음',
|
||||
participants: event.participants || 0,
|
||||
targetParticipants: event.targetParticipants || 0,
|
||||
roi: event.roi || 0,
|
||||
daysLeft: event.endDate
|
||||
? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: 0,
|
||||
isUrgent: event.endDate
|
||||
? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) <= 3
|
||||
: false,
|
||||
isPopular: event.participants && event.targetParticipants
|
||||
? (event.participants / event.targetParticipants) >= 0.8
|
||||
: false,
|
||||
isHighROI: event.roi ? event.roi >= 300 : false,
|
||||
isNew: event.createdAt
|
||||
? (Date.now() - new Date(event.createdAt).getTime()) < (7 * 24 * 60 * 60 * 1000)
|
||||
: false,
|
||||
}));
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredEvents = mockEvents
|
||||
const filteredEvents = transformedEvents
|
||||
.filter((event) => {
|
||||
const matchesSearch = event.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
|
||||
@ -204,22 +187,26 @@ export default function EventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const calculateProgress = (event: (typeof mockEvents)[0]) => {
|
||||
const calculateProgress = (event: typeof transformedEvents[0]) => {
|
||||
if (event.status !== 'active') return 0;
|
||||
const total = new Date(event.endDate).getTime() - new Date(event.startDate).getTime();
|
||||
const elapsed = Date.now() - new Date(event.startDate).getTime();
|
||||
const startTime = new Date(event.startDate).getTime();
|
||||
const endTime = new Date(event.endDate).getTime();
|
||||
const total = endTime - startTime;
|
||||
const elapsed = Date.now() - startTime;
|
||||
return Math.min(Math.max((elapsed / total) * 100, 0), 100);
|
||||
};
|
||||
|
||||
// 통계 계산
|
||||
const stats = {
|
||||
total: mockEvents.length,
|
||||
active: mockEvents.filter((e) => e.status === 'active').length,
|
||||
totalParticipants: mockEvents.reduce((sum, e) => sum + e.participants, 0),
|
||||
avgROI: Math.round(
|
||||
mockEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) /
|
||||
mockEvents.filter((e) => e.roi > 0).length
|
||||
),
|
||||
total: transformedEvents.length,
|
||||
active: transformedEvents.filter((e) => e.status === 'active').length,
|
||||
totalParticipants: transformedEvents.reduce((sum, e) => sum + e.participants, 0),
|
||||
avgROI: transformedEvents.filter((e) => e.roi > 0).length > 0
|
||||
? Math.round(
|
||||
transformedEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) /
|
||||
transformedEvents.filter((e) => e.roi > 0).length
|
||||
)
|
||||
: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
@ -237,6 +224,59 @@ export default function EventsPage() {
|
||||
maxWidth="lg"
|
||||
sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 6 }, px: { xs: 3, sm: 6, md: 10 } }}
|
||||
>
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<LinearProgress sx={{ borderRadius: 1 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 2,
|
||||
textAlign: 'center',
|
||||
color: colors.gray[600],
|
||||
fontSize: { xs: '0.875rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
이벤트 목록을 불러오는 중...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 4, bgcolor: '#FEE2E2' }}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Warning sx={{ fontSize: 48, color: '#DC2626', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ mb: 1, color: '#991B1B', fontSize: { xs: '1rem', sm: '1.25rem' } }}
|
||||
>
|
||||
이벤트 목록을 불러오는데 실패했습니다
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#7F1D1D', mb: 2 }}>
|
||||
{error.message}
|
||||
</Typography>
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => refetch()}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
border: 'none',
|
||||
bgcolor: '#DC2626',
|
||||
color: 'white',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#B91C1C' },
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary Statistics */}
|
||||
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mb: { xs: 4, sm: 8 } }}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
|
||||
846
src/app/(main)/test/analytics/[eventId]/page.tsx
Normal file
846
src/app/(main)/test/analytics/[eventId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/app/api/v1/events/objectives/route.ts
Normal file
63
src/app/api/v1/events/objectives/route.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Mock API: 이벤트 목적 선택 (Step 1)
|
||||
* 백엔드 API가 준비될 때까지 사용하는 임시 Mock API
|
||||
*
|
||||
* POST /api/v1/events/objectives
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { objective } = body;
|
||||
|
||||
// 백엔드 API 호출 시도
|
||||
const backendUrl = 'http://localhost:8080/api/events/objectives';
|
||||
|
||||
try {
|
||||
const backendResponse = await fetch(backendUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': request.headers.get('Authorization') || '',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// 백엔드가 정상 응답하면 그대로 반환
|
||||
if (backendResponse.ok) {
|
||||
const data = await backendResponse.json();
|
||||
return NextResponse.json(data, { status: backendResponse.status });
|
||||
}
|
||||
} catch (backendError) {
|
||||
console.warn('⚠️ 백엔드 API 호출 실패, Mock 데이터 반환:', backendError);
|
||||
}
|
||||
|
||||
// 백엔드 실패 시 Mock 데이터 반환
|
||||
// shared/api/eventApi의 selectObjective가 반환하는 형식과 일치
|
||||
const mockEventId = `evt_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
const mockResponse = {
|
||||
eventId: mockEventId,
|
||||
objective: objective,
|
||||
status: 'DRAFT' as const,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log('🎭 Mock API Response:', mockResponse);
|
||||
|
||||
return NextResponse.json(mockResponse, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('❌ Mock API Error:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
errorCode: 'MOCK_ERROR',
|
||||
message: 'Mock API 오류가 발생했습니다',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
src/entities/analytics/api/analyticsApi.ts
Normal file
142
src/entities/analytics/api/analyticsApi.ts
Normal 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;
|
||||
67
src/entities/analytics/api/analyticsClient.ts
Normal file
67
src/entities/analytics/api/analyticsClient.ts
Normal 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;
|
||||
3
src/entities/analytics/api/index.ts
Normal file
3
src/entities/analytics/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { analyticsApi } from './analyticsApi';
|
||||
export { analyticsClient } from './analyticsClient';
|
||||
export * from '../model/types';
|
||||
2
src/entities/analytics/index.ts
Normal file
2
src/entities/analytics/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export * from './model/types';
|
||||
295
src/entities/analytics/model/types.ts
Normal file
295
src/entities/analytics/model/types.ts
Normal 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;
|
||||
}
|
||||
253
src/entities/event/api/eventApi.ts
Normal file
253
src/entities/event/api/eventApi.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import { apiClient } from '@/shared/api';
|
||||
import type {
|
||||
GetEventsRequest,
|
||||
GetEventsResponse,
|
||||
EventDetail,
|
||||
ApiResponse,
|
||||
SelectObjectiveRequest,
|
||||
EventCreatedResponse,
|
||||
AiRecommendationRequest,
|
||||
JobAcceptedResponse,
|
||||
ImageGenerationRequest,
|
||||
ImageGenerationResponse,
|
||||
UpdateEventRequest,
|
||||
SelectChannelsRequest,
|
||||
SelectImageRequest,
|
||||
} from '../model/types';
|
||||
|
||||
/**
|
||||
* Event API 기본 경로
|
||||
*
|
||||
* 참고: apiClient는 기본적으로 user-service(8081)를 가리키므로
|
||||
* 별도의 event API 클라이언트를 사용하는 것이 좋습니다.
|
||||
*
|
||||
* 현재는 apiClient를 사용하되, baseURL을 오버라이드합니다.
|
||||
*/
|
||||
const EVENT_API_BASE = '/api/v1/events';
|
||||
const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* Event Service용 API 클라이언트
|
||||
* Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성
|
||||
*
|
||||
* 로컬 개발 환경: Next.js rewrites 프록시 사용 (CORS 회피)
|
||||
* 프로덕션 환경: 환경 변수에서 직접 호스트 사용
|
||||
*/
|
||||
import axios from 'axios';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = isProduction ? EVENT_HOST : ''; // 개발 환경에서는 상대 경로 사용
|
||||
|
||||
const eventApiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - JWT 토큰 추가
|
||||
eventApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log('🚀 Event 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 Event API request');
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Event API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor - 에러 처리
|
||||
eventApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log('✅ Event API Response:', {
|
||||
status: response.status,
|
||||
url: response.config.url,
|
||||
data: response.data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Event 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);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Event API Service
|
||||
* 이벤트 관리 API
|
||||
*/
|
||||
export const eventApi = {
|
||||
/**
|
||||
* 이벤트 목록 조회
|
||||
*/
|
||||
getEvents: async (params?: GetEventsRequest): Promise<GetEventsResponse> => {
|
||||
console.log('📞 eventApi.getEvents 호출', params);
|
||||
const response = await eventApiClient.get<GetEventsResponse>(EVENT_API_BASE, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이벤트 상세 조회
|
||||
*/
|
||||
getEvent: async (eventId: string): Promise<ApiResponse<EventDetail>> => {
|
||||
console.log('📞 eventApi.getEvent 호출', eventId);
|
||||
const response = await eventApiClient.get<ApiResponse<EventDetail>>(
|
||||
`${EVENT_API_BASE}/${eventId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이벤트 생성 (목적 선택)
|
||||
*/
|
||||
createEvent: async (
|
||||
data: SelectObjectiveRequest
|
||||
): Promise<ApiResponse<EventCreatedResponse>> => {
|
||||
console.log('📞 eventApi.createEvent 호출', data);
|
||||
const response = await eventApiClient.post<ApiResponse<EventCreatedResponse>>(
|
||||
`${EVENT_API_BASE}/objectives`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이벤트 삭제
|
||||
*/
|
||||
deleteEvent: async (eventId: string): Promise<ApiResponse<void>> => {
|
||||
console.log('📞 eventApi.deleteEvent 호출', eventId);
|
||||
const response = await eventApiClient.delete<ApiResponse<void>>(
|
||||
`${EVENT_API_BASE}/${eventId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이벤트 배포
|
||||
*/
|
||||
publishEvent: async (eventId: string): Promise<ApiResponse<void>> => {
|
||||
console.log('📞 eventApi.publishEvent 호출', eventId);
|
||||
const response = await eventApiClient.post<ApiResponse<void>>(
|
||||
`${EVENT_API_BASE}/${eventId}/publish`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이벤트 종료
|
||||
*/
|
||||
endEvent: async (eventId: string): Promise<ApiResponse<void>> => {
|
||||
console.log('📞 eventApi.endEvent 호출', eventId);
|
||||
const response = await eventApiClient.post<ApiResponse<void>>(
|
||||
`${EVENT_API_BASE}/${eventId}/end`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* AI 추천 요청
|
||||
*/
|
||||
requestAiRecommendations: async (
|
||||
eventId: string,
|
||||
data: AiRecommendationRequest
|
||||
): Promise<ApiResponse<JobAcceptedResponse>> => {
|
||||
console.log('📞 eventApi.requestAiRecommendations 호출', eventId, data);
|
||||
const response = await eventApiClient.post<ApiResponse<JobAcceptedResponse>>(
|
||||
`${EVENT_API_BASE}/${eventId}/ai-recommendations`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 생성 요청
|
||||
*/
|
||||
requestImageGeneration: async (
|
||||
eventId: string,
|
||||
data: ImageGenerationRequest
|
||||
): Promise<ApiResponse<ImageGenerationResponse>> => {
|
||||
console.log('📞 eventApi.requestImageGeneration 호출', eventId, data);
|
||||
const response = await eventApiClient.post<ApiResponse<ImageGenerationResponse>>(
|
||||
`${EVENT_API_BASE}/${eventId}/images`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이벤트 수정
|
||||
*/
|
||||
updateEvent: async (
|
||||
eventId: string,
|
||||
data: UpdateEventRequest
|
||||
): Promise<ApiResponse<EventDetail>> => {
|
||||
console.log('📞 eventApi.updateEvent 호출', eventId, data);
|
||||
const response = await eventApiClient.put<ApiResponse<EventDetail>>(
|
||||
`${EVENT_API_BASE}/${eventId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 배포 채널 선택
|
||||
*/
|
||||
selectChannels: async (
|
||||
eventId: string,
|
||||
data: SelectChannelsRequest
|
||||
): Promise<ApiResponse<void>> => {
|
||||
console.log('📞 eventApi.selectChannels 호출', eventId, data);
|
||||
const response = await eventApiClient.put<ApiResponse<void>>(
|
||||
`${EVENT_API_BASE}/${eventId}/channels`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 선택
|
||||
*/
|
||||
selectImage: async (
|
||||
eventId: string,
|
||||
imageId: string,
|
||||
data: SelectImageRequest
|
||||
): Promise<ApiResponse<void>> => {
|
||||
console.log('📞 eventApi.selectImage 호출', eventId, imageId, data);
|
||||
const response = await eventApiClient.put<ApiResponse<void>>(
|
||||
`${EVENT_API_BASE}/${eventId}/images/${imageId}/select`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default eventApi;
|
||||
198
src/entities/event/model/types.ts
Normal file
198
src/entities/event/model/types.ts
Normal file
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Event 도메인 타입 정의
|
||||
* Event Service API 응답 형식과 일치
|
||||
*/
|
||||
|
||||
/**
|
||||
* 이벤트 상태
|
||||
*/
|
||||
export type EventStatus = 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||
|
||||
/**
|
||||
* 이벤트 목적
|
||||
*/
|
||||
export type EventObjective =
|
||||
| 'CUSTOMER_ACQUISITION'
|
||||
| 'Sales Promotion'
|
||||
| 'Customer Retention'
|
||||
| 'New Customer Acquisition'
|
||||
| 'awareness'
|
||||
| 'sales'
|
||||
| 'new_customer';
|
||||
|
||||
/**
|
||||
* 배포 채널
|
||||
*/
|
||||
export type DistributionChannel = 'SMS' | 'EMAIL' | 'KAKAO' | 'PUSH';
|
||||
|
||||
/**
|
||||
* 이벤트 이미지
|
||||
*/
|
||||
export interface EventImage {
|
||||
imageId: string;
|
||||
imageUrl: string;
|
||||
prompt?: string;
|
||||
isSelected: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 추천
|
||||
*/
|
||||
export interface AiRecommendation {
|
||||
recommendationId: string;
|
||||
eventName: string;
|
||||
description: string;
|
||||
reward: string;
|
||||
participationMethod: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
targetParticipants: number;
|
||||
isSelected: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 상세 정보
|
||||
*/
|
||||
export interface EventDetail {
|
||||
eventId: string;
|
||||
userId: string;
|
||||
storeId: string;
|
||||
eventName: string;
|
||||
description: string | null;
|
||||
objective: EventObjective;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
status: EventStatus;
|
||||
selectedImageId: string | null;
|
||||
selectedImageUrl: string | null;
|
||||
participants: number | null;
|
||||
targetParticipants: number | null;
|
||||
roi: number | null;
|
||||
generatedImages: EventImage[];
|
||||
aiRecommendations: AiRecommendation[];
|
||||
channels: DistributionChannel[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 응답
|
||||
*/
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 표준 응답
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 목록 조회 요청
|
||||
*/
|
||||
export interface GetEventsRequest {
|
||||
status?: EventStatus;
|
||||
search?: string;
|
||||
objective?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 목록 조회 응답
|
||||
*/
|
||||
export type GetEventsResponse = ApiResponse<PageResponse<EventDetail>>;
|
||||
|
||||
/**
|
||||
* 이벤트 목적 선택 요청
|
||||
*/
|
||||
export interface SelectObjectiveRequest {
|
||||
objective: EventObjective;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 생성 응답
|
||||
*/
|
||||
export interface EventCreatedResponse {
|
||||
eventId: string;
|
||||
objective: EventObjective;
|
||||
status: EventStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 추천 요청
|
||||
*/
|
||||
export interface AiRecommendationRequest {
|
||||
storeCategory?: string;
|
||||
targetAudience?: string;
|
||||
budget?: number;
|
||||
additionalInfo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Job 수락 응답
|
||||
*/
|
||||
export interface JobAcceptedResponse {
|
||||
jobId: string;
|
||||
eventId: string;
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
estimatedCompletionTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 생성 요청
|
||||
*/
|
||||
export interface ImageGenerationRequest {
|
||||
prompt: string;
|
||||
numberOfImages?: number;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 생성 응답
|
||||
*/
|
||||
export interface ImageGenerationResponse {
|
||||
jobId: string;
|
||||
eventId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 수정 요청
|
||||
*/
|
||||
export interface UpdateEventRequest {
|
||||
eventName?: string;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
discountRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배포 채널 선택 요청
|
||||
*/
|
||||
export interface SelectChannelsRequest {
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 선택 요청
|
||||
*/
|
||||
export interface SelectImageRequest {
|
||||
selectedImageId: string;
|
||||
}
|
||||
200
src/entities/event/model/useEvents.ts
Normal file
200
src/entities/event/model/useEvents.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { eventApi } from '../api/eventApi';
|
||||
import type {
|
||||
EventDetail,
|
||||
GetEventsRequest,
|
||||
EventStatus,
|
||||
PageResponse,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* useEvents Hook
|
||||
* 이벤트 목록 조회 및 상태 관리
|
||||
*/
|
||||
export function useEvents(initialParams?: GetEventsRequest) {
|
||||
const [events, setEvents] = useState<EventDetail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [pageInfo, setPageInfo] = useState<Omit<PageResponse<EventDetail>, 'content'>>({
|
||||
page: 0,
|
||||
size: 20,
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
first: true,
|
||||
last: true,
|
||||
});
|
||||
|
||||
const fetchEvents = async (params?: GetEventsRequest) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('🔄 Fetching events with params:', params);
|
||||
|
||||
const response = await eventApi.getEvents(params);
|
||||
console.log('✅ Events fetched:', response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setEvents(response.data.content);
|
||||
setPageInfo({
|
||||
page: response.data.page,
|
||||
size: response.data.size,
|
||||
totalElements: response.data.totalElements,
|
||||
totalPages: response.data.totalPages,
|
||||
first: response.data.first,
|
||||
last: response.data.last,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Error fetching events:', err);
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
fetchEvents(initialParams);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
pageInfo,
|
||||
refetch: fetchEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useEvent Hook
|
||||
* 단일 이벤트 조회 및 상태 관리
|
||||
*/
|
||||
export function useEvent(eventId: string) {
|
||||
const [event, setEvent] = useState<EventDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchEvent = async () => {
|
||||
if (!eventId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('🔄 Fetching event:', eventId);
|
||||
|
||||
const response = await eventApi.getEvent(eventId);
|
||||
console.log('✅ Event fetched:', response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setEvent(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Error fetching event:', err);
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventId]);
|
||||
|
||||
return {
|
||||
event,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchEvent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useEventActions Hook
|
||||
* 이벤트 생성, 삭제, 배포 등의 액션 관리
|
||||
*/
|
||||
export function useEventActions() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const createEvent = async (objective: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('🔄 Creating event with objective:', objective);
|
||||
|
||||
const response = await eventApi.createEvent({ objective: objective as any });
|
||||
console.log('✅ Event created:', response);
|
||||
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
console.error('❌ Error creating event:', err);
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEvent = async (eventId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('🔄 Deleting event:', eventId);
|
||||
|
||||
await eventApi.deleteEvent(eventId);
|
||||
console.log('✅ Event deleted');
|
||||
} catch (err) {
|
||||
console.error('❌ Error deleting event:', err);
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publishEvent = async (eventId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('🔄 Publishing event:', eventId);
|
||||
|
||||
await eventApi.publishEvent(eventId);
|
||||
console.log('✅ Event published');
|
||||
} catch (err) {
|
||||
console.error('❌ Error publishing event:', err);
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const endEvent = async (eventId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log('🔄 Ending event:', eventId);
|
||||
|
||||
await eventApi.endEvent(eventId);
|
||||
console.log('✅ Event ended');
|
||||
} catch (err) {
|
||||
console.error('❌ Error ending event:', err);
|
||||
setError(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createEvent,
|
||||
deleteEvent,
|
||||
publishEvent,
|
||||
endEvent,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
178
src/shared/api/aiApi.ts
Normal file
178
src/shared/api/aiApi.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
// AI Service API 클라이언트
|
||||
const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083';
|
||||
|
||||
export const aiApiClient: AxiosInstance = axios.create({
|
||||
baseURL: AI_API_BASE_URL,
|
||||
timeout: 300000, // AI 생성은 최대 5분
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
aiApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log('🤖 AI API Request:', {
|
||||
method: config.method?.toUpperCase(),
|
||||
url: config.url,
|
||||
baseURL: config.baseURL,
|
||||
data: config.data,
|
||||
});
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ AI API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
aiApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log('✅ AI API Response:', {
|
||||
status: response.status,
|
||||
url: response.config.url,
|
||||
data: response.data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ AI API Error:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
url: error.config?.url,
|
||||
data: error.response?.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Types
|
||||
export interface TrendKeyword {
|
||||
keyword: string;
|
||||
relevance: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TrendAnalysis {
|
||||
industryTrends: TrendKeyword[];
|
||||
regionalTrends: TrendKeyword[];
|
||||
seasonalTrends: TrendKeyword[];
|
||||
}
|
||||
|
||||
export interface ExpectedMetrics {
|
||||
newCustomers: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
repeatVisits?: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
revenueIncrease: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
roi: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
socialEngagement?: {
|
||||
estimatedPosts: number;
|
||||
estimatedReach: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EventRecommendation {
|
||||
optionNumber: number;
|
||||
concept: string;
|
||||
title: string;
|
||||
description: string;
|
||||
targetAudience: string;
|
||||
duration: {
|
||||
recommendedDays: number;
|
||||
recommendedPeriod?: string;
|
||||
};
|
||||
mechanics: {
|
||||
type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO';
|
||||
details: string;
|
||||
};
|
||||
promotionChannels: string[];
|
||||
estimatedCost: {
|
||||
min: number;
|
||||
max: number;
|
||||
breakdown?: {
|
||||
material?: number;
|
||||
promotion?: number;
|
||||
discount?: number;
|
||||
};
|
||||
};
|
||||
expectedMetrics: ExpectedMetrics;
|
||||
differentiator: string;
|
||||
}
|
||||
|
||||
export interface AIRecommendationResult {
|
||||
eventId: string;
|
||||
trendAnalysis: TrendAnalysis;
|
||||
recommendations: EventRecommendation[];
|
||||
generatedAt: string;
|
||||
expiresAt: string;
|
||||
aiProvider: 'CLAUDE' | 'GPT4';
|
||||
}
|
||||
|
||||
export interface JobStatusResponse {
|
||||
jobId: string;
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
progress: number;
|
||||
message: string;
|
||||
eventId?: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
failedAt?: string;
|
||||
errorMessage?: string;
|
||||
retryCount?: number;
|
||||
processingTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface HealthCheckResponse {
|
||||
status: 'UP' | 'DOWN' | 'DEGRADED';
|
||||
timestamp: string;
|
||||
services: {
|
||||
kafka: 'UP' | 'DOWN';
|
||||
redis: 'UP' | 'DOWN';
|
||||
claude_api: 'UP' | 'DOWN' | 'CIRCUIT_OPEN';
|
||||
gpt4_api?: 'UP' | 'DOWN' | 'CIRCUIT_OPEN';
|
||||
circuit_breaker: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
||||
};
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const aiApi = {
|
||||
// 헬스체크
|
||||
healthCheck: async (): Promise<HealthCheckResponse> => {
|
||||
const response = await aiApiClient.get<HealthCheckResponse>('/health');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Job 상태 조회 (Internal API)
|
||||
getJobStatus: async (jobId: string): Promise<JobStatusResponse> => {
|
||||
const response = await aiApiClient.get<JobStatusResponse>(`/internal/jobs/${jobId}/status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// AI 추천 결과 조회 (Internal API)
|
||||
getRecommendations: async (eventId: string): Promise<AIRecommendationResult> => {
|
||||
const response = await aiApiClient.get<AIRecommendationResult>(`/internal/recommendations/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default aiApi;
|
||||
334
src/shared/api/eventApi.ts
Normal file
334
src/shared/api/eventApi.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
// Event Service API 클라이언트
|
||||
const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
|
||||
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
|
||||
|
||||
// 개발 환경에서는 상대 경로 사용 (Next.js rewrites 프록시 또는 Mock API 사용)
|
||||
// 프로덕션 환경에서는 환경 변수의 호스트 사용
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const BASE_URL = isProduction ? `${EVENT_API_BASE_URL}/api/${API_VERSION}` : `/api/${API_VERSION}`;
|
||||
|
||||
export const eventApiClient: AxiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 30000, // Job 폴링 고려
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
eventApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log('📅 Event API Request:', {
|
||||
method: config.method?.toUpperCase(),
|
||||
url: config.url,
|
||||
baseURL: config.baseURL,
|
||||
data: config.data,
|
||||
});
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Event API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
eventApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log('✅ Event API Response:', {
|
||||
status: response.status,
|
||||
url: response.config.url,
|
||||
data: response.data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Event API Error:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
url: error.config?.url,
|
||||
data: error.response?.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Types
|
||||
export interface EventObjectiveRequest {
|
||||
objective: string; // "신규 고객 유치", "재방문 유도", "매출 증대", "브랜드 인지도 향상"
|
||||
}
|
||||
|
||||
export interface EventCreatedResponse {
|
||||
eventId: string;
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||
objective: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiRecommendationRequest {
|
||||
storeInfo: {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface JobAcceptedResponse {
|
||||
jobId: string;
|
||||
status: 'PENDING';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface EventJobStatusResponse {
|
||||
jobId: string;
|
||||
jobType: 'AI_RECOMMENDATION' | 'IMAGE_GENERATION';
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
progress: number;
|
||||
resultKey?: string;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface SelectRecommendationRequest {
|
||||
recommendationId: string;
|
||||
customizations?: {
|
||||
eventName?: string;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
discountRate?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
eventInfo: {
|
||||
eventName: string;
|
||||
description: string;
|
||||
promotionType: string;
|
||||
};
|
||||
imageCount?: number;
|
||||
}
|
||||
|
||||
export interface SelectChannelsRequest {
|
||||
channels: ('WEBSITE' | 'KAKAO' | 'INSTAGRAM' | 'FACEBOOK' | 'NAVER_BLOG')[];
|
||||
}
|
||||
|
||||
export interface ChannelDistributionResult {
|
||||
channel: string;
|
||||
success: boolean;
|
||||
url?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface EventPublishedResponse {
|
||||
eventId: string;
|
||||
status: 'PUBLISHED';
|
||||
publishedAt: string;
|
||||
channels: string[];
|
||||
distributionResults: ChannelDistributionResult[];
|
||||
}
|
||||
|
||||
export interface EventSummary {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
objective: string;
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
thumbnailUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface EventListResponse {
|
||||
content: EventSummary[];
|
||||
page: PageInfo;
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
imageId: string;
|
||||
imageUrl: string;
|
||||
isSelected: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiRecommendation {
|
||||
recommendationId: string;
|
||||
eventName: string;
|
||||
description: string;
|
||||
promotionType: string;
|
||||
targetAudience: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export interface EventDetailResponse {
|
||||
eventId: string;
|
||||
userId: string;
|
||||
storeId: string;
|
||||
eventName: string;
|
||||
objective: string;
|
||||
description: string;
|
||||
targetAudience: string;
|
||||
promotionType: string;
|
||||
discountRate?: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||
selectedImageId?: string;
|
||||
selectedImageUrl?: string;
|
||||
generatedImages?: GeneratedImage[];
|
||||
channels?: string[];
|
||||
aiRecommendations?: AiRecommendation[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateEventRequest {
|
||||
eventName?: string;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
discountRate?: number;
|
||||
}
|
||||
|
||||
export interface EndEventRequest {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const eventApi = {
|
||||
// Step 1: 목적 선택 및 이벤트 생성
|
||||
selectObjective: async (objective: string): Promise<EventCreatedResponse> => {
|
||||
const response = await eventApiClient.post<EventCreatedResponse>('/events/objectives', {
|
||||
objective,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 2: AI 추천 요청
|
||||
requestAiRecommendations: async (
|
||||
eventId: string,
|
||||
storeInfo: AiRecommendationRequest['storeInfo']
|
||||
): Promise<JobAcceptedResponse> => {
|
||||
const response = await eventApiClient.post<JobAcceptedResponse>(
|
||||
`/events/${eventId}/ai-recommendations`,
|
||||
{ storeInfo }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Job 상태 폴링
|
||||
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
|
||||
const response = await eventApiClient.get<EventJobStatusResponse>(`/jobs/${jobId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// AI 추천 선택
|
||||
selectRecommendation: async (
|
||||
eventId: string,
|
||||
request: SelectRecommendationRequest
|
||||
): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(
|
||||
`/events/${eventId}/recommendations`,
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 3: 이미지 생성 요청
|
||||
requestImageGeneration: async (
|
||||
eventId: string,
|
||||
request: ImageGenerationRequest
|
||||
): Promise<JobAcceptedResponse> => {
|
||||
const response = await eventApiClient.post<JobAcceptedResponse>(`/events/${eventId}/images`, request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이미지 선택
|
||||
selectImage: async (eventId: string, imageId: string): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(
|
||||
`/events/${eventId}/images/${imageId}/select`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 4: 이미지 편집
|
||||
editImage: async (
|
||||
eventId: string,
|
||||
imageId: string,
|
||||
editRequest: any
|
||||
): Promise<{ imageId: string; imageUrl: string; editedAt: string }> => {
|
||||
const response = await eventApiClient.put(`/events/${eventId}/images/${imageId}/edit`, editRequest);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 5: 배포 채널 선택
|
||||
selectChannels: async (eventId: string, channels: string[]): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}/channels`, {
|
||||
channels,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 6: 최종 배포
|
||||
publishEvent: async (eventId: string): Promise<EventPublishedResponse> => {
|
||||
const response = await eventApiClient.post<EventPublishedResponse>(`/events/${eventId}/publish`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이벤트 목록 조회
|
||||
getEvents: async (params?: {
|
||||
status?: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||
objective?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}): Promise<EventListResponse> => {
|
||||
const response = await eventApiClient.get<EventListResponse>('/events', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이벤트 상세 조회
|
||||
getEventDetail: async (eventId: string): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.get<EventDetailResponse>(`/events/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이벤트 수정
|
||||
updateEvent: async (eventId: string, request: UpdateEventRequest): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}`, request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이벤트 삭제
|
||||
deleteEvent: async (eventId: string): Promise<void> => {
|
||||
await eventApiClient.delete(`/events/${eventId}`);
|
||||
},
|
||||
|
||||
// 이벤트 조기 종료
|
||||
endEvent: async (eventId: string, reason: string): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.post<EventDetailResponse>(`/events/${eventId}/end`, {
|
||||
reason,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default eventApi;
|
||||
@ -1,2 +1,6 @@
|
||||
export { apiClient } from './client';
|
||||
export { apiClient, participationClient } from './client';
|
||||
export type { ApiError } from './types';
|
||||
export * from './contentApi';
|
||||
export * from './aiApi';
|
||||
export * from './eventApi';
|
||||
export * from './participation.api';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user