diff --git a/package-lock.json b/package-lock.json index ad38b77..9644766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,11 @@ "@use-funnel/browser": "^0.0.12", "@use-funnel/next": "^0.0.12", "axios": "^1.7.7", - "chart.js": "^4.4.6", + "chart.js": "^4.5.1", "dayjs": "^1.11.13", "next": "^14.2.15", "react": "^18.3.1", - "react-chartjs-2": "^5.2.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", "zod": "^3.23.8", @@ -30,6 +30,7 @@ }, "devDependencies": { "@playwright/test": "^1.48.0", + "@types/chart.js": "^2.9.41", "@types/node": "^22.7.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -1215,6 +1216,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chart.js": { + "version": "2.9.41", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", + "integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "moment": "^2.10.2" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -4684,6 +4695,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index eca31d7..b4463e4 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,11 @@ "@use-funnel/browser": "^0.0.12", "@use-funnel/next": "^0.0.12", "axios": "^1.7.7", - "chart.js": "^4.4.6", + "chart.js": "^4.5.1", "dayjs": "^1.11.13", "next": "^14.2.15", "react": "^18.3.1", - "react-chartjs-2": "^5.2.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", "zod": "^3.23.8", @@ -32,6 +32,7 @@ }, "devDependencies": { "@playwright/test": "^1.48.0", + "@types/chart.js": "^2.9.41", "@types/node": "^22.7.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", diff --git a/src/app/(main)/analytics/page.tsx b/src/app/(main)/analytics/page.tsx index c2fc0ff..a94244f 100644 --- a/src/app/(main)/analytics/page.tsx +++ b/src/app/(main)/analytics/page.tsx @@ -10,12 +10,41 @@ import { Grid, } from '@mui/material'; import { - PieChart, - ShowChart, + PieChart as PieChartIcon, + ShowChart as ShowChartIcon, Payments, People, } from '@mui/icons-material'; +import { + Chart as ChartJS, + ArcElement, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; +import { Pie, Line } from 'react-chartjs-2'; import Header from '@/shared/ui/Header'; +import { + cardStyles, + colors, + responsiveText, +} from '@/shared/lib/button-styles'; + +// Chart.js 등록 +ChartJS.register( + ArcElement, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend +); // Mock 데이터 const mockAnalyticsData = { @@ -28,9 +57,9 @@ const mockAnalyticsData = { targetRoi: 300, }, channelPerformance: [ - { channel: '우리동네TV', participants: 58, percentage: 45, color: '#E31E24' }, - { channel: '링고비즈', participants: 38, percentage: 30, color: '#0066FF' }, - { channel: 'SNS', participants: 32, percentage: 25, color: '#FFB800' }, + { 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시', @@ -98,18 +127,25 @@ export default function AnalyticsPage() { return ( <>
- - + + {/* Title with Real-time Indicator */} - + 📊 요약 (실시간) @@ -118,7 +154,7 @@ export default function AnalyticsPage() { width: 8, height: 8, borderRadius: '50%', - bgcolor: 'success.main', + bgcolor: colors.mint, animation: 'pulse 2s infinite', '@keyframes pulse': { '0%, 100%': { opacity: 1 }, @@ -126,39 +162,75 @@ export default function AnalyticsPage() { }, }} /> - + {updateText} {/* Summary KPI Cards */} - + - - - + + + 참여자 수 - - {summary.participants}명 + + {summary.participants} - + ↑ {summary.participantsDelta}명 (오늘) - - - + + + 총 비용 - - {Math.floor(summary.totalCost / 10000)}만원 + + {Math.floor(summary.totalCost / 10000)}만 - + 경품 {Math.floor(roiDetail.prizeCost / 10000)}만 + 채널{' '} {Math.floor(roiDetail.channelCost / 10000)}만 @@ -166,31 +238,67 @@ export default function AnalyticsPage() { - - - + + + 예상 수익 - - {Math.floor(summary.expectedRevenue / 10000)}만원 + + {Math.floor(summary.expectedRevenue / 10000)}만 - - 매출증가 {Math.floor(roiDetail.salesIncrease / 10000)}만 + LTV{' '} + + 매출 {Math.floor(roiDetail.salesIncrease / 10000)}만 + LTV{' '} {Math.floor(roiDetail.newCustomerLTV / 10000)}만 - - - + + + 투자대비수익률 - + {summary.roi}% - + 목표 {summary.targetRoi}% 달성 @@ -199,39 +307,76 @@ export default function AnalyticsPage() { {/* Charts Grid */} - + {/* Channel Performance */} - - - - - + + + + + + + 채널별 성과 - {/* Chart Placeholder */} + {/* Pie Chart */} - - donut_large - - - 파이 차트 - + 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}%)`; + }, + }, + }, + }, + }} + /> {/* Legend */} @@ -250,9 +395,11 @@ export default function AnalyticsPage() { bgcolor: item.color, }} /> - {item.channel} + + {item.channel} + - + {item.percentage}% ({item.participants}명) @@ -264,44 +411,113 @@ export default function AnalyticsPage() { {/* Time Trend */} - - - - - + + + + + + + 시간대별 참여 추이 - {/* Chart Placeholder */} + {/* Line Chart */} - - trending_up - - - 라인 차트 - + {/* Stats */} - + 피크 시간: {timePerformance.peakTime} ({timePerformance.peakParticipants}명) - + 평균 시간당: {timePerformance.avgPerHour}명 @@ -311,37 +527,49 @@ export default function AnalyticsPage() { {/* ROI Detail & Participant Profile */} - + {/* ROI Detail */} - - - - - + + + + + + + 투자대비수익률 상세 - + - + 총 비용: {Math.floor(roiDetail.totalCost / 10000)}만원 - + - + • 경품 비용 - + {Math.floor(roiDetail.prizeCost / 10000)}만원 - + • 채널 비용 - + {Math.floor(roiDetail.channelCost / 10000)}만원 @@ -349,23 +577,23 @@ export default function AnalyticsPage() { - + 예상 수익: {Math.floor(roiDetail.expectedRevenue / 10000)}만원 - + - + • 매출 증가 - + {Math.floor(roiDetail.salesIncrease / 10000)}만원 - + • 신규 고객 LTV - + {Math.floor(roiDetail.newCustomerLTV / 10000)}만원 @@ -373,26 +601,26 @@ export default function AnalyticsPage() { - + 투자대비수익률 - + (수익 - 비용) ÷ 비용 × 100 - + ({Math.floor(roiDetail.expectedRevenue / 10000)}만 -{' '} {Math.floor(roiDetail.totalCost / 10000)}만) ÷{' '} {Math.floor(roiDetail.totalCost / 10000)}만 × 100 - + = {summary.roi}% @@ -404,33 +632,45 @@ export default function AnalyticsPage() { {/* Participant Profile */} - - - - - + + + + + + + 참여자 프로필 {/* Age Distribution */} - + 연령별 {participantProfile.age.map((item) => ( - + {item.label} @@ -438,11 +678,11 @@ export default function AnalyticsPage() { sx={{ width: `${item.percentage}%`, height: '100%', - bgcolor: 'info.main', + background: `linear-gradient(135deg, ${colors.blue} 0%, ${colors.blueLight} 100%)`, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', - pr: 1, + pr: 1.5, }} > - + 성별 {participantProfile.gender.map((item) => ( - + {item.label} @@ -484,11 +724,11 @@ export default function AnalyticsPage() { sx={{ width: `${item.percentage}%`, height: '100%', - bgcolor: 'error.main', + background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.pinkLight} 100%)`, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', - pr: 1, + pr: 1.5, }} > { + const getStatusStyle = (status: string) => { switch (status) { case 'active': - return 'success'; + return { + bgcolor: colors.mint, + color: 'white', + }; case 'scheduled': - return 'info'; + return { + bgcolor: colors.blue, + color: 'white', + }; case 'ended': - return 'default'; + return { + bgcolor: colors.gray[300], + color: colors.gray[700], + }; default: - return 'default'; + return { + bgcolor: colors.gray[200], + color: colors.gray[600], + }; } }; @@ -140,13 +193,149 @@ export default function EventsPage() { } }; + const getMethodIcon = (method: string) => { + switch (method) { + case '전화번호 입력': + return ; + case 'SNS 팔로우': + return ; + case '구매 인증': + return ; + case '이메일 등록': + return ; + default: + return ; + } + }; + + const calculateProgress = (event: typeof mockEvents[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(); + 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 + ), + }; + return ( <>
- - + + + {/* Summary Statistics */} + + + + + + + {stats.total} + + + 전체 이벤트 + + + + + + + + + + {stats.active} + + + 진행중 + + + + + + + + + + {stats.totalParticipants} + + + 총 참여자 + + + + + + + + + + {stats.avgROI}% + + + 평균 ROI + + + + + + {/* Search Section */} - + - + ), }} sx={{ '& .MuiOutlinedInput-root': { - borderRadius: 2, + borderRadius: 3, + bgcolor: 'white', + '& fieldset': { + borderColor: colors.gray[200], + }, + '&:hover fieldset': { + borderColor: colors.gray[300], + }, + '&.Mui-focused fieldset': { + borderColor: colors.purple, + }, }, }} /> {/* Filters */} - - - + + + 상태 setSortBy(e.target.value as SortBy)} size="small" + sx={{ + borderRadius: 2, + bgcolor: 'white', + '& .MuiOutlinedInput-notchedOutline': { + borderColor: colors.gray[200], + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: colors.gray[300], + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: colors.purple, + }, + }} > 최신순 참여자순 @@ -222,89 +460,234 @@ export default function EventsPage() { {/* Event List */} - + {pageEvents.length === 0 ? ( - - - event_busy - - - 검색 결과가 없습니다 - - + + + + + event_busy + + + + 검색 결과가 없습니다 + + + 다른 검색 조건으로 다시 시도해보세요 + + + ) : ( - + {pageEvents.map((event) => ( handleEventClick(event.id)} > - - {/* Header */} - - - {event.title} - - + {/* Header with Badges */} + + + + {event.title} + + + {getStatusText(event.status)} + {event.status === 'active' ? ` | D-${event.daysLeft}` : event.status === 'scheduled' ? ` | D+${event.daysLeft}` - : '' - }`} - color={getStatusColor(event.status) as any} - size="small" - sx={{ fontWeight: 600 }} - /> + : ''} + + + + {/* Status Badges */} + + {event.isUrgent && ( + } + label="마감임박" + size="small" + sx={{ + bgcolor: '#FEF3C7', + color: '#92400E', + fontWeight: 600, + fontSize: '0.75rem', + '& .MuiChip-icon': { color: '#92400E' }, + }} + /> + )} + {event.isPopular && ( + } + label="인기" + size="small" + sx={{ + bgcolor: '#FEE2E2', + color: '#991B1B', + fontWeight: 600, + fontSize: '0.75rem', + '& .MuiChip-icon': { color: '#991B1B' }, + }} + /> + )} + {event.isHighROI && ( + } + label="높은 ROI" + size="small" + sx={{ + bgcolor: '#DCFCE7', + color: '#166534', + fontWeight: 600, + fontSize: '0.75rem', + '& .MuiChip-icon': { color: '#166534' }, + }} + /> + )} + {event.isNew && ( + } + label="신규" + size="small" + sx={{ + bgcolor: '#DBEAFE', + color: '#1E40AF', + fontWeight: 600, + fontSize: '0.75rem', + '& .MuiChip-icon': { color: '#1E40AF' }, + }} + /> + )} + - {/* Stats */} - - - - - 참여 + {/* Progress Bar for Active Events */} + {event.status === 'active' && ( + + + + 이벤트 진행률 - - {event.participants}명 + + {Math.round(calculateProgress(event))}% - - + + + )} + + {/* Event Info and Stats Container */} + + {/* Left: Event Info */} + + + + + + {event.prize} + + + + {getMethodIcon(event.method)} + + {event.method} + + + + + {/* Date */} + + 📅 + + {event.startDate} ~ {event.endDate} + + + + + {/* Right: Stats */} + - - 투자대비수익률 + + 참여자 - + + {event.participants.toLocaleString()} + + 명 + + + {event.targetParticipants > 0 && ( + + 목표: {event.targetParticipants}명 ({Math.round((event.participants / event.targetParticipants) * 100)}%) + + )} + + + + ROI + + = 400 ? colors.mint : event.roi >= 200 ? colors.orange : colors.gray[500] }}> {event.roi}% - - - - {/* Date */} - - {event.startDate} ~ {event.endDate} - + + ))} @@ -314,13 +697,28 @@ export default function EventsPage() { {/* Pagination */} {totalPages > 1 && ( - + setCurrentPage(page)} - color="primary" size="large" + sx={{ + '& .MuiPaginationItem-root': { + color: colors.gray[700], + fontWeight: 600, + '&.Mui-selected': { + background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.blue} 100%)`, + color: 'white', + '&:hover': { + background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blueLight} 100%)`, + }, + }, + '&:hover': { + bgcolor: colors.gray[100], + }, + }, + }} /> )} diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 61de739..6dbac59 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -88,14 +88,14 @@ export default function HomePage() { minHeight: '100vh', }} > - + {/* Welcome Section */} - + 안녕하세요, {mockUser.name}님! 👋 @@ -106,7 +106,7 @@ export default function HomePage() { {/* KPI Cards */} - + - + @@ -145,7 +145,7 @@ export default function HomePage() { {activeEvents.length} @@ -161,7 +161,7 @@ export default function HomePage() { borderColor: 'transparent', }} > - + @@ -190,7 +190,7 @@ export default function HomePage() { {totalParticipants.toLocaleString()} @@ -206,7 +206,7 @@ export default function HomePage() { borderColor: 'transparent', }} > - + @@ -235,7 +235,7 @@ export default function HomePage() { {avgROI}% @@ -245,11 +245,11 @@ export default function HomePage() { {/* Quick Actions */} - - + + 빠른 시작 - + - + - + 새 이벤트 @@ -289,7 +289,7 @@ export default function HomePage() { }} onClick={handleViewAnalytics} > - + - + 성과분석 @@ -316,9 +316,9 @@ export default function HomePage() { {/* Active Events */} - + 진행 중인 이벤트 @@ -372,7 +372,7 @@ export default function HomePage() { ) : ( - + {activeEvents.map((event) => ( handleEventClick(event.id)} > - + @@ -411,11 +411,11 @@ export default function HomePage() { 📅 @@ -423,7 +423,7 @@ export default function HomePage() { {event.startDate} ~ {event.endDate} - + {/* Recent Activity */} - - + + 최근 활동 - + {mockActivities.map((activity, index) => ( 0 ? 3.5 : 0, - mt: index > 0 ? 3.5 : 0, + gap: 4, + pt: index > 0 ? 6 : 0, + mt: index > 0 ? 6 : 0, borderTop: index > 0 ? 1 : 0, borderColor: colors.gray[200], }} diff --git a/src/styles/globals.css b/src/styles/globals.css index 3054163..eb797cc 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -24,13 +24,13 @@ --color-error: #D32F2F; --color-info: #0288D1; - /* Spacing (4px Grid) */ - --spacing-xs: 4px; - --spacing-s: 8px; - --spacing-m: 16px; - --spacing-l: 24px; - --spacing-xl: 32px; - --spacing-2xl: 48px; + /* Spacing (4px Grid) - Doubled values */ + --spacing-xs: 8px; + --spacing-s: 16px; + --spacing-m: 32px; + --spacing-l: 48px; + --spacing-xl: 64px; + --spacing-2xl: 96px; /* Border Radius */ --border-radius-sm: 4px; @@ -73,8 +73,8 @@ body { padding: 0; font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", system-ui, sans-serif; - font-size: 14px; - line-height: 1.5; + font-size: 16px; + line-height: 1.6; color: var(--color-gray-900); background-color: var(--color-gray-50); min-height: 100%; @@ -149,18 +149,18 @@ button { width: 100%; max-width: 1200px; margin: 0 auto; - padding: 0 20px; + padding: 0 40px; } @media (min-width: 768px) { .container { - padding: 0 40px; + padding: 0 80px; } } @media (min-width: 1024px) { .container { - padding: 0 80px; + padding: 0 120px; } }