mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 06:56:24 +00:00
- 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>
542 lines
19 KiB
TypeScript
542 lines
19 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Box,
|
|
Container,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
Button,
|
|
Checkbox,
|
|
FormControlLabel,
|
|
Select,
|
|
MenuItem,
|
|
TextField,
|
|
FormControl,
|
|
InputLabel,
|
|
IconButton,
|
|
} from '@mui/material';
|
|
import { ArrowBack } from '@mui/icons-material';
|
|
|
|
// 디자인 시스템 색상
|
|
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 Channel {
|
|
id: string;
|
|
name: string;
|
|
selected: boolean;
|
|
config?: Record<string, string | number>;
|
|
}
|
|
|
|
interface ChannelStepProps {
|
|
onNext: (channels: string[]) => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|
const [channels, setChannels] = useState<Channel[]>([
|
|
{ id: 'uriTV', name: '우리동네TV', selected: false, config: { radius: '500', time: 'evening' } },
|
|
{ id: 'ringoBiz', name: '링고비즈', selected: false, config: { phone: '010-1234-5678' } },
|
|
{ id: 'genieTV', name: '지니TV 광고', selected: false, config: { region: 'suwon', time: 'all', budget: '' } },
|
|
{ id: 'sns', name: 'SNS', selected: false, config: { instagram: 'true', naver: 'true', kakao: 'false', schedule: 'now' } },
|
|
]);
|
|
|
|
const handleChannelToggle = (channelId: string) => {
|
|
setChannels((prev) =>
|
|
prev.map((ch) => (ch.id === channelId ? { ...ch, selected: !ch.selected } : ch))
|
|
);
|
|
};
|
|
|
|
const handleConfigChange = (channelId: string, key: string, value: string) => {
|
|
setChannels((prev) =>
|
|
prev.map((ch) =>
|
|
ch.id === channelId ? { ...ch, config: { ...ch.config, [key]: value } } : ch
|
|
)
|
|
);
|
|
};
|
|
|
|
const getChannelConfig = (channelId: string, key: string): string => {
|
|
const channel = channels.find((ch) => ch.id === channelId);
|
|
return (channel?.config?.[key] as string) || '';
|
|
};
|
|
|
|
const calculateSummary = () => {
|
|
let totalCost = 0;
|
|
let totalExposure = 0;
|
|
|
|
channels.forEach((ch) => {
|
|
if (!ch.selected) return;
|
|
|
|
if (ch.id === 'uriTV') {
|
|
totalCost += 80000;
|
|
totalExposure += 50000;
|
|
} else if (ch.id === 'ringoBiz') {
|
|
totalExposure += 30000;
|
|
} else if (ch.id === 'genieTV') {
|
|
const budget = parseInt(getChannelConfig('genieTV', 'budget')) || 0;
|
|
totalCost += budget;
|
|
totalExposure += Math.floor(budget / 100) * 1000;
|
|
}
|
|
});
|
|
|
|
return { totalCost, totalExposure };
|
|
};
|
|
|
|
const handleNext = () => {
|
|
const selectedChannels = channels.filter((ch) => ch.selected).map((ch) => ch.id);
|
|
if (selectedChannels.length > 0) {
|
|
onNext(selectedChannels);
|
|
}
|
|
};
|
|
|
|
const { totalCost, totalExposure } = calculateSummary();
|
|
const selectedCount = channels.filter((ch) => ch.selected).length;
|
|
|
|
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' } }}>
|
|
배포 채널 선택
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: { xs: '0.8125rem', sm: '1rem' } }}>
|
|
(최소 1개 이상)
|
|
</Typography>
|
|
|
|
{/* 우리동네TV */}
|
|
<Card
|
|
elevation={0}
|
|
sx={{
|
|
mb: 6,
|
|
borderRadius: 4,
|
|
border: channels[0].selected ? 2 : 1,
|
|
borderColor: channels[0].selected ? colors.purple : 'divider',
|
|
bgcolor: channels[0].selected ? `${colors.purpleLight}40` : 'background.paper',
|
|
boxShadow: channels[0].selected ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
transition: 'all 0.3s',
|
|
}}
|
|
>
|
|
<CardContent sx={{ p: { xs: 2, sm: 6 } }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={channels[0].selected}
|
|
onChange={() => handleChannelToggle('uriTV')}
|
|
sx={{
|
|
color: colors.purple,
|
|
'&.Mui-checked': {
|
|
color: colors.purple,
|
|
},
|
|
}}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body1" sx={{ fontWeight: channels[0].selected ? 700 : 600, fontSize: '1.125rem' }}>
|
|
우리동네TV
|
|
</Typography>
|
|
}
|
|
sx={{ mb: channels[0].selected ? 2 : 0 }}
|
|
/>
|
|
|
|
{channels[0].selected && (
|
|
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
<InputLabel>반경</InputLabel>
|
|
<Select
|
|
value={getChannelConfig('uriTV', 'radius')}
|
|
onChange={(e) => handleConfigChange('uriTV', 'radius', e.target.value)}
|
|
label="반경"
|
|
>
|
|
<MenuItem value="500">500m</MenuItem>
|
|
<MenuItem value="1000">1km</MenuItem>
|
|
<MenuItem value="2000">2km</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
<InputLabel>노출 시간대</InputLabel>
|
|
<Select
|
|
value={getChannelConfig('uriTV', 'time')}
|
|
onChange={(e) => handleConfigChange('uriTV', 'time', e.target.value)}
|
|
label="노출 시간대"
|
|
>
|
|
<MenuItem value="morning">아침 (7-12시)</MenuItem>
|
|
<MenuItem value="afternoon">점심 (12-17시)</MenuItem>
|
|
<MenuItem value="evening">저녁 (17-22시)</MenuItem>
|
|
<MenuItem value="all">전체</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Typography variant="body2" color="text.secondary">
|
|
예상 노출: <strong>5만명</strong>
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
비용: <strong>8만원</strong>
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 링고비즈 */}
|
|
<Card
|
|
elevation={0}
|
|
sx={{
|
|
mb: 6,
|
|
borderRadius: 4,
|
|
border: channels[1].selected ? 2 : 1,
|
|
borderColor: channels[1].selected ? colors.purple : 'divider',
|
|
bgcolor: channels[1].selected ? `${colors.purpleLight}40` : 'background.paper',
|
|
boxShadow: channels[1].selected ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
transition: 'all 0.3s',
|
|
}}
|
|
>
|
|
<CardContent sx={{ p: { xs: 2, sm: 6 } }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={channels[1].selected}
|
|
onChange={() => handleChannelToggle('ringoBiz')}
|
|
sx={{
|
|
color: colors.purple,
|
|
'&.Mui-checked': {
|
|
color: colors.purple,
|
|
},
|
|
}}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body1" sx={{ fontWeight: channels[1].selected ? 700 : 600, fontSize: '1.125rem' }}>
|
|
링고비즈
|
|
</Typography>
|
|
}
|
|
sx={{ mb: channels[1].selected ? 2 : 0 }}
|
|
/>
|
|
|
|
{channels[1].selected && (
|
|
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
|
<TextField
|
|
fullWidth
|
|
label="매장 전화번호"
|
|
value={getChannelConfig('ringoBiz', 'phone')}
|
|
InputProps={{ readOnly: true }}
|
|
sx={{ mb: 2 }}
|
|
/>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
|
연결음 자동 업데이트
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
예상 노출: <strong>3만명</strong>
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
비용: <strong>무료</strong>
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 지니TV 광고 */}
|
|
<Card
|
|
elevation={0}
|
|
sx={{
|
|
mb: 6,
|
|
borderRadius: 4,
|
|
border: channels[2].selected ? 2 : 1,
|
|
borderColor: channels[2].selected ? colors.purple : 'divider',
|
|
bgcolor: channels[2].selected ? `${colors.purpleLight}40` : 'background.paper',
|
|
boxShadow: channels[2].selected ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
transition: 'all 0.3s',
|
|
}}
|
|
>
|
|
<CardContent sx={{ p: { xs: 2, sm: 6 } }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={channels[2].selected}
|
|
onChange={() => handleChannelToggle('genieTV')}
|
|
sx={{
|
|
color: colors.purple,
|
|
'&.Mui-checked': {
|
|
color: colors.purple,
|
|
},
|
|
}}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body1" sx={{ fontWeight: channels[2].selected ? 700 : 600, fontSize: '1.125rem' }}>
|
|
지니TV 광고
|
|
</Typography>
|
|
}
|
|
sx={{ mb: channels[2].selected ? 2 : 0 }}
|
|
/>
|
|
|
|
{channels[2].selected && (
|
|
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
<InputLabel>지역</InputLabel>
|
|
<Select
|
|
value={getChannelConfig('genieTV', 'region')}
|
|
onChange={(e) => handleConfigChange('genieTV', 'region', e.target.value)}
|
|
label="지역"
|
|
>
|
|
<MenuItem value="suwon">수원</MenuItem>
|
|
<MenuItem value="seoul">서울</MenuItem>
|
|
<MenuItem value="busan">부산</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
<InputLabel>노출 시간대</InputLabel>
|
|
<Select
|
|
value={getChannelConfig('genieTV', 'time')}
|
|
onChange={(e) => handleConfigChange('genieTV', 'time', e.target.value)}
|
|
label="노출 시간대"
|
|
>
|
|
<MenuItem value="all">전체</MenuItem>
|
|
<MenuItem value="prime">프라임 (19-23시)</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<TextField
|
|
fullWidth
|
|
type="number"
|
|
label="예산"
|
|
placeholder="예산을 입력하세요"
|
|
value={getChannelConfig('genieTV', 'budget')}
|
|
onChange={(e) => handleConfigChange('genieTV', 'budget', e.target.value)}
|
|
InputProps={{ inputProps: { min: 0, step: 10000 } }}
|
|
sx={{ mb: 2 }}
|
|
/>
|
|
|
|
<Typography variant="body2" color="text.secondary">
|
|
예상 노출:{' '}
|
|
<strong>
|
|
{getChannelConfig('genieTV', 'budget')
|
|
? `${(Math.floor(parseInt(getChannelConfig('genieTV', 'budget')) / 100) * 1000 / 10000).toFixed(1)}만명`
|
|
: '계산중...'}
|
|
</strong>
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* SNS */}
|
|
<Card
|
|
elevation={0}
|
|
sx={{
|
|
mb: { xs: 4, sm: 10 },
|
|
borderRadius: 4,
|
|
border: channels[3].selected ? 2 : 1,
|
|
borderColor: channels[3].selected ? colors.purple : 'divider',
|
|
bgcolor: channels[3].selected ? `${colors.purpleLight}40` : 'background.paper',
|
|
boxShadow: channels[3].selected ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
transition: 'all 0.3s',
|
|
}}
|
|
>
|
|
<CardContent sx={{ p: { xs: 2, sm: 6 } }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={channels[3].selected}
|
|
onChange={() => handleChannelToggle('sns')}
|
|
sx={{
|
|
color: colors.purple,
|
|
'&.Mui-checked': {
|
|
color: colors.purple,
|
|
},
|
|
}}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body1" sx={{ fontWeight: channels[3].selected ? 700 : 600, fontSize: '1.125rem' }}>
|
|
SNS
|
|
</Typography>
|
|
}
|
|
sx={{ mb: channels[3].selected ? 2 : 0 }}
|
|
/>
|
|
|
|
{channels[3].selected && (
|
|
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
|
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
|
플랫폼 선택
|
|
</Typography>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={getChannelConfig('sns', 'instagram') === 'true'}
|
|
onChange={(e) =>
|
|
handleConfigChange('sns', 'instagram', e.target.checked.toString())
|
|
}
|
|
sx={{
|
|
color: colors.purple,
|
|
'&.Mui-checked': {
|
|
color: colors.purple,
|
|
},
|
|
}}
|
|
/>
|
|
}
|
|
label="Instagram"
|
|
sx={{ display: 'block' }}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={getChannelConfig('sns', 'naver') === 'true'}
|
|
onChange={(e) =>
|
|
handleConfigChange('sns', 'naver', e.target.checked.toString())
|
|
}
|
|
sx={{
|
|
color: colors.purple,
|
|
'&.Mui-checked': {
|
|
color: colors.purple,
|
|
},
|
|
}}
|
|
/>
|
|
}
|
|
label="Naver Blog"
|
|
sx={{ display: 'block' }}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={getChannelConfig('sns', 'kakao') === 'true'}
|
|
onChange={(e) =>
|
|
handleConfigChange('sns', 'kakao', e.target.checked.toString())
|
|
}
|
|
sx={{
|
|
color: colors.purple,
|
|
'&.Mui-checked': {
|
|
color: colors.purple,
|
|
},
|
|
}}
|
|
/>
|
|
}
|
|
label="Kakao Channel"
|
|
sx={{ display: 'block', mb: 2 }}
|
|
/>
|
|
|
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
<InputLabel>예약 게시</InputLabel>
|
|
<Select
|
|
value={getChannelConfig('sns', 'schedule')}
|
|
onChange={(e) => handleConfigChange('sns', 'schedule', e.target.value)}
|
|
label="예약 게시"
|
|
>
|
|
<MenuItem value="now">즉시</MenuItem>
|
|
<MenuItem value="schedule">예약</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Typography variant="body2" color="text.secondary">
|
|
예상 노출: <strong>-</strong>
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
비용: <strong>무료</strong>
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Summary */}
|
|
<Card
|
|
elevation={0}
|
|
sx={{
|
|
mb: { xs: 4, sm: 10 },
|
|
borderRadius: 4,
|
|
bgcolor: 'grey.50',
|
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
}}
|
|
>
|
|
<CardContent sx={{ p: { xs: 2, sm: 8 } }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 4 }}>
|
|
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
|
총 예상 비용
|
|
</Typography>
|
|
<Typography variant="h6" color="error.main" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
|
|
{totalCost.toLocaleString()}원
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
|
총 예상 노출
|
|
</Typography>
|
|
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem', color: colors.purple }}>
|
|
{totalExposure > 0 ? `${totalExposure.toLocaleString()}명+` : '0명'}
|
|
</Typography>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 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={selectedCount === 0}
|
|
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],
|
|
},
|
|
}}
|
|
>
|
|
다음
|
|
</Button>
|
|
</Box>
|
|
</Container>
|
|
</Box>
|
|
);
|
|
}
|