diff --git a/package-lock.json b/package-lock.json index 7f70906..32be59c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.6.8", + "chart.js": "^4.4.4", + "chartjs-plugin-datalabels": "^2.2.0", "craco-alias": "^3.0.1", "date-fns": "^3.6.0", "moment": "^2.30.1", @@ -21,6 +23,7 @@ "react-animated-numbers": "^0.18.0", "react-app-alias": "^2.2.2", "react-calendar": "^5.0.0", + "react-chartjs-2": "^5.2.0", "react-datepicker": "^7.2.0", "react-dom": "^18.3.1", "react-icons": "^5.2.1", @@ -3340,6 +3343,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -6325,6 +6333,25 @@ "node": ">=10" } }, + "node_modules/chart.js": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz", + "integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -15550,6 +15577,15 @@ } } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-datepicker": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.2.0.tgz", diff --git a/package.json b/package.json index 5d00994..96c3736 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.6.8", + "chart.js": "^4.4.4", + "chartjs-plugin-datalabels": "^2.2.0", "craco-alias": "^3.0.1", "date-fns": "^3.6.0", "moment": "^2.30.1", @@ -17,6 +19,7 @@ "react-animated-numbers": "^0.18.0", "react-app-alias": "^2.2.2", "react-calendar": "^5.0.0", + "react-chartjs-2": "^5.2.0", "react-datepicker": "^7.2.0", "react-dom": "^18.3.1", "react-icons": "^5.2.1", diff --git a/src/components/TopNavigation/TopNavigation.jsx b/src/components/TopNavigation/TopNavigation.jsx index d43cbb4..a207bcb 100644 --- a/src/components/TopNavigation/TopNavigation.jsx +++ b/src/components/TopNavigation/TopNavigation.jsx @@ -60,6 +60,10 @@ export default function TopNavigation({ eventTitle } = {}) { 통계 + {/* 세부 통계 페이지는 추후 통째로 삭제 */} + + 세부 통계(임시) + {location.pathname.startsWith('/event/dashboard') && ( {eventTitle !== undefined && ( diff --git a/src/pages/DetailStatisticsPage/DetailStatisticsPage.jsx b/src/pages/DetailStatisticsPage/DetailStatisticsPage.jsx new file mode 100644 index 0000000..0c71d0e --- /dev/null +++ b/src/pages/DetailStatisticsPage/DetailStatisticsPage.jsx @@ -0,0 +1,228 @@ +import * as S from './DetailStatisticsPage.style'; +import { PageLayout } from '@/Layout'; +import { TopNavigation } from '@/components'; +import { Doughnut } from 'react-chartjs-2'; +import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { ATTENDEE_LIST } from './attendee'; + +ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels); + +const DetailStatisticsPage = () => { + const startDate = ATTENDEE_LIST[0].eventSchedules[0].date.split('T')[0]; + const endDate = + ATTENDEE_LIST[0].eventSchedules[ + ATTENDEE_LIST[0].eventSchedules.length - 1 + ].date.split('T')[0]; + + // 학과별 참석 비율을 계산하는 로직 + const departmentAttendance = {}; + ATTENDEE_LIST[0].eventSchedules.forEach((schedule) => { + schedule.students.forEach((student) => { + if (student.isAttending) { + if (departmentAttendance[student.major]) { + departmentAttendance[student.major]++; + } else { + departmentAttendance[student.major] = 1; + } + } + }); + }); + + const sortedDepartmentAttendance = Object.entries(departmentAttendance).sort( + (a, b) => b[1] - a[1], + ); + + const majorAttendanceLimit = 4; + const departmentLabels = sortedDepartmentAttendance + .slice(0, majorAttendanceLimit) + .map((item) => item[0]); + + const etcValue = sortedDepartmentAttendance + .slice(majorAttendanceLimit) + .reduce((sum, item) => sum + item[1], 0); + + if (etcValue > 0) { + departmentLabels.push('기타'); + } + + const departmentValues = sortedDepartmentAttendance + .slice(0, majorAttendanceLimit) + .map((item) => item[1]); + + if (etcValue > 0) { + departmentValues.push(etcValue); + } + + const departmentColors = departmentValues.map((_, index) => { + if (index < 4) { + return ['#2F7CEF', '#ACCDFF', '#2f7cef33', '#EDF5FF'][index]; + } else { + return '#E4E4E4'; + } + }); + + const departmentData = { + labels: departmentLabels, + datasets: [ + { + data: departmentValues, + backgroundColor: departmentColors, + }, + ], + }; + + // 학번별 참석 비율을 계산하는 로직 + const yearAttendance = {}; + ATTENDEE_LIST[0].eventSchedules.forEach((schedule) => { + schedule.students.forEach((student) => { + if (student.isAttending) { + if (yearAttendance[student.studentYear]) { + yearAttendance[student.studentYear]++; + } else { + yearAttendance[student.studentYear] = 1; + } + } + }); + }); + + const sortedYearAttendance = Object.entries(yearAttendance).sort( + (a, b) => b[0] - a[0], + ); + + const yearAttendanceLimit = 4; + const yearLabels = sortedYearAttendance + .slice(0, yearAttendanceLimit) + .map((item) => `${item[0]}학번`); + + const etcYearValue = sortedYearAttendance + .slice(yearAttendanceLimit) + .reduce((sum, item) => sum + item[1], 0); + + if (etcYearValue > 0) { + yearLabels.push('기타'); + } + + const yearValues = sortedYearAttendance + .slice(0, yearAttendanceLimit) + .map((item) => item[1]); + + if (etcYearValue > 0) { + yearValues.push(etcYearValue); + } + + const yearColors = yearValues.map((_, index) => { + if (index < 4) { + return ['#2F7CEF', '#ACCDFF', '#2f7cef33', '#EDF5FF'][index]; + } else { + return '#E4E4E4'; + } + }); + + const yearData = { + labels: yearLabels, + datasets: [ + { + data: yearValues, + backgroundColor: yearColors, + }, + ], + }; + + // 이수율 계산 로직 + const totalStudents = ATTENDEE_LIST[0].eventSchedules.reduce( + (total, schedule) => total + schedule.students.length, + 0, + ); + + const attendingStudents = ATTENDEE_LIST[0].eventSchedules.reduce( + (total, schedule) => + total + schedule.students.filter((student) => student.isAttending).length, + 0, + ); + + const completionRate = ((attendingStudents / totalStudents) * 100).toFixed(0); + const nonCompletionRate = (100 - completionRate).toFixed(0); + + // 이수율 차트 데이터 + const completionData = { + labels: ['이수', '미이수'], + datasets: [ + { + data: [completionRate, nonCompletionRate], + backgroundColor: ['#2F7CEF', '#ACCDFF'], + }, + ], + }; + + const options = { + plugins: { + legend: { + position: 'right', + labels: { + usePointStyle: true, + padding: 20, + boxWidth: 10, + }, + }, + datalabels: { + color: '#000', + anchor: 'center', + align: 'center', + textAlign: 'center', + + formatter: (value, context) => { + const total = context.dataset.data.reduce((acc, val) => acc + val, 0); + const percentage = ((value / total) * 100).toFixed(0); + const label = context.chart.data.labels[context.dataIndex]; + return `${label} \n ${percentage}%`; + }, + }, + }, + layout: { + padding: { + right: 10, + }, + }, + }; + + return ( + }> + + + + 행사별 통계 + + {startDate} ~ {endDate} + + + + + + 행사에 참석한 학과 비율 + + + + + + + 각 학번별 참석률 + + + + + + + 전체 학생 중 이수율 + + + + + + + + + ); +}; + +export default DetailStatisticsPage; diff --git a/src/pages/DetailStatisticsPage/DetailStatisticsPage.style.jsx b/src/pages/DetailStatisticsPage/DetailStatisticsPage.style.jsx new file mode 100644 index 0000000..76bfdf9 --- /dev/null +++ b/src/pages/DetailStatisticsPage/DetailStatisticsPage.style.jsx @@ -0,0 +1,86 @@ +import { BREAKPOINTS } from '@/styles'; +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +export const DetailStatisticsPage = styled.div` + display: flex; + flex-direction: column; + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 50px 20px; + + border: 1px solid red; /* 임시 코드 */ +`; + +// 행사 타이틀 + 버튼 +export const TopContainer = styled.div` + display: flex; + margin-bottom: 20px; + gap: 10px; + align-items: flex-end; + + @media (max-width: ${BREAKPOINTS[0]}px) { + margin-bottom: 10px; + } +`; + +export const Title = styled.h1` + font-size: 24px; + font-weight: bold; +`; + +export const EventDate = styled.p` + font-size: 14px; + margin-bottom: 2px; + color: #6b6b6b; + font-weight: 500; +`; + +// 통계 +export const ContentContainer = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + width: 100%; + gap: 32px; + padding-top: 20px; + + @media (max-width: ${BREAKPOINTS[1]}px) { + flex-direction: column; + } +`; + +export const ChartWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + width: calc(50% - 16px); + + @media (max-width: ${BREAKPOINTS[1]}px) { + width: 100%; + } +`; + +export const ChartTitle = styled.h2` + font-size: 18px; + font-weight: 600; +`; + +export const Chart = styled.div` + display: flex; + justify-content: center; + align-items: center; + border-radius: 20px; + border: 1px solid #aecfff; + background: #fff; + padding: 0 30px; + width: 100%; + height: 100%; + max-height: 300px; +`; diff --git a/src/pages/DetailStatisticsPage/attendee.js b/src/pages/DetailStatisticsPage/attendee.js new file mode 100644 index 0000000..9538508 --- /dev/null +++ b/src/pages/DetailStatisticsPage/attendee.js @@ -0,0 +1,174 @@ +export const ATTENDEE_LIST = [ + { + eventId: 0, + eventTitle: 'string', + eventSchedules: [ + { + date: '2024-09-11T12:18:40.472Z', + students: [ + { + major: '소프트웨어응용학전공', + studentYear: 21, + isAttending: true, + }, + { + major: '컴퓨터과학전공', + studentYear: 21, + isAttending: false, + }, + { + major: '컴퓨터과학전공', + studentYear: 22, + isAttending: true, + }, + { + major: '경영학과', + studentYear: 23, + isAttending: true, + }, + { + major: '경영학과', + studentYear: 23, + isAttending: false, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: true, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: true, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: false, + }, + { + major: '수학과', + studentYear: 24, + isAttending: true, + }, + { + major: '의상학과', + studentYear: 20, + isAttending: true, + }, + ], + }, + { + date: '2024-09-12T12:18:40.472Z', + students: [ + { + major: '소프트웨어응용학전공', + studentYear: 21, + isAttending: true, + }, + { + major: '컴퓨터과학전공', + studentYear: 21, + isAttending: false, + }, + { + major: '컴퓨터과학전공', + studentYear: 22, + isAttending: true, + }, + { + major: '경영학과', + studentYear: 23, + isAttending: false, + }, + { + major: '경영학과', + studentYear: 23, + isAttending: true, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: true, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: false, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: true, + }, + { + major: '수학과', + studentYear: 24, + isAttending: false, + }, + { + major: '의상학과', + studentYear: 20, + isAttending: true, + }, + ], + }, + { + date: '2024-09-13T12:18:40.472Z', + students: [ + { + major: '소프트웨어응용학전공', + studentYear: 21, + isAttending: false, + }, + { + major: '컴퓨터과학전공', + studentYear: 21, + isAttending: true, + }, + { + major: '컴퓨터과학전공', + studentYear: 22, + isAttending: true, + }, + { + major: '경영학과', + studentYear: 23, + isAttending: true, + }, + { + major: '경영학과', + studentYear: 23, + isAttending: false, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: true, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: false, + }, + { + major: '통계학과', + studentYear: 24, + isAttending: true, + }, + { + major: '수학과', + studentYear: 24, + isAttending: false, + }, + { + major: '의상학과', + studentYear: 20, + isAttending: true, + }, + ], + }, + ], + eventImage: 'string', + }, +]; diff --git a/src/pages/DetailStatisticsPage/index.js b/src/pages/DetailStatisticsPage/index.js new file mode 100644 index 0000000..f7dae42 --- /dev/null +++ b/src/pages/DetailStatisticsPage/index.js @@ -0,0 +1 @@ +export { default as DetailStatisticsPage } from './DetailStatisticsPage'; diff --git a/src/pages/index.js b/src/pages/index.js index 2a1bafc..e236c28 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -12,3 +12,5 @@ export { EventCardListPage } from './EventCardListPage'; export { TotalStatisticsPage } from './TotalStatisticsPage'; export { LoadingPage } from './LoadingPage'; export { RegisterCompleted } from './RegisterPage'; +// 추후 삭제 +export { DetailStatisticsPage } from './DetailStatisticsPage'; diff --git a/src/router.js b/src/router.js index 687c4f5..a70169f 100644 --- a/src/router.js +++ b/src/router.js @@ -13,6 +13,7 @@ import { DashboardStatisticPage, LoadingPage, RegisterCompleted, + DetailStatisticsPage, } from './pages'; import Layout from './Layout/Layout'; @@ -60,6 +61,10 @@ const router = createBrowserRouter([ path: '/stats', element: , }, + { + path: '/stats/detail', + element: , + }, { path: '/loading', element: ,