Files
bowlingManager/frontend/src/views/club/ScoreManagement.vue
T
2025-07-03 08:36:26 +09:00

635 lines
20 KiB
Vue

<template>
<div class="score-management">
<div class="header">
<div class="meeting-info">
<h2>{{ meetingTitle }}</h2>
<div class="meeting-details">
<span><i class="pi pi-calendar"></i> {{ meetingDate }}</span>
<span><i class="pi pi-map-marker"></i> {{ meetingLocation }}</span>
<span><i class="pi pi-users"></i> 참가자: {{ participants.length }}</span>
</div>
</div>
<div class="actions">
<Button label="저장" icon="pi pi-save" @click="saveAllScores" />
<Button label="내보내기" icon="pi pi-file-excel" class="p-button-success ml-2" @click="exportScores" />
<Button label="뒤로" icon="pi pi-arrow-left" class="p-button-secondary ml-2" @click="goBack" />
</div>
</div>
<div class="card">
<TabView>
<TabPanel header="점수 입력">
<div class="filter-section mb-3">
<div class="grid">
<div class="col-12 md:col-4">
<span class="p-input-icon-left w-full">
<i class="pi pi-search" />
<InputText v-model="filters.global" placeholder="이름 검색" class="w-full" />
</span>
</div>
<div class="col-12 md:col-4">
<Select v-model="filters.team" :options="teamOptions" optionLabel="name"
placeholder="팀 선택" class="w-full" />
</div>
<div class="col-12 md:col-4">
<MultiSelect v-model="selectedGames" :options="gameOptions" optionLabel="name"
placeholder="게임 선택" class="w-full" />
</div>
</div>
</div>
<DataTable :value="filteredParticipants" :paginator="true" :rows="10"
stripedRows responsiveLayout="scroll"
v-model:expandedRows="expandedRows">
<Column :expander="true" headerStyle="width: 3rem" />
<Column field="name" header="이름" sortable style="width: 15%"></Column>
<Column field="teamNumber" header="팀" sortable style="width: 8%"></Column>
<Column field="handicap" header="핸디캡" sortable style="width: 10%">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.handicap"
:min="0" :max="50"
@blur="calculateTotal(slotProps.data)" />
</template>
</Column>
<template v-for="game in gameCount" :key="game">
<Column :field="`game${game}`" :header="`G${game}`" sortable style="width: 8%">
<template #body="slotProps">
<InputNumber v-model="slotProps.data[`game${game}`]"
:min="0" :max="300"
@blur="calculateTotal(slotProps.data)" />
</template>
</Column>
</template>
<Column field="totalPins" header="총점" sortable style="width: 10%"></Column>
<Column field="totalWithHandicap" header="핸디포함" sortable style="width: 10%"></Column>
<Column field="average" header="에버리지" sortable style="width: 10%"></Column>
<template #expansion="slotProps">
<div class="score-history p-3">
<h4>이전 점수 기록</h4>
<DataTable :value="slotProps.data.history" responsiveLayout="scroll">
<Column field="date" header="날짜"></Column>
<Column field="location" header="장소"></Column>
<Column v-for="game in 3" :key="game" :field="`game${game}`" :header="`G${game}`"></Column>
<Column field="average" header="에버리지"></Column>
</DataTable>
</div>
</template>
</DataTable>
</TabPanel>
<TabPanel header="팀별 점수">
<div class="team-scores">
<div class="grid">
<div v-for="team in teamScores" :key="team.teamNumber" class="col-12 md:col-6 lg:col-4">
<div class="team-card">
<div class="team-header">
<h3> {{ team.teamNumber }}</h3>
<div class="team-stats">
<span>인원: {{ team.members.length }}</span>
<span>평균: {{ team.average.toFixed(1) }}</span>
</div>
</div>
<DataTable :value="team.members" responsiveLayout="scroll">
<Column field="name" header="이름"></Column>
<Column v-for="game in gameCount" :key="game" :field="`game${game}`" :header="`G${game}`"></Column>
<Column field="totalWithHandicap" header="총점"></Column>
</DataTable>
<div class="team-totals">
<div class="grid">
<div v-for="game in gameCount" :key="game" class="col">
<div class="game-total">
<span>G{{ game }}: {{ team.gameTotals[game-1] }}</span>
</div>
</div>
<div class="col">
<div class="game-total total">
<span>총점: {{ team.total }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</TabPanel>
<TabPanel header="개인 순위">
<div class="ranking-filters mb-3">
<div class="grid">
<div class="col-12 md:col-4">
<Select v-model="rankingType" :options="rankingOptions" optionLabel="name"
placeholder="순위 유형" class="w-full" />
</div>
<div class="col-12 md:col-4">
<Select v-model="rankingGame" :options="gameRankingOptions" optionLabel="name"
placeholder="게임 선택" class="w-full" />
</div>
</div>
</div>
<DataTable :value="individualRankings" :paginator="true" :rows="10"
stripedRows responsiveLayout="scroll">
<Column field="rank" header="순위" style="width: 8%">
<template #body="slotProps">
<span class="rank-badge" :class="getRankClass(slotProps.data.rank)">
{{ slotProps.data.rank }}
</span>
</template>
</Column>
<Column field="name" header="이름" sortable style="width: 15%"></Column>
<Column field="teamNumber" header="팀" sortable style="width: 8%"></Column>
<Column field="handicap" header="핸디캡" sortable style="width: 10%"></Column>
<Column v-if="rankingGame.code === 'all'"
v-for="game in gameCount" :key="game"
:field="`game${game}`" :header="`G${game}`" sortable style="width: 8%"></Column>
<Column v-if="rankingGame.code !== 'all'"
:field="rankingGame.code" :header="rankingGame.name" sortable style="width: 12%"></Column>
<Column field="totalPins" header="총점" sortable style="width: 10%"></Column>
<Column field="totalWithHandicap" header="핸디포함" sortable style="width: 10%"></Column>
<Column field="average" header="에버리지" sortable style="width: 10%"></Column>
</DataTable>
</TabPanel>
<TabPanel header="팀 순위">
<DataTable :value="teamRankings" :paginator="true" :rows="10"
stripedRows responsiveLayout="scroll">
<Column field="rank" header="순위" style="width: 8%">
<template #body="slotProps">
<span class="rank-badge" :class="getRankClass(slotProps.data.rank)">
{{ slotProps.data.rank }}
</span>
</template>
</Column>
<Column field="teamNumber" header="팀" style="width: 8%"></Column>
<Column field="memberCount" header="인원" style="width: 8%"></Column>
<Column v-for="game in gameCount" :key="game" :field="`game${game}`" :header="`G${game}`" style="width: 10%"></Column>
<Column field="total" header="총점" style="width: 10%"></Column>
<Column field="average" header="평균" style="width: 10%"></Column>
</DataTable>
</TabPanel>
</TabView>
</div>
<!-- 내보내기 다이얼로그 -->
<Dialog v-model:visible="exportDialog" :style="{width: '450px'}"
header="점수 내보내기" :modal="true">
<div class="export-options">
<div class="field">
<label for="exportFormat">파일 형식</label>
<Select id="exportFormat" v-model="exportFormat" :options="exportFormatOptions"
optionLabel="name" placeholder="형식 선택" class="w-full" />
</div>
<div class="field">
<label for="exportContent">내보낼 내용</label>
<MultiSelect id="exportContent" v-model="exportContent" :options="exportContentOptions"
optionLabel="name" placeholder="내용 선택" class="w-full" />
</div>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="exportDialog = false" />
<Button label="내보내기" icon="pi pi-download" @click="downloadExport" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import scoreService from '@/services/scoreService';
import { useGtag } from 'vue-gtag-next';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const clubId = ref(localStorage.getItem('clubId') || null);
// 모임 정보
const meetingId = ref(route.query.meetingId || null);
const meetingTitle = ref('');
const meetingDate = ref('');
const meetingLocation = ref('');
// 점수 관련 상태
const participants = ref([]);
const expandedRows = ref([]);
const gameCount = ref(3);
const loading = ref(false);
// 필터 관련 상태
const filters = reactive({
global: '',
team: null
});
// 팀 옵션
const teamOptions = ref([
{ name: '전체 팀', code: null }
]);
// 게임 옵션
const gameOptions = computed(() => {
return Array.from({ length: gameCount.value }, (_, i) => ({
name: `게임 ${i + 1}`,
code: `game${i + 1}`
}));
});
const selectedGames = ref([]);
// 순위 관련 상태
const rankingType = ref({ name: '핸디포함', code: 'handicap' });
const rankingOptions = ref([
{ name: '스크래치', code: 'scratch' },
{ name: '핸디포함', code: 'handicap' }
]);
const rankingGame = ref({ name: '전체', code: 'all' });
const gameRankingOptions = computed(() => {
const options = [{ name: '전체', code: 'all' }];
for (let i = 1; i <= gameCount.value; i++) {
options.push({ name: `게임 ${i}`, code: `game${i}` });
}
return options;
});
// 내보내기 관련 상태
const exportDialog = ref(false);
const exportFormat = ref({ name: 'Excel', code: 'xlsx' });
const exportFormatOptions = ref([
{ name: 'Excel', code: 'xlsx' },
{ name: 'CSV', code: 'csv' },
{ name: 'PDF', code: 'pdf' }
]);
const exportContent = ref([]);
const exportContentOptions = ref([
{ name: '개인 점수', code: 'individual' },
{ name: '팀별 점수', code: 'team' },
{ name: '개인 순위', code: 'individual_rank' },
{ name: '팀 순위', code: 'team_rank' }
]);
// 필터링된 참가자 목록
const filteredParticipants = computed(() => {
if (!participants.value) return [];
return participants.value.filter(participant => {
// 이름 필터
if (filters.global && !participant.name.toLowerCase().includes(filters.global.toLowerCase())) {
return false;
}
// 팀 필터
if (filters.team && participant.teamNumber !== filters.team.code) {
return false;
}
return true;
});
});
// 팀별 점수
const teamScores = computed(() => {
const teams = {};
// 팀별로 참가자 그룹화
participants.value.forEach(participant => {
const teamNumber = participant.teamNumber;
if (!teams[teamNumber]) {
teams[teamNumber] = {
teamNumber,
members: [],
gameTotals: Array(gameCount.value).fill(0),
total: 0,
average: 0
};
}
teams[teamNumber].members.push(participant);
// 게임별 팀 총점 계산
for (let i = 1; i <= gameCount.value; i++) {
const gameScore = participant[`game${i}`];
const handicap = participant.handicap || 0;
teams[teamNumber].gameTotals[i-1] += (gameScore + handicap);
}
// 팀 총점 계산
teams[teamNumber].total += participant.totalWithHandicap || 0;
});
// 팀 평균 계산
Object.values(teams).forEach(team => {
if (team.members.length > 0) {
team.average = team.total / (team.members.length * gameCount.value);
}
});
return Object.values(teams).sort((a, b) => a.teamNumber - b.teamNumber);
});
// 개인 순위
const individualRankings = computed(() => {
if (!participants.value.length) return [];
const field = rankingType.value.code === 'handicap' ? 'totalWithHandicap' : 'totalPins';
const gameField = rankingGame.value.code;
let sortedParticipants = [...participants.value];
if (gameField === 'all') {
// 전체 게임 기준 정렬
sortedParticipants.sort((a, b) => b[field] - a[field]);
} else {
// 특정 게임 기준 정렬
sortedParticipants.sort((a, b) => {
const scoreA = (a[gameField] || 0) + (rankingType.value.code === 'handicap' ? (a.handicap || 0) : 0);
const scoreB = (b[gameField] || 0) + (rankingType.value.code === 'handicap' ? (b.handicap || 0) : 0);
return scoreB - scoreA;
});
}
// 순위 부여
return sortedParticipants.map((participant, index) => ({
...participant,
rank: index + 1
}));
});
// 팀 순위
const teamRankings = computed(() => {
if (!teamScores.value.length) return [];
const rankedTeams = teamScores.value.map(team => ({
...team,
memberCount: team.members.length,
game1: team.gameTotals[0],
game2: team.gameTotals[1],
game3: team.gameTotals[2]
}));
// 총점 기준 정렬
rankedTeams.sort((a, b) => b.total - a.total);
// 순위 부여
return rankedTeams.map((team, index) => ({
...team,
rank: index + 1
}));
});
// 순위 클래스 반환
const getRankClass = (rank) => {
if (rank === 1) return 'rank-first';
if (rank === 2) return 'rank-second';
if (rank === 3) return 'rank-third';
return '';
};
// 총점 및 에버리지 계산
const calculateTotal = (participant) => {
let totalPins = 0;
let validGames = 0;
// 총점 계산
for (let i = 1; i <= gameCount.value; i++) {
const score = participant[`game${i}`];
if (score && score > 0) {
totalPins += score;
validGames++;
}
}
participant.totalPins = totalPins;
participant.totalWithHandicap = totalPins + (participant.handicap * validGames);
participant.average = validGames > 0 ? (totalPins / validGames).toFixed(1) : 0;
};
// 모든 점수 저장
// 점수 저장 버튼 클릭 시 GA 이벤트 추적
const saveAllScores = async () => {
const gtag = useGtag();
gtag.event('save_scores', {
event_category: '점수관리',
event_label: '점수 저장'
});
try {
loading.value = true;
await scoreService.saveScores(meetingId.value, participants.value);
toast.add({ severity: 'success', summary: '성공', detail: '점수가 성공적으로 저장되었습니다.', life: 3000 });
} catch (error) {
console.error('점수 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '점수 저장 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 점수 내보내기 다이얼로그 열기
// 점수 내보내기 버튼 클릭 시 GA 이벤트 추적
const exportScores = () => {
const gtag = useGtag();
gtag.event('export_scores', {
event_category: '점수관리',
event_label: '점수 내보내기'
});
exportContent.value = [exportContentOptions.value[0]];
exportDialog.value = true;
};
// 점수 내보내기 다운로드
const downloadExport = async () => {
try {
const format = exportFormat.value.code;
const content = exportContent.value.map(item => item.code);
await scoreService.exportScores(meetingId.value, format, content);
exportDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '점수가 성공적으로 내보내졌습니다.', life: 3000 });
} catch (error) {
console.error('점수 내보내기 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '점수 내보내기 중 오류가 발생했습니다.', life: 3000 });
}
};
// 뒤로 가기
// 뒤로가기 버튼 클릭 시 GA 이벤트 추적
const goBack = () => {
const gtag = useGtag();
gtag.event('go_back', {
event_category: '점수관리',
event_label: '뒤로가기'
});
router.push('/clubs/meetings');
};
// 컴포넌트 마운트 시 데이터 로드
// 페이지 진입 시 Google Analytics 페이지뷰 추적
onMounted(async () => {
const gtag = useGtag();
gtag.pageview({
page_title: '점수관리',
page_path: window.location.pathname,
page_location: window.location.href
});
if (!clubId.value) {
toast.add({ severity: 'error', summary: '오류', detail: '클럽 ID가 설정되지 않았습니다.', life: 3000 });
return;
}
if (!meetingId.value) {
toast.add({ severity: 'error', summary: '오류', detail: '모임 ID가 설정되지 않았습니다.', life: 3000 });
router.push('/clubs/meetings');
return;
}
loading.value = true;
try {
// 모임 정보 가져오기
const meetingResponse = await meetingService.getMeetingById(meetingId.value);
const meeting = meetingResponse.data;
meetingTitle.value = meeting.title;
meetingDate.value = new Date(meeting.date).toLocaleDateString();
meetingLocation.value = meeting.location;
gameCount.value = meeting.gameCount || 3;
// 점수 데이터 가져오기
const scoresResponse = await scoreService.getMeetingScores(meetingId.value);
participants.value = scoresResponse.data;
// 팀 옵션 설정
const teams = new Set(participants.value.map(p => p.teamNumber));
teamOptions.value = [
{ name: '전체 팀', code: null },
...Array.from(teams).map(team => ({ name: `팀 ${team}`, code: team }))
];
// 각 참가자의 총점 및 에버리지 계산
participants.value.forEach(participant => {
calculateTotal(participant);
});
} catch (error) {
console.error('데이터 로드 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '데이터를 로드하는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.score-management {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.meeting-info h2 {
margin: 0 0 0.5rem 0;
}
.meeting-details {
display: flex;
gap: 1rem;
color: #666;
}
.meeting-details i {
margin-right: 0.25rem;
}
.filter-section {
margin-bottom: 1rem;
}
.score-history h4 {
margin-top: 0;
margin-bottom: 1rem;
}
.team-card {
background-color: #f8f9fa;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.team-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.team-header h3 {
margin: 0;
}
.team-stats {
display: flex;
gap: 1rem;
font-size: 0.9rem;
}
.team-totals {
margin-top: 1rem;
padding-top: 0.5rem;
border-top: 1px solid #ddd;
}
.game-total {
text-align: center;
font-weight: bold;
padding: 0.5rem;
background-color: #f0f0f0;
border-radius: 4px;
}
.game-total.total {
background-color: #e6f7ff;
}
.rank-badge {
display: inline-block;
width: 2rem;
height: 2rem;
line-height: 2rem;
text-align: center;
border-radius: 50%;
font-weight: bold;
}
.rank-first {
background-color: #ffd700;
color: #333;
}
.rank-second {
background-color: #c0c0c0;
color: #333;
}
.rank-third {
background-color: #cd7f32;
color: #fff;
}
.export-options {
padding: 1rem 0;
}
</style>