635 lines
20 KiB
Vue
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>
|