552 lines
14 KiB
Vue
552 lines
14 KiB
Vue
<template>
|
|
<div class="statistics-container">
|
|
<div v-if="loading" class="loading">
|
|
<div class="spinner"></div>
|
|
<p>통계 데이터를 불러오는 중...</p>
|
|
</div>
|
|
<div v-else>
|
|
<div class="period-selector">
|
|
<select v-model="selectedPeriod" @change="onPeriodChange">
|
|
<option value="30">최근 30일</option>
|
|
<option value="90">최근 90일</option>
|
|
<option value="180">최근 180일</option>
|
|
<option value="365">최근 1년</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="stats-summary">
|
|
<div class="stat-card">
|
|
<h3>회원 현황</h3>
|
|
<p>전체 회원: {{ stats.summary.totalMembers }}명</p>
|
|
<p>활동 회원: {{ stats.summary.activeMembers }}명</p>
|
|
<p>평균 참석률: {{ stats.summary.averageAttendance }}%</p>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>점수 현황</h3>
|
|
<p>최고 점수: {{ stats.summary.highestGame }}점</p>
|
|
<p>평균 점수: {{ stats.summary.averageScore }}점</p>
|
|
<p>총 게임 수: {{ stats.summary.totalGames }}게임</p>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>모임 현황</h3>
|
|
<p>총 모임 수: {{ stats.summary.totalMeetings }}회</p>
|
|
<p>평균 참가자: {{ stats.summary.averageParticipants }}명</p>
|
|
<p>모임당 게임 수: {{ stats.summary.averageGamesPerMeeting }}게임</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="charts-container">
|
|
<div class="chart-card">
|
|
<h3>점수 분포</h3>
|
|
<BarChart
|
|
:data="scoreDistributionChartData"
|
|
:options="scoreDistributionChartOptions"
|
|
/>
|
|
</div>
|
|
<div class="chart-card">
|
|
<h3>참석률 추이</h3>
|
|
<LineChart
|
|
:data="attendanceTrendChartData"
|
|
:options="attendanceTrendChartOptions"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rankings-container">
|
|
<div class="rankings-card">
|
|
<h3>회원 순위</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>순위</th>
|
|
<th>이름</th>
|
|
<th>평균</th>
|
|
<th>게임 수</th>
|
|
<th>핸디캡</th>
|
|
<th>참석률</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(member, index) in stats.memberRankings" :key="member.id">
|
|
<td>{{ index + 1 }}</td>
|
|
<td>{{ member.name }}</td>
|
|
<td>{{ member.average }}</td>
|
|
<td>{{ member.games }}</td>
|
|
<td>{{ member.handicap }}</td>
|
|
<td>{{ member.attendance }}%</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="meetings-container">
|
|
<div class="meetings-card">
|
|
<h3>모임별 통계</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>날짜</th>
|
|
<th>제목</th>
|
|
<th>참가자</th>
|
|
<th>게임 수</th>
|
|
<th>평균 점수</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="meeting in stats.meetingStats" :key="meeting.date">
|
|
<td>{{ formatDate(meeting.date) }}</td>
|
|
<td>{{ meeting.title }}</td>
|
|
<td>{{ meeting.participants }}명</td>
|
|
<td>{{ meeting.games }}게임</td>
|
|
<td>{{ meeting.averageScore }}점</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== 추가 통계 표시 영역 (정리) ===== -->
|
|
<div class="continued-stats-container">
|
|
<div class="continued-stats-card">
|
|
<h3>회원별 최고/최저/총점</h3>
|
|
<table>
|
|
<thead>
|
|
<tr><th>이름</th><th>최고점</th><th>최저점</th><th>총점</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="m in stats.memberScoreStats" :key="m.memberId">
|
|
<td>{{ m.name }}</td>
|
|
<td>{{ m.maxScore }}</td>
|
|
<td>{{ m.minScore }}</td>
|
|
<td>{{ m.totalScore }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="continued-stats-card">
|
|
<h3>모임별 점수 통계(확장)</h3>
|
|
<table>
|
|
<thead>
|
|
<tr><th>모임명</th><th>최고점</th><th>최저점</th><th>평균점</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="e in stats.meetingScoreStats" :key="e.eventId">
|
|
<td>{{ e.title }}</td>
|
|
<td>{{ e.maxScore }}</td>
|
|
<td>{{ e.minScore }}</td>
|
|
<td>{{ e.avgScore }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="continued-stats-card">
|
|
<h3>참가자 수 변화 추이</h3>
|
|
<LineChart v-if="participantTrendChartData" :data="participantTrendChartData" />
|
|
</div>
|
|
<div class="continued-stats-card">
|
|
<h3>회원별 모임 참가 횟수</h3>
|
|
<table>
|
|
<thead>
|
|
<tr><th>이름</th><th>참가 횟수</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="m in stats.memberAttendanceStats" :key="m.memberId">
|
|
<td>{{ m.name }}</td>
|
|
<td>{{ m.attendCount }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="continued-stats-card">
|
|
<h3>팀별 통계</h3>
|
|
<table>
|
|
<thead>
|
|
<tr><th>이벤트ID</th><th>팀ID</th><th>평균점수</th><th>최고점</th><th>참가자수</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="t in stats.teamStats" :key="`${t.eventId}-${t.teamId}`">
|
|
<td>{{ t.eventId }}</td>
|
|
<td>{{ t.teamId }}</td>
|
|
<td>{{ t.avgScore }}</td>
|
|
<td>{{ t.maxScore }}</td>
|
|
<td>{{ t.memberCount }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<!-- ===== 추가 통계 표시 영역 끝 ===== -->
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { ref, onMounted, computed } from 'vue';
|
|
import { useGtag } from 'vue-gtag-next';
|
|
import { Bar as BarChart, Line as LineChart } from 'vue-chartjs';
|
|
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend } from 'chart.js';
|
|
import apiClient from '@/services/api';
|
|
|
|
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend);
|
|
|
|
export default {
|
|
name: 'Statistics',
|
|
components: { BarChart, LineChart },
|
|
setup() {
|
|
const gtag = useGtag();
|
|
const stats = ref({
|
|
summary: {},
|
|
scoreDistribution: [],
|
|
attendanceTrend: [],
|
|
memberRankings: [],
|
|
meetingStats: []
|
|
});
|
|
const selectedPeriod = ref('90');
|
|
const loading = ref(false);
|
|
|
|
const loadStatistics = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const clubId = localStorage.getItem('clubId');
|
|
const response = await apiClient.post('/api/club/statistics', {
|
|
clubId,
|
|
days: parseInt(selectedPeriod.value)
|
|
});
|
|
|
|
stats.value = response.data;
|
|
console.log('[통계] API 응답:', response.data);
|
|
} catch (error) {
|
|
console.error('통계 데이터 로드 오류:', error);
|
|
} finally {
|
|
console.log('[통계] 최종 stats 상태:', stats.value);
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit'
|
|
});
|
|
};
|
|
|
|
const scoreDistributionChartData = computed(() => ({
|
|
labels: stats.value.scoreDistribution?.map(d => d.range) || [],
|
|
datasets: [{
|
|
label: '게임 수',
|
|
data: stats.value.scoreDistribution?.map(d => d.count) || [],
|
|
backgroundColor: '#4CAF50'
|
|
}]
|
|
}));
|
|
|
|
const scoreDistributionChartOptions = {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
title: {
|
|
display: false
|
|
}
|
|
}
|
|
};
|
|
|
|
const attendanceTrendChartData = computed(() => ({
|
|
labels: stats.value.attendanceTrend?.map(d => formatDate(d.date)) || [],
|
|
datasets: [{
|
|
label: '참석률',
|
|
data: stats.value.attendanceTrend?.map(d => d.attendance) || [],
|
|
borderColor: '#2196F3',
|
|
tension: 0.1
|
|
}]
|
|
}));
|
|
|
|
const attendanceTrendChartOptions = {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: { y: { beginAtZero: true, max: 100 } }
|
|
};
|
|
|
|
// 추가 통계용 차트 데이터 (순서 주의!)
|
|
const participantTrendChartData = computed(() => {
|
|
const arr = stats.value.participantTrend || [];
|
|
return {
|
|
labels: arr.map(x => formatDate(x.date)),
|
|
datasets: [{
|
|
label: '참가자 수',
|
|
data: arr.map(x => x.participantCount),
|
|
backgroundColor: '#42b983',
|
|
borderColor: '#42b983',
|
|
fill: false
|
|
}]
|
|
};
|
|
});
|
|
|
|
function getHandicapTrendChartData(trendArr) {
|
|
if (!trendArr) return null;
|
|
return {
|
|
labels: trendArr.map(t => formatDate(t.date)),
|
|
datasets: [{
|
|
label: '평균 핸디캡',
|
|
data: trendArr.map(t => t.avgHandicap),
|
|
borderColor: '#f87979',
|
|
fill: false
|
|
}]
|
|
};
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Google Analytics 페이지뷰 추적
|
|
gtag.pageview({
|
|
page_title: '통계',
|
|
page_path: window.location.pathname,
|
|
page_location: window.location.href
|
|
});
|
|
loadStatistics();
|
|
});
|
|
|
|
// 기간 변경 시 GA 이벤트
|
|
const onPeriodChange = () => {
|
|
gtag.event('change_period', {
|
|
'event_category': '통계',
|
|
'event_label': selectedPeriod.value + '일',
|
|
'value': Number(selectedPeriod.value)
|
|
});
|
|
loadStatistics();
|
|
};
|
|
|
|
return {
|
|
stats,
|
|
selectedPeriod,
|
|
loading,
|
|
loadStatistics,
|
|
formatDate,
|
|
scoreDistributionChartData,
|
|
scoreDistributionChartOptions,
|
|
attendanceTrendChartData,
|
|
attendanceTrendChartOptions,
|
|
participantTrendChartData,
|
|
getHandicapTrendChartData,
|
|
onPeriodChange
|
|
};
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.statistics-container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 32px 16px;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.statistics-container {
|
|
padding: 12px 2px;
|
|
}
|
|
.stats-summary,
|
|
.rankings-container,
|
|
.meetings-container,
|
|
.charts-container {
|
|
grid-template-columns: 1fr !important;
|
|
gap: 12px;
|
|
display: block !important;
|
|
}
|
|
.stat-card, .rankings-card, .meetings-card, .chart-card {
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
}
|
|
.stat-card table,
|
|
.rankings-card table,
|
|
.meetings-card table {
|
|
min-width: 320px;
|
|
display: block;
|
|
overflow-x: auto;
|
|
}
|
|
table {
|
|
min-width: 320px;
|
|
font-size: 13px;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 200px;
|
|
}
|
|
|
|
.spinner {
|
|
border: 4px solid #f3f3f3;
|
|
border-top: 4px solid #3498db;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
.period-selector {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.period-selector select {
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
border: 1px solid #ddd;
|
|
}
|
|
|
|
.stats-summary,
|
|
.rankings-container,
|
|
.meetings-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
|
gap: 24px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
|
|
.stat-card h3 {
|
|
margin-top: 0;
|
|
margin-bottom: 15px;
|
|
color: #333;
|
|
}
|
|
|
|
.charts-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
|
|
.chart-card {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
min-width: 0;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.stat-card table,
|
|
.rankings-card table,
|
|
.meetings-card table {
|
|
width: 100%;
|
|
min-width: unset;
|
|
display: table;
|
|
overflow-x: unset;
|
|
}
|
|
|
|
|
|
.chart-card h3 {
|
|
margin-top: 0;
|
|
margin-bottom: 15px;
|
|
color: #333;
|
|
}
|
|
|
|
.rankings-container,
|
|
.meetings-container {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.rankings-card,
|
|
.meetings-card {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
min-width: 400px;
|
|
border-collapse: collapse;
|
|
margin-top: 10px;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
overflow-x: auto;
|
|
display: block;
|
|
}
|
|
|
|
|
|
th, td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
|
|
th {
|
|
background-color: #f5f5f5;
|
|
font-weight: 600;
|
|
}
|
|
|
|
tr:hover {
|
|
background-color: #f9f9f9;
|
|
}
|
|
.continued-stats-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
|
gap: 24px;
|
|
margin-bottom: 40px;
|
|
}
|
|
.continued-stats-card {
|
|
background: #fff;
|
|
border-radius: 10px;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
|
padding: 20px;
|
|
min-width: 0;
|
|
margin-bottom: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.continued-stats-card table {
|
|
width: 100%;
|
|
min-width: unset;
|
|
display: table;
|
|
overflow-x: unset;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.continued-stats-container {
|
|
grid-template-columns: 1fr;
|
|
gap: 12px;
|
|
}
|
|
.continued-stats-card {
|
|
padding: 12px;
|
|
}
|
|
.continued-stats-card table {
|
|
min-width: 320px;
|
|
display: block;
|
|
overflow-x: auto;
|
|
}
|
|
}
|
|
|
|
</style>
|