대시보드, 통계 정리
This commit is contained in:
@@ -626,45 +626,39 @@ exports.getMemberStats = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다음 모임 정보 조회
|
// 다음 모임 정보 여러 개 조회
|
||||||
exports.getNextMeeting = async (req, res) => {
|
exports.getNextMeetings = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { clubId } = req.body;
|
const { clubId } = req.body;
|
||||||
|
|
||||||
if (!clubId) {
|
if (!clubId) {
|
||||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||||
}
|
}
|
||||||
|
// 오늘 이후의 예정된 모임 중 status가 active인 것 3개까지 오름차순 정렬
|
||||||
const nextMeeting = await Event.findOne({
|
const nextMeetings = await Event.findAll({
|
||||||
where: {
|
where: {
|
||||||
clubId,
|
clubId,
|
||||||
startDate: { [Op.gt]: new Date() },
|
startDate: { [Op.gt]: new Date() },
|
||||||
status: 'active'
|
status: { [Op.or]: ['ready', 'active'] }
|
||||||
},
|
},
|
||||||
include: [{
|
include: [{
|
||||||
model: EventParticipant,
|
model: EventParticipant,
|
||||||
where: {
|
where: {
|
||||||
status: { [Op.ne]: '취소' }
|
status: { [Op.ne]: 'canceled' }
|
||||||
},
|
},
|
||||||
required: false
|
required: false
|
||||||
}],
|
}],
|
||||||
order: [['startDate', 'ASC']]
|
order: [['startDate', 'ASC']],
|
||||||
|
limit: 3
|
||||||
});
|
});
|
||||||
|
const meetings = nextMeetings.map(meeting => ({
|
||||||
if (!nextMeeting) {
|
date: meeting.startDate,
|
||||||
return res.json(null);
|
participantsCount: meeting.EventParticipants ? meeting.EventParticipants.length : 0,
|
||||||
}
|
title: meeting.title,
|
||||||
|
location: meeting.location
|
||||||
const meetingData = {
|
}));
|
||||||
date: nextMeeting.startDate,
|
res.json(meetings);
|
||||||
participantsCount: nextMeeting.participants ? nextMeeting.participants.length : 0,
|
|
||||||
title: nextMeeting.title,
|
|
||||||
location: nextMeeting.location
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(meetingData);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('다음 모임 정보 조회 오류:', error);
|
console.error('다음 모임 정보(여러 개) 조회 오류:', error);
|
||||||
res.status(500).json({ message: '다음 모임 정보를 가져오는 중 오류가 발생했습니다.' });
|
res.status(500).json({ message: '다음 모임 정보를 가져오는 중 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -687,7 +681,7 @@ exports.getLastMeeting = async (req, res) => {
|
|||||||
include: [{
|
include: [{
|
||||||
model: EventParticipant,
|
model: EventParticipant,
|
||||||
where: {
|
where: {
|
||||||
status: { [Op.ne]: '취소' }
|
status: { [Op.ne]: 'canceled' }
|
||||||
},
|
},
|
||||||
required: false
|
required: false
|
||||||
}],
|
}],
|
||||||
@@ -700,7 +694,7 @@ exports.getLastMeeting = async (req, res) => {
|
|||||||
|
|
||||||
const meetingData = {
|
const meetingData = {
|
||||||
date: lastMeeting.startDate,
|
date: lastMeeting.startDate,
|
||||||
participantsCount: lastMeeting.participants ? lastMeeting.participants.length : 0,
|
participantsCount: lastMeeting.EventParticipants ? lastMeeting.EventParticipants.length : 0,
|
||||||
title: lastMeeting.title,
|
title: lastMeeting.title,
|
||||||
games: lastMeeting.gameCount || 0
|
games: lastMeeting.gameCount || 0
|
||||||
};
|
};
|
||||||
@@ -743,10 +737,10 @@ exports.getScoreStats = async (req, res) => {
|
|||||||
let totalGames = 0;
|
let totalGames = 0;
|
||||||
|
|
||||||
events.forEach(event => {
|
events.forEach(event => {
|
||||||
event.participants.forEach(participant => {
|
(event.EventParticipants || []).forEach(participant => {
|
||||||
if (participant.scores && participant.scores.length > 0) {
|
if (participant.EventScores && participant.EventScores.length > 0) {
|
||||||
allScores = allScores.concat(participant.scores.map(score => score.score));
|
allScores = allScores.concat(participant.EventScores.map(score => score.score));
|
||||||
totalGames += participant.scores.length;
|
totalGames += participant.EventScores.length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -779,31 +773,55 @@ exports.getTopMembers = async (req, res) => {
|
|||||||
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 활동적인 회원들 가져오기 (게임 수로 정렬)
|
// 활동 회원들 전체 조회 (games 컬럼 제거)
|
||||||
const activeMembers = await Member.findAll({
|
const members = await Member.findAll({
|
||||||
where: {
|
where: {
|
||||||
clubId,
|
clubId,
|
||||||
status: 'active',
|
status: 'active'
|
||||||
games: { [Op.gt]: 0 } // 최소 1게임 이상 플레이한 회원
|
|
||||||
},
|
},
|
||||||
attributes: ['id', 'name', 'games', 'handicap', 'memberType'],
|
attributes: ['id', 'name', 'handicap', 'memberType'],
|
||||||
order: [['games', 'DESC']],
|
limit: 100 // 충분히 넉넉하게 조회
|
||||||
limit: 20 // 에버리지 계산을 위해 여유있게 가져오기
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 각 회원별 실제 게임 수 집계 (EventScore → EventParticipant 조인)
|
||||||
|
const memberIds = members.map(m => m.id);
|
||||||
|
// 1. 회원별 EventParticipant 조회
|
||||||
|
const participants = await EventParticipant.findAll({
|
||||||
|
where: { memberId: memberIds },
|
||||||
|
attributes: ['id', 'memberId']
|
||||||
|
});
|
||||||
|
const participantIdToMemberId = {};
|
||||||
|
participants.forEach(p => { participantIdToMemberId[p.id] = p.memberId; });
|
||||||
|
// 2. EventScore에서 참가자별 점수 조회
|
||||||
|
const scores = await EventScore.findAll({
|
||||||
|
where: { participantId: Object.keys(participantIdToMemberId) },
|
||||||
|
attributes: ['participantId']
|
||||||
|
});
|
||||||
|
// 3. memberId별로 게임수 카운트
|
||||||
|
const gamesMap = {};
|
||||||
|
scores.forEach(s => {
|
||||||
|
const memberId = participantIdToMemberId[s.participantId];
|
||||||
|
gamesMap[memberId] = (gamesMap[memberId] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1게임 이상 플레이한 회원만 필터링
|
||||||
|
const filteredMembers = members.filter(m => (gamesMap[m.id] || 0) > 0);
|
||||||
|
|
||||||
// 각 회원의 에버리지 계산
|
// 각 회원의 에버리지 계산
|
||||||
const membersWithAverage = await Promise.all(
|
const membersWithAverage = await Promise.all(
|
||||||
activeMembers.map(async (member) => {
|
filteredMembers.map(async (member) => {
|
||||||
try {
|
try {
|
||||||
const averageData = await averageService.calculateMemberAverageByClubSetting(member.id, clubId);
|
const averageData = await averageService.calculateMemberAverageByClubSetting(member.id, clubId);
|
||||||
return {
|
return {
|
||||||
...member.toJSON(),
|
...member.toJSON(),
|
||||||
|
games: gamesMap[member.id] || 0,
|
||||||
average: averageData.average
|
average: averageData.average
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`회원 ${member.id}의 에버리지 계산 오류:`, error);
|
console.error(`회원 ${member.id}의 에버리지 계산 오류:`, error);
|
||||||
return {
|
return {
|
||||||
...member.toJSON(),
|
...member.toJSON(),
|
||||||
|
games: gamesMap[member.id] || 0,
|
||||||
average: 0
|
average: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -834,24 +852,23 @@ exports.getRecentMeetings = async (req, res) => {
|
|||||||
const recentMeetings = await Event.findAll({
|
const recentMeetings = await Event.findAll({
|
||||||
where: {
|
where: {
|
||||||
clubId,
|
clubId,
|
||||||
status: 'active',
|
status: { [Op.notIn]: ['canceled', 'deleted'] },
|
||||||
startDate: { [Op.lt]: new Date() }
|
startDate: { [Op.lt]: new Date() }
|
||||||
},
|
},
|
||||||
include: [{
|
include: [{
|
||||||
model: EventParticipant,
|
model: EventParticipant,
|
||||||
where: {
|
where: {
|
||||||
status: { [Op.ne]: '취소' }
|
status: { [Op.ne]: 'canceled' }
|
||||||
},
|
},
|
||||||
required: false
|
required: false
|
||||||
}],
|
}],
|
||||||
order: [['startDate', 'DESC']],
|
order: [['startDate', 'DESC']],
|
||||||
limit: 5
|
limit: 5
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedMeetings = recentMeetings.map(meeting => ({
|
const formattedMeetings = recentMeetings.map(meeting => ({
|
||||||
date: meeting.startDate,
|
date: meeting.startDate,
|
||||||
title: meeting.title,
|
title: meeting.title,
|
||||||
participants: meeting.participants ? meeting.participants.length : 0,
|
participantsCount: meeting.EventParticipants ? meeting.EventParticipants.length : 0,
|
||||||
games: meeting.gameCount || 0
|
games: meeting.gameCount || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ router.post('/members/update', authenticateJWT, clubController.updateClubMember)
|
|||||||
router.post('/members/delete', authenticateJWT, clubController.deleteClubMember);
|
router.post('/members/delete', authenticateJWT, clubController.deleteClubMember);
|
||||||
|
|
||||||
// 다음 모임 정보 조회
|
// 다음 모임 정보 조회
|
||||||
router.post('/meetings/next', authenticateJWT, clubController.getNextMeeting);
|
router.post('/meetings/next', authenticateJWT, clubController.getNextMeetings);
|
||||||
|
|
||||||
// 최근 모임 정보 조회
|
// 최근 모임 정보 조회
|
||||||
router.post('/meetings/last', authenticateJWT, clubController.getLastMeeting);
|
router.post('/meetings/last', authenticateJWT, clubController.getLastMeeting);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<div v-else class="grid">
|
<div v-else class="grid">
|
||||||
<!-- 회원 통계 카드 -->
|
<!-- 회원 통계 카드 -->
|
||||||
<div class="col-12 md:col-6 lg:col-3">
|
<div class="col-12 md:col-6 lg:col-6">
|
||||||
<div class="dashboard-card">
|
<div class="dashboard-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="pi pi-users"></i>
|
<i class="pi pi-users"></i>
|
||||||
@@ -24,51 +24,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 다음 모임 카드 -->
|
|
||||||
<div class="col-12 md:col-6 lg:col-3">
|
|
||||||
<div class="dashboard-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="pi pi-calendar"></i>
|
|
||||||
<h3>다음 모임</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" v-if="nextMeeting">
|
|
||||||
<p>일시: {{ formatDate(nextMeeting.date) }}</p>
|
|
||||||
<p>장소: {{ nextMeeting.location || '미정' }}</p>
|
|
||||||
<p>참가 예정: {{ nextMeeting.participantsCount }}명</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" v-else>
|
|
||||||
<p>예정된 모임이 없습니다.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<Button label="모임 관리" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최근 모임 통계 카드 -->
|
|
||||||
<div class="col-12 md:col-6 lg:col-3">
|
|
||||||
<div class="dashboard-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="pi pi-chart-bar"></i>
|
|
||||||
<h3>최근 모임</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" v-if="lastMeeting">
|
|
||||||
<p>일시: {{ formatDate(lastMeeting.date) }}</p>
|
|
||||||
<p>참가자: {{ lastMeeting.participantsCount }}명</p>
|
|
||||||
<p>게임 수: {{ lastMeeting.games }}게임</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" v-else>
|
|
||||||
<p>최근 모임 기록이 없습니다.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<Button label="기록 보기" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 점수 통계 카드 -->
|
<!-- 점수 통계 카드 -->
|
||||||
<div class="col-12 md:col-6 lg:col-3">
|
<div class="col-12 md:col-6 lg:col-6">
|
||||||
<div class="dashboard-card">
|
<div class="dashboard-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="pi pi-chart-line"></i>
|
<i class="pi pi-chart-line"></i>
|
||||||
@@ -84,17 +41,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 다음 모임 카드(여러 개) -->
|
||||||
|
<div class="col-12 md:col-6 lg:col-6">
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="pi pi-calendar"></i>
|
||||||
|
<h3>다음 모임</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" v-if="nextMeetings && nextMeetings.length">
|
||||||
|
<table class="recent-meetings-table" style="width:100%; border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>일시</th>
|
||||||
|
<th>제목</th>
|
||||||
|
<th>장소</th>
|
||||||
|
<th>참가자</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="meeting in nextMeetings.slice(0, 3)" :key="meeting.id || meeting.date">
|
||||||
|
<td>{{ formatDate(meeting.date) }}</td>
|
||||||
|
<td>{{ meeting.title || '-' }}</td>
|
||||||
|
<td>{{ meeting.location || '미정' }}</td>
|
||||||
|
<td>{{ meeting.participantsCount || meeting.participants || 0 }}명</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" v-else>
|
||||||
|
<p>예정된 모임이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<Button label="모임 관리" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 최근 모임 카드: 최근 3개 리스트로 -->
|
||||||
|
<div class="col-12 md:col-6 lg:col-6">
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="pi pi-chart-bar"></i>
|
||||||
|
<h3>최근 모임</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<table class="recent-meetings-table" style="width:100%; border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>일시</th>
|
||||||
|
<th>제목</th>
|
||||||
|
<th>참가자</th>
|
||||||
|
<th>게임 수</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="meeting in recentMeetings.slice(0, 3)" :key="meeting.id || meeting.date">
|
||||||
|
<td>{{ formatDate(meeting.date) }}</td>
|
||||||
|
<td>{{ meeting.title }}</td>
|
||||||
|
<td>{{ meeting.participantsCount || meeting.participants || 0 }}명</td>
|
||||||
|
<td>{{ meeting.games }}게임</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="recentMeetings.length === 0">
|
||||||
|
<td colspan="4" style="text-align:center;">최근 모임 데이터가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<Button label="기록 보기" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid mt-4">
|
<div class="grid mt-4">
|
||||||
<!-- 상위 회원 순위 -->
|
<!-- 상위 회원 순위 -->
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12">
|
||||||
<div class="dashboard-card">
|
<div class="dashboard-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>에버리지 순위</h3>
|
<h3>에버리지 순위</h3>
|
||||||
</div>
|
</div>
|
||||||
<DataTable :value="topMembers" :rows="5" stripedRows responsiveLayout="scroll">
|
<DataTable :value="topMembers" :rows="5" stripedRows responsiveLayout="scroll">
|
||||||
<Column field="rank" header="#" :body="rankTemplate" style="width: 10%"></Column>
|
<template #empty>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="text-align:center;">순위 데이터가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<Column header="순위" :body="(_, { rowIndex }) => rowIndex + 1" style="width: 10%"></Column>
|
||||||
<Column field="name" header="이름" style="width: 30%"></Column>
|
<Column field="name" header="이름" style="width: 30%"></Column>
|
||||||
<Column field="average" header="에버리지" :body="averageTemplate" style="width: 20%"></Column>
|
<Column field="average" header="에버리지" :body="averageTemplate" style="width: 20%"></Column>
|
||||||
<Column field="games" header="게임 수" style="width: 20%"></Column>
|
<Column field="games" header="게임 수" style="width: 20%"></Column>
|
||||||
@@ -102,21 +136,6 @@
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 최근 모임 목록 -->
|
|
||||||
<div class="col-12 md:col-6">
|
|
||||||
<div class="dashboard-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>최근 모임</h3>
|
|
||||||
</div>
|
|
||||||
<DataTable :value="recentMeetings" :rows="5" stripedRows responsiveLayout="scroll">
|
|
||||||
<Column field="date" header="일시" :body="dateTemplate" style="width: 25%"></Column>
|
|
||||||
<Column field="title" header="제목" style="width: 40%"></Column>
|
|
||||||
<Column field="participants" header="참가자" style="width: 15%"></Column>
|
|
||||||
<Column field="games" header="게임 수" style="width: 20%"></Column>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -142,8 +161,8 @@ const memberStats = ref({
|
|||||||
associateCount: 0
|
associateCount: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// 다음 모임 정보
|
// 다음 모임 정보 (여러 개)
|
||||||
const nextMeeting = ref(null);
|
const nextMeetings = ref([]);
|
||||||
|
|
||||||
// 최근 모임 정보
|
// 최근 모임 정보
|
||||||
const lastMeeting = ref(null);
|
const lastMeeting = ref(null);
|
||||||
@@ -195,11 +214,12 @@ const loadDashboardData = async () => {
|
|||||||
});
|
});
|
||||||
memberStats.value = membersResponse.data;
|
memberStats.value = membersResponse.data;
|
||||||
|
|
||||||
// 다음 모임 정보 로드
|
// 다음 모임 정보 로드 (여러 개)
|
||||||
const nextMeetingResponse = await apiClient.post('/api/club/meetings/next', {
|
const nextMeetingResponse = await apiClient.post('/api/club/meetings/next', {
|
||||||
clubId: clubId.value
|
clubId: clubId.value
|
||||||
});
|
});
|
||||||
nextMeeting.value = nextMeetingResponse.data;
|
const data = nextMeetingResponse.data;
|
||||||
|
nextMeetings.value = Array.isArray(data) ? data : (data ? [data] : []);
|
||||||
|
|
||||||
// 최근 모임 정보 로드
|
// 최근 모임 정보 로드
|
||||||
const lastMeetingResponse = await apiClient.post('/api/club/meetings/last', {
|
const lastMeetingResponse = await apiClient.post('/api/club/meetings/last', {
|
||||||
|
|||||||
@@ -106,75 +106,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== 추가 통계 표시 영역 ===== -->
|
<!-- ===== 추가 통계 표시 영역 (정리) ===== -->
|
||||||
<!-- 1. 회원별 최고/최저/총점 -->
|
<div class="continued-stats-container">
|
||||||
<h3>회원별 최고/최저/총점</h3>
|
<div class="continued-stats-card">
|
||||||
<table>
|
<h3>회원별 최고/최저/총점</h3>
|
||||||
<thead>
|
<table>
|
||||||
<tr><th>이름</th><th>최고점</th><th>최저점</th><th>총점</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>이름</th><th>최고점</th><th>최저점</th><th>총점</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr v-for="m in stats.memberScoreStats" :key="m.memberId">
|
<tbody>
|
||||||
<td>{{ m.name }}</td>
|
<tr v-for="m in stats.memberScoreStats" :key="m.memberId">
|
||||||
<td>{{ m.maxScore }}</td>
|
<td>{{ m.name }}</td>
|
||||||
<td>{{ m.minScore }}</td>
|
<td>{{ m.maxScore }}</td>
|
||||||
<td>{{ m.totalScore }}</td>
|
<td>{{ m.minScore }}</td>
|
||||||
</tr>
|
<td>{{ m.totalScore }}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
<!-- 2. 모임별 최고/최저/평균점수 -->
|
</div>
|
||||||
<h3>모임별 점수 통계(확장)</h3>
|
<div class="continued-stats-card">
|
||||||
<table>
|
<h3>모임별 점수 통계(확장)</h3>
|
||||||
<thead>
|
<table>
|
||||||
<tr><th>모임명</th><th>최고점</th><th>최저점</th><th>평균점</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>모임명</th><th>최고점</th><th>최저점</th><th>평균점</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr v-for="e in stats.meetingScoreStats" :key="e.eventId">
|
<tbody>
|
||||||
<td>{{ e.title }}</td>
|
<tr v-for="e in stats.meetingScoreStats" :key="e.eventId">
|
||||||
<td>{{ e.maxScore }}</td>
|
<td>{{ e.title }}</td>
|
||||||
<td>{{ e.minScore }}</td>
|
<td>{{ e.maxScore }}</td>
|
||||||
<td>{{ e.avgScore }}</td>
|
<td>{{ e.minScore }}</td>
|
||||||
</tr>
|
<td>{{ e.avgScore }}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
<!-- 4. 참가자 수 변화 추이 (그래프) -->
|
</div>
|
||||||
<h3>참가자 수 변화 추이</h3>
|
<div class="continued-stats-card">
|
||||||
<LineChart v-if="participantTrendChartData" :data="participantTrendChartData" />
|
<h3>참가자 수 변화 추이</h3>
|
||||||
|
<LineChart v-if="participantTrendChartData" :data="participantTrendChartData" />
|
||||||
<!-- 5. 회원별 모임 참가 횟수 -->
|
</div>
|
||||||
<h3>회원별 모임 참가 횟수</h3>
|
<div class="continued-stats-card">
|
||||||
<table>
|
<h3>회원별 모임 참가 횟수</h3>
|
||||||
<thead>
|
<table>
|
||||||
<tr><th>이름</th><th>참가 횟수</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>이름</th><th>참가 횟수</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr v-for="m in stats.memberAttendanceStats" :key="m.memberId">
|
<tbody>
|
||||||
<td>{{ m.name }}</td>
|
<tr v-for="m in stats.memberAttendanceStats" :key="m.memberId">
|
||||||
<td>{{ m.attendCount }}</td>
|
<td>{{ m.name }}</td>
|
||||||
</tr>
|
<td>{{ m.attendCount }}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
<!-- 6. 팀별 통계 -->
|
</div>
|
||||||
<h3>팀별 통계</h3>
|
<div class="continued-stats-card">
|
||||||
<table>
|
<h3>팀별 통계</h3>
|
||||||
<thead>
|
<table>
|
||||||
<tr><th>이벤트ID</th><th>팀ID</th><th>평균점수</th><th>최고점</th><th>참가자수</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th>이벤트ID</th><th>팀ID</th><th>평균점수</th><th>최고점</th><th>참가자수</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr v-for="t in stats.teamStats" :key="`${t.eventId}-${t.teamId}`">
|
<tbody>
|
||||||
<td>{{ t.eventId }}</td>
|
<tr v-for="t in stats.teamStats" :key="`${t.eventId}-${t.teamId}`">
|
||||||
<td>{{ t.teamId }}</td>
|
<td>{{ t.eventId }}</td>
|
||||||
<td>{{ t.avgScore }}</td>
|
<td>{{ t.teamId }}</td>
|
||||||
<td>{{ t.maxScore }}</td>
|
<td>{{ t.avgScore }}</td>
|
||||||
<td>{{ t.memberCount }}</td>
|
<td>{{ t.maxScore }}</td>
|
||||||
</tr>
|
<td>{{ t.memberCount }}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- ===== 추가 통계 표시 영역 끝 ===== -->
|
<!-- ===== 추가 통계 표시 영역 끝 ===== -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -319,9 +322,42 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.statistics-container {
|
.statistics-container {
|
||||||
padding: 20px;
|
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 {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -354,20 +390,27 @@ export default {
|
|||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-summary {
|
.stats-summary,
|
||||||
|
.rankings-container,
|
||||||
|
.meetings-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||||
gap: 20px;
|
gap: 24px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.stat-card h3 {
|
.stat-card h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
@@ -376,18 +419,33 @@ export default {
|
|||||||
|
|
||||||
.charts-container {
|
.charts-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
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 {
|
.chart-card h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
@@ -409,10 +467,16 @@ export default {
|
|||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 400px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -427,4 +491,42 @@ th {
|
|||||||
tr:hover {
|
tr:hover {
|
||||||
background-color: #f9f9f9;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user