cherry2250 aaa03274af Analytics 페이지 채널 및 타임라인 API 연동 및 데이터 표시 개선
- Channels API 연동으로 채널별 성과 데이터 상세 표시
- Impressions 데이터 기반 채널 성과 차트 표시 (참여자 0일 때)
- Timeline API 연동 및 시간대별 데이터 집계 로직 구현
- 시간대별 참여 추이 섹션 임시 주석처리
- 참여자 수를 고정값(1,234명)으로 설정
- Analytics Proxy Route에 상세 로깅 추가 (ROI, Timeline, Channels)
- Mock 데이터 디렉토리 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 12:01:08 +09:00

594 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useRef } from 'react';
import {
Box,
Container,
Typography,
Card,
CardContent,
Button,
Grid,
Chip,
TextField,
Radio,
RadioGroup,
FormControlLabel,
IconButton,
CircularProgress,
Alert,
} from '@mui/material';
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
import { eventApi } from '@/shared/api';
import type { AiRecommendationResult, EventRecommendation } from '@/shared/api/eventApi';
// 디자인 시스템 색상
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',
},
};
interface RecommendationStepProps {
eventId?: string; // 이전 단계에서 생성된 eventId
onNext: (data: { recommendation: EventRecommendation; eventId: string }) => void;
onBack: () => void;
}
// 쿠키에서 값 가져오기
const getCookie = (name: string): string | null => {
if (typeof document === 'undefined') return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(';').shift() || null;
}
return null;
};
export default function RecommendationStep({
eventId: initialEventId,
onNext,
onBack,
}: RecommendationStepProps) {
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [aiResult, setAiResult] = useState<AiRecommendationResult | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [editedData, setEditedData] = useState<
Record<number, { title: string; description: string }>
>({});
// 중복 호출 방지를 위한 ref
const requestedEventIdRef = useRef<string | null>(null);
// 컴포넌트 마운트 시 AI 추천 결과 조회
useEffect(() => {
// props에서만 eventId를 받음
if (initialEventId) {
// 이미 요청한 eventId면 중복 요청하지 않음
if (requestedEventIdRef.current === initialEventId) {
console.log('⚠️ 이미 요청한 eventId입니다. 중복 요청 방지:', initialEventId);
return;
}
requestedEventIdRef.current = initialEventId;
setEventId(initialEventId);
console.log('✅ RecommendationStep - eventId 설정:', initialEventId);
// eventId가 있으면 바로 AI 추천 결과 조회
fetchAIRecommendations(initialEventId);
} else {
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
}
}, [initialEventId]);
const fetchAIRecommendations = async (evtId: string) => {
try {
setLoading(true);
setError(null);
console.log('📡 AI 추천 요청 시작, eventId:', evtId);
// POST /events/{eventId}/ai-recommendations 엔드포인트로 AI 추천 요청
const recommendations = await eventApi.requestAiRecommendations(evtId);
console.log('✅ AI 추천 요청 성공:', recommendations);
setAiResult(recommendations);
setLoading(false);
} catch (err: any) {
console.error('❌ AI 추천 요청 실패:', err);
const errorMessage =
err.response?.data?.message ||
err.response?.data?.error ||
'AI 추천을 생성하는데 실패했습니다';
setError(errorMessage);
setLoading(false);
}
};
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,
[optionNumber]: {
...prev[optionNumber],
title,
},
}));
};
const handleEditDescription = (optionNumber: number, description: string) => {
setEditedData((prev) => ({
...prev,
[optionNumber]: {
...prev[optionNumber],
description,
},
}));
};
// 로딩 상태 표시
if (loading) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 3, sm: 8 }, pb: { xs: 3, sm: 8 }, px: { xs: 1.5, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 3 }, mb: { xs: 3, sm: 8 } }}>
<IconButton onClick={onBack} sx={{ width: 40, height: 40 }}>
<ArrowBack sx={{ fontSize: 20 }} />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
AI
</Typography>
</Box>
<Box
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: { xs: 1.5, sm: 4 }, py: { xs: 4, sm: 12 } }}
>
<CircularProgress size={48} sx={{ color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: { xs: '0.9375rem', sm: '1.25rem' } }}>
AI가 ...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.8125rem', sm: '1rem' } }}>
, ,
</Typography>
</Box>
</Container>
</Box>
);
}
// 에러 상태 표시
if (error) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 3, sm: 8 }, pb: { xs: 3, sm: 8 }, px: { xs: 1.5, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 3 }, mb: { xs: 3, sm: 8 } }}>
<IconButton onClick={onBack} sx={{ width: 40, height: 40 }}>
<ArrowBack sx={{ fontSize: 20 }} />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
AI
</Typography>
</Box>
<Alert severity="error" sx={{ mb: { xs: 3, sm: 4 } }}>
{error}
</Alert>
<Box sx={{ display: 'flex', gap: { xs: 2, sm: 4 } }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onBack}
sx={{
py: { xs: 1.5, sm: 3 },
borderRadius: 3,
fontSize: { xs: '0.875rem', sm: '1rem' },
fontWeight: 600,
}}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
onClick={() => {
setError(null);
// props에서 eventId가 없으면 쿠키에서 읽어오기
const evtId = initialEventId || getCookie('eventId');
if (evtId) {
fetchAIRecommendations(evtId);
} else {
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
}
}}
sx={{
py: { xs: 1.5, sm: 3 },
borderRadius: 3,
fontSize: { xs: '0.875rem', sm: '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: { xs: 3, sm: 8 }, pb: { xs: 3, sm: 8 }, px: { xs: 1.5, sm: 6, md: 8 } }}>
<CircularProgress />
</Container>
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 3, sm: 8 }, pb: { xs: 3, sm: 8 }, px: { xs: 1.5, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 3 }, mb: { xs: 3, sm: 8 } }}>
<IconButton onClick={onBack} sx={{ width: 40, height: 40 }}>
<ArrowBack sx={{ fontSize: 20 }} />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
AI
</Typography>
</Box>
{/* Trends Analysis */}
<Card
elevation={0}
sx={{
mb: { xs: 3, sm: 10 },
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: { xs: 2, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 2 }, mb: { xs: 3, sm: 6 } }}>
<Insights sx={{ fontSize: { xs: 24, sm: 32 }, color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
AI
</Typography>
</Box>
<Grid container spacing={{ xs: 3, sm: 6 }}>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: { xs: 1.5, sm: 2 }, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
📍
</Typography>
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
<Typography
key={idx}
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.8125rem', sm: '0.95rem' }, mb: 1 }}
>
{trend.description}
</Typography>
))}
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: { xs: 1.5, sm: 2 }, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
🗺
</Typography>
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
<Typography
key={idx}
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.8125rem', sm: '0.95rem' }, mb: 1 }}
>
{trend.description}
</Typography>
))}
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: { xs: 1.5, sm: 2 }, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
</Typography>
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
<Typography
key={idx}
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.8125rem', sm: '0.95rem' }, mb: 1 }}
>
{trend.description}
</Typography>
))}
</Grid>
</Grid>
</CardContent>
</Card>
{/* AI Recommendations */}
<Box sx={{ mb: { xs: 2, sm: 8 } }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: { xs: 2, sm: 4 }, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
AI ({aiResult.recommendations.length} )
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 6 }, fontSize: { xs: '0.8125rem', sm: '1rem' } }}>
.
.
</Typography>
</Box>
{/* Recommendations */}
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
<Grid container spacing={{ xs: 3, sm: 6 }} sx={{ mb: { xs: 3, sm: 10 } }}>
{aiResult.recommendations.map((rec) => (
<Grid item xs={12} key={rec.optionNumber}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 4,
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.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.optionNumber)}
>
<CardContent sx={{ p: { xs: 2, sm: 6 } }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: { xs: 2, sm: 4 },
}}
>
<Box sx={{ display: 'flex', gap: { xs: 1, sm: 2 }, flexWrap: 'wrap' }}>
<Chip
label={`옵션 ${rec.optionNumber}`}
color="primary"
size="small"
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 1.5, sm: 2 } }}
/>
<Chip
label={rec.concept}
variant="outlined"
size="small"
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 1.5, sm: 2 } }}
/>
</Box>
<FormControlLabel
value={rec.optionNumber}
control={<Radio />}
label=""
sx={{ m: 0 }}
/>
</Box>
<TextField
fullWidth
variant="outlined"
value={editedData[rec.optionNumber]?.title || rec.title}
onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)}
onClick={(e) => e.stopPropagation()}
sx={{ mb: { xs: 2, sm: 4 } }}
InputProps={{
endAdornment: <Edit fontSize="small" color="action" />,
sx: { fontSize: { xs: '0.9375rem', sm: '1.1rem' }, fontWeight: 600, py: { xs: 1.5, sm: 2 } },
}}
/>
<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: { xs: 2, sm: 4 } }}
InputProps={{
sx: { fontSize: { xs: '0.875rem', sm: '1rem' } },
}}
/>
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mt: { xs: 1, sm: 2 } }}>
<Grid item xs={6} md={3}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' }, mt: { xs: 0.5, sm: 1 } }}
>
{rec.targetAudience}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' }, mt: { xs: 0.5, sm: 1 } }}
>
{(rec.estimatedCost.min / 10000).toFixed(0)}~
{(rec.estimatedCost.max / 10000).toFixed(0)}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' }, mt: { xs: 0.5, sm: 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: { xs: '0.75rem', sm: '0.875rem' } }}
>
ROI
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: 'error.main', fontSize: { xs: '0.875rem', sm: '1rem' }, mt: { xs: 0.5, sm: 1 } }}
>
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
</Typography>
</Grid>
<Grid item xs={12}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
</Typography>
<Typography variant="body2" sx={{ fontSize: { xs: '0.8125rem', sm: '0.95rem' }, mt: { xs: 0.5, sm: 1 } }}>
{rec.differentiator}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</RadioGroup>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: { xs: 2, sm: 4 } }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onBack}
sx={{
py: { xs: 1.5, sm: 3 },
borderRadius: 3,
fontSize: { xs: '0.875rem', sm: '1rem' },
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
disabled={selected === null || loading}
onClick={handleNext}
sx={{
py: { xs: 1.5, sm: 3 },
borderRadius: 3,
fontSize: { xs: '0.875rem', sm: '1rem' },
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
'&:disabled': {
background: colors.gray[300],
},
}}
>
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
</Button>
</Box>
</Container>
</Box>
);
}