랭킹표시 수정

This commit is contained in:
2025-05-17 07:53:05 +09:00
parent a2b5ce88a6
commit b03f8d2d35
13 changed files with 1284 additions and 203 deletions
+3 -3
View File
@@ -84,11 +84,11 @@ exports.getEventByPublicHash = async (event, token = null) => {
try { try {
const allMembers = await Member.findAll({ const allMembers = await Member.findAll({
where: { clubId: event.clubId }, where: { clubId: event.clubId },
attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average']
}); });
const participants = await EventParticipant.findAll({ const participants = await EventParticipant.findAll({
where: { eventId: event.id, status: { [Op.ne]: '취소' } }, where: { eventId: event.id, status: { [Op.ne]: '취소' } },
include: [ { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] } ] include: [ { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] } ]
}); });
const allMemberObjs = allMembers.map(m => m.dataValues); const allMemberObjs = allMembers.map(m => m.dataValues);
const participantMemberIds = [ const participantMemberIds = [
@@ -293,7 +293,7 @@ exports.addPublicGuest = async (req, res) => {
} }
// 여성 핸디캡 적용 // 여성 핸디캡 적용
let handicap = null; let handicap = 0;
if (gender === '여성' && club.femaleHandicap) { if (gender === '여성' && club.femaleHandicap) {
handicap = club.femaleHandicap; handicap = club.femaleHandicap;
} }
+1
View File
@@ -1,4 +1,5 @@
<template> <template>
<Toast />
<template v-if="isRouterReady"> <template v-if="isRouterReady">
<AppPublicLayout v-if="isPublicLayout"> <AppPublicLayout v-if="isPublicLayout">
<router-view /> <router-view />
@@ -38,7 +38,7 @@
</div> </div>
<div class="col-12 md:col-6 mb-2"> <div class="col-12 md:col-6 mb-2">
<span class="font-bold text-color-secondary">참가자 :</span> <span class="font-bold text-color-secondary">참가자 :</span>
<span class="ml-2">{{ eventModel.currentParticipants }} / {{ eventModel.maxParticipants }}</span> <span class="ml-2">{{ confirmedCount }} / {{ eventModel.maxParticipants }}</span>
</div> </div>
<div class="col-12 md:col-6 mb-2 flex align-items-center"> <div class="col-12 md:col-6 mb-2 flex align-items-center">
<span class="font-bold text-color-secondary">공개 URL:</span> <span class="font-bold text-color-secondary">공개 URL:</span>
@@ -47,7 +47,7 @@
</div> </div>
<div class="col-12 md:col-6 mb-2"> <div class="col-12 md:col-6 mb-2">
<span class="font-bold text-color-secondary">공개 비밀번호:</span> <span class="font-bold text-color-secondary">공개 비밀번호:</span>
<span class="ml-2">{{ eventModel.publicPassword }}</span> <span class="ml-2">{{ eventModel.accessPassword }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -5,10 +5,20 @@
:loading="loading" :loading="loading"
class="mt-4" class="mt-4"
:paginator="true" :paginator="true"
:rows="10" :rows="30"
stripedRows stripedRows
v-model:filters="filters" v-model:filters="filters"
:sortField="'participant.name'"
:sortOrder="1"
> >
<!-- 순위 컬럼 추가 -->
<Column field="rank" header="순위" sortable style="width: 5rem;">
<template #body="slotProps">
<span :class="'rank-badge rank-' + slotProps.data.rank">
{{ slotProps.data.rank || '-' }}
</span>
</template>
</Column>
<Column field="participant.name" header="참가자" sortable> <Column field="participant.name" header="참가자" sortable>
<template #body="slotProps"> <template #body="slotProps">
{{ slotProps.data.participant?.name }} {{ slotProps.data.participant?.name }}
@@ -28,9 +38,27 @@
</template> </template>
</Column> </Column>
</template> </template>
<!-- 총점 컬럼 추가 -->
<Column field="totalScore" header="총점" sortable>
<template #body="slotProps">
<div class="flex flex-column">
<span>{{ calculateTotalScore(slotProps.data.games) }}</span>
<span class="text-xs text-gray-500" v-if="calculateRawTotalScore(slotProps.data.games) !== '-' && calculateTotalHandicap(slotProps.data.games) > 0">
({{ calculateRawTotalScore(slotProps.data.games) }}+{{ calculateTotalHandicap(slotProps.data.games) }})
</span>
</div>
</template>
</Column>
<Column field="average" header="평균" sortable> <Column field="average" header="평균" sortable>
<template #body="slotProps"> <template #body="slotProps">
{{ calculateAverage(slotProps.data.games) }} <div class="flex flex-column">
<span :class="getScoreClass(calculateAverage(slotProps.data.games))">
{{ calculateAverage(slotProps.data.games) }}
</span>
<span class="text-xs text-gray-500" v-if="calculateRawAverage(slotProps.data.games) !== '-' && calculateTotalHandicap(slotProps.data.games) > 0">
({{ calculateRawAverage(slotProps.data.games) }})
</span>
</div>
</template> </template>
</Column> </Column>
<Column header="작업"> <Column header="작업">
@@ -55,7 +83,7 @@
optionValue="id" optionValue="id"
placeholder="참가자 선택" placeholder="참가자 선택"
:class="{'p-invalid': submitted && !editingScore.participantId}" :class="{'p-invalid': submitted && !editingScore.participantId}"
:disabled="!!editingScore.id" :disabled="editingScore.isEdit"
/> />
<small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small> <small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small>
</div> </div>
@@ -122,6 +150,7 @@
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService'; import eventService from '@/services/eventService';
import { calculateIndividualRankings } from '@/utils/rankingUtils';
const props = defineProps({ const props = defineProps({
eventId: { eventId: {
@@ -368,7 +397,36 @@ const scoreDialogTitle = computed(() => {
const getTotalScore = (game) => { const getTotalScore = (game) => {
if (!game || !game.score || game.score === 0) return null; if (!game || !game.score || game.score === 0) return null;
return game.score + (game.handicap || 0); // 핸디캡 포함 점수가 300점을 넘지 않도록 제한
return Math.min(game.score + (game.handicap || 0), 300);
};
const calculateTotalScore = (games) => {
const validGames = games.filter(game => game && game.score && game.score > 0);
if (!validGames.length) return '-';
const total = validGames.reduce((sum, game) => sum + getTotalScore(game), 0);
return total;
};
const calculateRawTotalScore = (games) => {
const validGames = games.filter(game => game && game.score && game.score > 0);
if (!validGames.length) return '-';
const total = validGames.reduce((sum, game) => sum + game.score, 0);
return total;
};
const calculateTotalHandicap = (games) => {
const validGames = games.filter(game => game && game.score && game.score > 0);
if (!validGames.length) return 0;
const totalHandicap = validGames.reduce((sum, game) => sum + (game.handicap || 0), 0);
return totalHandicap;
};
const calculateRawAverage = (games) => {
const validGames = games.filter(game => game && game.score && game.score > 0);
if (!validGames.length) return '-';
const total = validGames.reduce((sum, game) => sum + game.score, 0);
return (total / validGames.length).toFixed(1);
}; };
const calculateAverage = (games) => { const calculateAverage = (games) => {
@@ -387,6 +445,32 @@ const formatScoreWithHandicap = (game) => {
return `(${game.score}+${handicap})`; return `(${game.score}+${handicap})`;
}; };
// 순위 계산함수
const calculateRanks = (groupedData) => {
if (!groupedData || groupedData.length === 0) return {};
// rankingUtils에서 사용하는 형태로 데이터 변환
const participants = groupedData.map(item => ({
participantId: item.participantId,
name: item.participant.name,
handicap: calculateTotalHandicap(item.games) / item.games.filter(g => g && g.score > 0).length || 0,
birthday: item.participant.birthday || null
}));
// 점수 프록시 데이터 생성
const scoresProxy = {};
groupedData.forEach(item => {
item.games.forEach((game, index) => {
if (game && game.score) {
scoresProxy[`${item.participantId}-${index + 1}`] = game.score;
}
});
});
// rankingUtils를 사용하여 순위 계산
return calculateIndividualRankings(participants, scoresProxy, props.maxGames);
};
const groupedScores = computed(() => { const groupedScores = computed(() => {
if (!participants.value || !scores.value) { if (!participants.value || !scores.value) {
return []; return [];
@@ -418,7 +502,16 @@ const groupedScores = computed(() => {
} }
}); });
return Object.values(grouped); const groupedArray = Object.values(grouped);
// 순위 계산
const ranks = calculateRanks(groupedArray);
// 순위 정보 추가
return groupedArray.map(item => ({
...item,
rank: ranks[item.participantId] || '-'
}));
}); });
// 점수 변경 시 호출 // 점수 변경 시 호출
+79 -10
View File
@@ -130,13 +130,40 @@ const props = defineProps({
visible: Boolean, // Dialog의 v-model:visible과 연결 visible: Boolean, // Dialog의 v-model:visible과 연결
eventStatus: String eventStatus: String
}); });
const emits = defineEmits(['close', 'updated', 'update:visible']); const emits = defineEmits(['close', 'updated', 'update:visible', 'guest-added']);
const selectedMember = ref(null); const selectedMember = ref(null);
const memberOptions = computed(() => { const memberOptions = computed(() => {
// 참가자 목록에서 참가자 그룹 생성
const participantOptions = (props.participants || []).map(m => ({
...m,
group: '참가자',
id: m.participantId,
participantId: m.participantId,
memberId: m.memberId
}));
// 미신청 그룹 생성 (참가자에 없는 회원만)
const unregisteredOptions = (props.availableMembers || [])
.filter(m => {
// 이미 참가자로 등록된 회원은 제외
return !participantOptions.some(p =>
p.memberId === m.memberId ||
(p.Member && m.name && p.Member.name === m.name)
);
})
.map(m => ({
...m,
group: '미신청',
id: m.memberId,
participantId: null,
memberId: m.memberId
}));
// 두 그룹 합치기
const baseOptions = [ const baseOptions = [
...(props.participants || []).map(m => ({ ...m, group: '참가자', id: m.participantId, participantId: m.participantId, memberId: m.memberId })), ...participantOptions,
...(props.availableMembers || []).map(m => ({ ...m, group: '미신청', id: m.memberId, participantId: null, memberId: m.memberId })) ...unregisteredOptions
].sort((a, b) => { ].sort((a, b) => {
const nameA = a.name || ''; const nameA = a.name || '';
const nameB = b.name || ''; const nameB = b.name || '';
@@ -185,8 +212,8 @@ import PublicEventService from '@/services/PublicEventService';
const newGuest = ref({ name: '', phone: '', gender: '' }); const newGuest = ref({ name: '', phone: '', gender: '' });
const genderOptions = [ const genderOptions = [
{ label: '남', value: '남성' }, { label: '남', value: '남성' },
{ label: '여', value: '여성' } { label: '여', value: '여성' }
]; ];
async function addNewGuest() { async function addNewGuest() {
@@ -204,24 +231,66 @@ async function addNewGuest() {
average: newGuest.value.average average: newGuest.value.average
}); });
if (!res || !res.data || !res.data.memberId) throw new Error('게스트 등록 실패'); if (!res || !res.data || !res.data.memberId) throw new Error('게스트 등록 실패');
// 새 게스트를 멤버 옵션에 추가하고 자동 선택 // 새 게스트를 멤버 옵션에 추가하고 자동 선택
const guest = { const guest = {
...res.data, ...res.data,
group: '참가자', group: '참가자',
id: res.data.participantId, id: res.data.participantId,
participantId: res.data.participantId, participantId: res.data.participantId,
memberId: res.data.memberId memberId: res.data.memberId,
status: res.data.status || '참가예정', // 참가 확정 상태 추가
paymentStatus: res.data.paymentStatus || '미납', // 결제 상태 추가
Member: {
name: newGuest.value.name,
gender: newGuest.value.gender,
average: newGuest.value.average || 0,
handicap: res.data.handicap || 0
}
}; };
// options에 직접 push (reactivity 보장 위해 availableMembers에도 추가)
props.availableMembers.push(guest); // 이미 있는 게스트 업데이트 (선택 가능한 회원 목록)
// 참가자 목록에도 추가 const existingGuestIndex = props.availableMembers.findIndex(m =>
props.participants.push(guest); m.name === guest.Member.name && m.group === '게스트');
// 이미 참가자 목록에 있는 게스트 업데이트
const existingParticipantIndex = props.participants.findIndex(p =>
p.memberId === guest.memberId ||
(p.Member && p.Member.name === guest.Member.name && p.status === '미신청'));
// 기존 데이터 업데이트 또는 새 데이터 추가
if (existingGuestIndex !== -1) {
// 기존 게스트 정보 업데이트
Object.assign(props.availableMembers[existingGuestIndex], guest);
} else {
// 새 게스트 추가
props.availableMembers.push(guest);
}
if (existingParticipantIndex !== -1) {
// 기존 참가자 정보 업데이트
Object.assign(props.participants[existingParticipantIndex], guest);
} else {
// 새 참가자 추가
props.participants.push(guest);
}
// 선택값 세팅 // 선택값 세팅
selectedMember.value = guest; selectedMember.value = guest;
selectedParticipantId.value = guest.participantId; selectedParticipantId.value = guest.participantId;
selectedMemberId.value = guest.memberId; selectedMemberId.value = guest.memberId;
// 참가 상태와 회비 납부 상태 자동 선택
console.log(guest);
status.value = guest.status;
paymentStatus.value = guest.paymentStatus;
comment.value = guest.comment || '';
// 입력폼 초기화 // 입력폼 초기화
newGuest.value = { name: '', phone: '', gender: '', average: '' }; newGuest.value = { name: '', phone: '', gender: '', average: '' };
// 부모 컴포넌트에 이벤트 발생
emits('guest-added', guest);
} catch (e) { } catch (e) {
error.value = e.message || '게스트 등록 중 오류 발생'; error.value = e.message || '게스트 등록 중 오류 발생';
} }
@@ -7,6 +7,11 @@
<div v-for="member in members" :key="member.participantId" class="member-row"> <div v-for="member in members" :key="member.participantId" class="member-row">
<div class="member-info-block"> <div class="member-info-block">
<div class="member-header-row"> <div class="member-header-row">
<span v-if="member && (member.participantId || member.id) && (memberRanksRef[member.participantId] || memberRanksRef[member.id])"
class="medal-icon-container">
<i class="pi pi-trophy medal-icon" :class="'medal-' + (memberRanksRef[member.participantId] || memberRanksRef[member.id])"></i>
<span class="medal-rank" :class="'medal-rank-' + (memberRanksRef[member.participantId] || memberRanksRef[member.id])">{{ memberRanksRef[member.participantId] || memberRanksRef[member.id] }}</span>
</span>
<span class="member-name">{{ member.name }}</span> <span class="member-name">{{ member.name }}</span>
<Tag v-if="member.isGuest" value="G" severity="warning" class="ml-2" /> <Tag v-if="member.isGuest" value="G" severity="warning" class="ml-2" />
</div> </div>
@@ -31,6 +36,14 @@
<template v-for="n in gameCount" :key="n"> <template v-for="n in gameCount" :key="n">
<div class="score-input-hc-wrap"> <div class="score-input-hc-wrap">
<div class="score-input-status-wrap"> <div class="score-input-status-wrap">
<!-- 상태 아이콘을 입력 필드 앞으로 이동 -->
<span class="score-status-icon-container">
<i v-if="scoreStates[member.participantId + '-' + n] === 'saving'" class="pi pi-spin pi-spinner score-status-icon" />
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'saved'" class="pi pi-check score-status-icon" />
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'error'" class="pi pi-times score-status-icon" />
</span>
<!-- 점수 입력 필드 -->
<InputText <InputText
v-if="canInputScore" v-if="canInputScore"
v-model.number="scoresProxy[member.participantId + '-' + n]" v-model.number="scoresProxy[member.participantId + '-' + n]"
@@ -43,29 +56,29 @@
@keydown.enter="onScoreEnter(member, n, teamNumber)" @keydown.enter="onScoreEnter(member, n, teamNumber)"
/> />
<span v-else>{{ member.scores?.[n - 1] ?? '-' }}</span> <span v-else>{{ member.scores?.[n - 1] ?? '-' }}</span>
<!-- 핸디캡 포함 점수 표시 -->
<span :class="scoreColorClass(scoreStates[member.participantId + '-' + n], scoresProxy[member.participantId + '-' + n], member.handicap)" <span :class="scoreColorClass(scoreStates[member.participantId + '-' + n], scoresProxy[member.participantId + '-' + n], member.handicap)"
class="score-hc-value score-status-text" class="score-hc-value score-status-text"
:key="scoreStates[member.participantId + '-' + n] + '-' + scoresProxy[member.participantId + '-' + n]"> :key="scoreStates[member.participantId + '-' + n] + '-' + scoresProxy[member.participantId + '-' + n]">
{{ (scoresProxy[member.participantId + '-' + n] !== undefined && scoresProxy[member.participantId + '-' + n] !== '') ? {{ (scoresProxy[member.participantId + '-' + n] !== undefined && scoresProxy[member.participantId + '-' + n] !== '') ?
Number(scoresProxy[member.participantId + '-' + n]) + (Number(member.handicap) || 0) : '' Number(scoresProxy[member.participantId + '-' + n]) + (Number(member.handicap) || 0) : ''
}} }}
<i v-if="scoreStates[member.participantId + '-' + n] === 'saving'" class="pi pi-spin pi-spinner score-status-icon" />
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'saved'" class="pi pi-check score-status-icon" />
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'error'" class="pi pi-times score-status-icon" />
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<div class="member-score-summary"> <div class="member-score-summary">
<span>총점: {{ getMemberTotalScore(member) }}</span> <span>총점: {{ getMemberTotalScore(member) }}</span>
<span class="member-score-avg">/ 에버리지: {{ getMemberAverageScore(member) }}</span> <span class="member-score-avg">/ 평균: {{ getMemberAverageScore(member) }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { toRefs } from 'vue' import { computed, onMounted, watch, ref } from 'vue';
import { calculateIndividualRankings, getRankColorClass } from '@/utils/rankingUtils';
const props = defineProps({ const props = defineProps({
title: String, title: String,
members: Array, members: Array,
@@ -80,6 +93,127 @@ const props = defineProps({
onScoreEnter: Function, onScoreEnter: Function,
getMemberTotalScore: Function, getMemberTotalScore: Function,
getMemberAverageScore: Function, getMemberAverageScore: Function,
scoreColorClass: Function scoreColorClass: Function,
memberRanks: Object
}) })
// 디버깅을 위한 코드
// 순위 데이터를 저장할 로컬 변수
const localRanks = ref({});
// 점수 변경 감지를 위한 참조 변수
const lastScores = ref({});
// 순위 계산 함수 - 순위 계산 유틸리티 사용
const updateLocalRanks = () => {
if (!props.members || props.members.length === 0) return;
// 순위 계산 유틸리티 함수 호출
const newRanks = calculateIndividualRankings(
props.members,
props.scoresProxy || {},
props.gameCount || 3
);
// 순위 맵 갱신
localRanks.value = newRanks;
// 현재 점수 저장
lastScores.value = {};
props.members.forEach(member => {
if (member && member.participantId) {
lastScores.value[member.participantId] = props.getMemberTotalScore ? props.getMemberTotalScore(member) : 0;
}
});
};
// 프록스와 로컬 데이터를 통합한 계산된 순위 객체
const memberRanksRef = computed(() => {
// 무조건 props.memberRanks 사용 - 최종 순위 테이블과 동일한 순위 표시를 위해
return props.memberRanks || {};
});
onMounted(() => {
if (props.members && props.members.length > 0) {
// props가 undefined인 경우 기본값 설정
if (!props.memberRanks) {
updateLocalRanks();
}
// 초기 점수 저장
props.members.forEach(member => {
if (member && member.participantId) {
lastScores.value[member.participantId] = props.getMemberTotalScore ? props.getMemberTotalScore(member) : 0;
}
});
}
})
// props.memberRanks 변경 감지
watch(() => props.memberRanks, (newVal) => {
}, { deep: true })
// 점수 변경 감지
watch(() => props.scoresProxy, () => {
// 점수가 변경되었는지 확인
let scoresChanged = false;
props.members.forEach(member => {
if (member && member.participantId) {
const currentScore = props.getMemberTotalScore ? props.getMemberTotalScore(member) : 0;
const previousScore = lastScores.value[member.participantId] || 0;
if (currentScore !== previousScore) {
scoresChanged = true;
}
}
});
// 점수가 변경되었으면 순위 재계산
if (scoresChanged) {
updateLocalRanks();
}
}, { deep: true })
</script> </script>
<style scoped>
.medal-icon-container {
position: relative;
display: inline-block;
margin-right: 8px;
}
.medal-icon {
font-size: 1.8rem;
color: #eee;
}
/* 메달 색상 */
.medal-1 {
color: gold;
}
.medal-2 {
color: silver;
}
.medal-3 {
color: #cd7f32; /* 동메달 색상 */
}
.medal-rank {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -80%);
font-size: 1rem;
font-weight: bold;
color: black;
/* 글자 테두리 효과 */
text-shadow:
-1px -1px 0 rgba(255, 255, 255, 0.7),
1px -1px 0 rgba(255, 255, 255, 0.7),
-1px 1px 0 rgba(255, 255, 255, 0.7),
1px 1px 0 rgba(255, 255, 255, 0.7);
}
</style>
+532
View File
@@ -0,0 +1,532 @@
/**
* 볼링 점수 기반 순위 계산 유틸리티
* 다양한 이벤트 유형(개인전, 팀전)에 대한 순위 계산 함수 제공
*/
// ================ 점수 계산 함수 ================
/**
* 멤버별 평균 점수 계산 (입력된 점수만 평균, 소수점 1자리)
* @param {Object} member - 참가자 객체
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Number} - 참가자의 평균 점수
*/
export const calculateMemberAverageScore = (member, scoresProxy, gameCount) => {
if (!member || !member.participantId || !gameCount) return 0;
let total = 0, count = 0;
const handicap = Number(member.handicap) || 0;
// 각 게임별 점수 합산 및 플레이한 게임 수 카운트
for (let i = 1; i <= gameCount; i++) {
const scoreKey = `${member.participantId}-${i}`;
const score = scoresProxy[scoreKey];
if (score !== undefined && score !== null && score !== '' && !isNaN(Number(score))) {
total += Number(score) + handicap;
count++;
}
}
return count > 0 ? Math.round((total / count) * 10) / 10 : 0;
};
/**
* 팀 게임별 총점 계산 함수
* @param {Object} team - 팀 객체
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameNumber - 게임 번호
* @returns {Number} - 해당 게임의 팀 총점
*/
export const calculateTeamGameScore = (team, scoresProxy, gameNumber) => {
if (!team || !team.members) return 0;
let total = 0;
// 팀원들의 점수 합산
team.members.forEach(member => {
if (!member || !member.participantId) return;
const scoreKey = `${member.participantId}-${gameNumber}`;
const score = scoresProxy[scoreKey];
if (score !== undefined && score !== null && score !== '') {
const memberHc = Number(member.handicap) || 0;
total += Number(score) + memberHc;
}
});
// 팀 핸디캡 추가
total += Number(team.handicap) || 0;
return total;
};
/**
* 팀 총 핸디캡 계산 함수
* @param {Object} team - 팀 객체
* @returns {Number} - 팀의 총 핸디캡
*/
export const calculateTeamTotalHandicap = (team) => {
if (!team || !team.members) return 0;
let total = 0;
// 팀원들의 핸디캡 합산
team.members.forEach(member => {
const memberHc = Number(member.handicap) || 0;
total += memberHc;
});
// 팀 핸디캡 추가
total += Number(team.handicap) || 0;
return total;
};
/**
* 참가자 총점 계산 함수
* @param {Object} participant - 참가자 객체
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Number} - 참가자의 총점
*/
export const calculateParticipantTotalScore = (participant, scoresProxy, gameCount) => {
if (!participant || !participant.participantId) return 0;
let total = 0;
const handicap = Number(participant.handicap) || 0;
// 각 게임별 점수 합산
for (let i = 1; i <= gameCount; i++) {
const scoreKey = `${participant.participantId}-${i}`;
const score = scoresProxy[scoreKey];
if (score !== undefined && score !== null && score !== '') {
total += Number(score) + handicap;
}
}
return total;
};
/**
* 참가자 평균 점수 계산 함수
* @param {Object} participant - 참가자 객체
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Number} - 참가자의 평균 점수
*/
export const calculateParticipantAverageScore = (participant, scoresProxy, gameCount) => {
if (!participant || !participant.participantId) return 0;
let total = 0;
let playedGames = 0;
const handicap = Number(participant.handicap) || 0;
// 각 게임별 점수 합산 및 플레이한 게임 수 카운트
for (let i = 1; i <= gameCount; i++) {
const scoreKey = `${participant.participantId}-${i}`;
const score = scoresProxy[scoreKey];
if (score !== undefined && score !== null && score !== '') {
total += Number(score) + handicap;
playedGames++;
}
}
return playedGames > 0 ? Math.round(total / playedGames) : 0;
};
/**
* 개인전 순위 계산 함수
* @param {Array} participants - 참가자 배열
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Object} - 참가자 ID를 키로, 순위를 값으로 하는 객체
*/
export const calculateIndividualRankings = (participants, scoresProxy, gameCount) => {
if (!participants || participants.length === 0) return {};
// 참가자별 총점 계산
const participantsWithScores = participants.map(participant => ({
participantId: participant.participantId,
name: participant.name,
totalScore: calculateParticipantTotalScore(participant, scoresProxy, gameCount),
// 동점자 처리를 위한 추가 정보
highestGame: getHighestGameScore(participant, scoresProxy, gameCount),
handicap: Number(participant.handicap) || 0,
scoreDeviation: getScoreDeviation(participant, scoresProxy, gameCount),
birthday: participant.birthday ? new Date(participant.birthday) : new Date('9999-12-31')
}));
// 정렬 기준 적용
// 1. 총점 내림차순
// 2. 동점시 핸디캡이 낮은 순
// 3. 핸디캡도 같으면 점수 편차가 적은 순
// 4. 점수 편차도 같으면 생년월일이 빠른 순
participantsWithScores.sort((a, b) => {
// 1. 총점 비교
if (b.totalScore !== a.totalScore) {
return b.totalScore - a.totalScore;
}
// 2. 핸디캡 비교 (낮은 순)
if (a.handicap !== b.handicap) {
return a.handicap - b.handicap;
}
// 3. 점수 편차 비교 (적은 순)
if (a.scoreDeviation !== b.scoreDeviation) {
return a.scoreDeviation - b.scoreDeviation;
}
// 4. 생년월일 비교 (빠른 순)
return a.birthday - b.birthday;
});
// 동점자 비교 함수 - 모든 기준을 적용하여 동점자 판별
const compareParticipants = (a, b) => {
// 총점, 핸디캡, 점수 편차, 생년월일이 모두 같을 때만 동점으로 처리
return a.totalScore === b.totalScore &&
a.handicap === b.handicap &&
a.scoreDeviation === b.scoreDeviation &&
a.birthday.getTime() === b.birthday.getTime();
};
// assignRanks 함수를 사용하여 순위 부여 (동점자 처리 포함)
return assignRanks(participantsWithScores, 'participantId', compareParticipants);
};
/**
* 팀전 순위 계산 함수
* @param {Array} teams - 팀 배열
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Object} - 팀 ID를 키로, 순위를 값으로 하는 객체
*/
export const calculateTeamRankings = (teams, scoresProxy, gameCount) => {
if (!teams || teams.length === 0) return {};
// 팀별 총점 계산
const teamsWithScores = teams.map(team => {
let teamTotalScore = 0;
// 팀원들의 점수 합산
if (team.members && team.members.length > 0) {
team.members.forEach(member => {
teamTotalScore += calculateParticipantTotalScore(member, scoresProxy, gameCount);
});
}
return {
teamId: team.id || team.teamNumber,
teamNumber: team.teamNumber,
totalScore: teamTotalScore,
// 추후 동점자 처리를 위한 추가 정보
memberCount: team.members ? team.members.length : 0,
teamHandicap: Number(team.handicap) || 0
};
});
// 총점 기준 내림차순 정렬
teamsWithScores.sort((a, b) => b.totalScore - a.totalScore);
// 순위 맵 생성
const rankMap = {};
teamsWithScores.forEach((team, index) => {
if (team.teamId) {
rankMap[team.teamId] = index + 1;
}
});
return rankMap;
};
/**
* 게임별 순위 계산 함수
* @param {Array} participants - 참가자 배열
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameNumber - 게임 번호
* @returns {Object} - 참가자 ID를 키로, 해당 게임의 순위를 값으로 하는 객체
*/
export const calculateGameRankings = (participants, scoresProxy, gameNumber) => {
if (!participants || participants.length === 0 || !gameNumber) return {};
// 참가자별 해당 게임 점수 계산
const participantsWithGameScore = participants.map(participant => {
const scoreKey = `${participant.participantId}-${gameNumber}`;
const score = scoresProxy[scoreKey];
const handicap = Number(participant.handicap) || 0;
let gameScore = 0;
if (score !== undefined && score !== null && score !== '') {
gameScore = Number(score) + handicap;
}
return {
participantId: participant.participantId,
name: participant.name,
gameScore: gameScore,
handicap: handicap
};
});
// 게임 점수 기준 내림차순 정렬
participantsWithGameScore.sort((a, b) => b.gameScore - a.gameScore);
// 동점자 비교 함수 - 게임 점수가 같은 경우 동점으로 처리
const compareParticipants = (a, b) => {
return a.gameScore === b.gameScore;
};
// assignRanks 함수를 사용하여 순위 부여 (동점자 처리 포함)
return assignRanks(participantsWithGameScore, 'participantId', compareParticipants);
};
/**
* 참가자의 최고 게임 점수 구하기
* @param {Object} participant - 참가자 객체
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Number} - 최고 게임 점수
*/
export const getHighestGameScore = (participant, scoresProxy, gameCount) => {
if (!participant || !participant.participantId) return 0;
let highestScore = 0;
const handicap = Number(participant.handicap) || 0;
for (let i = 1; i <= gameCount; i++) {
const scoreKey = `${participant.participantId}-${i}`;
const score = scoresProxy[scoreKey];
if (score !== undefined && score !== null && score !== '') {
const gameScore = Number(score) + handicap;
if (gameScore > highestScore) {
highestScore = gameScore;
}
}
}
return highestScore;
};
/**
* 참가자의 최저 게임 점수 구하기
* @param {Object} participant - 참가자 객체
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Number} - 최저 게임 점수
*/
export const getLowestGameScore = (participant, scoresProxy, gameCount) => {
if (!participant || !participant.participantId) return 0;
let lowestScore = Number.MAX_SAFE_INTEGER;
const handicap = Number(participant.handicap) || 0;
let hasValidScore = false;
for (let i = 1; i <= gameCount; i++) {
const scoreKey = `${participant.participantId}-${i}`;
const score = scoresProxy[scoreKey];
if (score !== undefined && score !== null && score !== '') {
const gameScore = Number(score) + handicap;
if (gameScore < lowestScore) {
lowestScore = gameScore;
hasValidScore = true;
}
}
}
return hasValidScore ? lowestScore : 0;
};
/**
* 참가자의 게임 점수 편차 계산
* @param {Object} participant - 참가자 객체
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Number} - 점수 편차 (최고 점수 - 최저 점수)
*/
export const getScoreDeviation = (participant, scoresProxy, gameCount) => {
const highestScore = getHighestGameScore(participant, scoresProxy, gameCount);
const lowestScore = getLowestGameScore(participant, scoresProxy, gameCount);
return highestScore - lowestScore;
};
/**
* 순위 부여 함수 - 동점자 처리 포함
* @param {Array} items - 정렬된 아이템 배열
* @param {String} idKey - 아이템의 고유 ID를 나타내는 키 이름
* @param {Function} compareFunc - 두 아이템이 동점인지 비교하는 함수 (a, b) => boolean
* @returns {Object} - 아이템 ID를 키로, 순위를 값으로 하는 객체
*/
export const assignRanks = (items, idKey, compareFunc) => {
if (!items || items.length === 0) return {};
const rankMap = {};
// 예외 처리: 아이템이 하나만 있는 경우
if (items.length === 1 && items[0][idKey]) {
rankMap[items[0][idKey]] = 1;
return rankMap;
}
// 동점자 그룹 처리
const groups = [];
let currentGroup = [];
// 첫 번째 아이템 처리
if (items[0][idKey]) {
currentGroup.push(items[0]);
}
// 동점자 그룹화
for (let i = 1; i < items.length; i++) {
const current = items[i];
const prev = items[i-1];
// 아이템 ID가 없는 경우 건너뛰기
if (!current[idKey]) continue;
// 동점 여부 확인
if (compareFunc(current, prev)) {
// 동점자는 현재 그룹에 추가
currentGroup.push(current);
} else {
// 동점이 아니면 현재 그룹 저장하고 새 그룹 시작
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [current];
}
}
// 마지막 그룹 추가
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
// 그룹별 순위 부여
let nextRank = 1;
for (const group of groups) {
// 현재 그룹의 모든 아이템에 동일한 순위 부여
for (const item of group) {
if (item[idKey]) {
rankMap[item[idKey]] = nextRank;
}
}
// 다음 순위는 현재 그룹 크기를 더한 값
nextRank += group.length;
}
return rankMap;
};
/**
* 순위 색상 클래스 결정 함수
* @param {Number} rank - 순위
* @returns {String} - 순위에 따른 CSS 클래스 이름
*/
export const getRankColorClass = (rank) => {
if (!rank || rank <= 0) return 'rank-other';
if (rank <= 5) {
return `rank-${rank}`;
}
return 'rank-other';
};
/**
* 팀 전체 순위 계산 함수
* @param {Array} teams - 팀 배열
* @param {Object} scoresProxy - 점수 프록시 객체
* @param {Number} gameCount - 게임 수
* @returns {Object} - 팀 ID를 키로, 순위를 값으로 하는 객체
*/
export const calculateTeamOverallRankings = (teams, scoresProxy, gameCount) => {
if (!teams || teams.length === 0) return {};
// 팀별 총점 계산
const teamTotals = teams.map(team => {
// 각 게임별 점수 합산
let totalScore = 0;
// 팀의 게임별 점수 및 편차 계산을 위한 배열
const gameScores = [];
for (let i = 1; i <= gameCount; i++) {
const gameScore = calculateTeamGameScore(team, scoresProxy, i);
totalScore += gameScore;
gameScores.push(gameScore);
}
// 팀 점수 편차 계산 (최고 점수 - 최저 점수)
let scoreDeviation = 0;
if (gameScores.length > 0) {
const maxScore = Math.max(...gameScores.filter(score => score > 0));
const minScore = Math.min(...gameScores.filter(score => score > 0));
scoreDeviation = maxScore - minScore;
}
// 팀원들의 평균 생년월일 계산
let averageBirthday = new Date('9999-12-31');
if (team.members && team.members.length > 0) {
const validBirthdays = team.members
.filter(member => member.birthday)
.map(member => new Date(member.birthday));
if (validBirthdays.length > 0) {
// 평균 샜산을 위해 총 밀리초 계산
const totalMs = validBirthdays.reduce((sum, date) => sum + date.getTime(), 0);
averageBirthday = new Date(totalMs / validBirthdays.length);
}
}
return {
teamId: team.id || team.teamNumber,
totalScore: totalScore,
handicapSum: calculateTeamTotalHandicap(team),
memberCount: (team.members ? team.members.length : 0),
scoreDeviation: scoreDeviation,
averageBirthday: averageBirthday
};
});
// 정렬 기준 적용
// 1. 총점 내림차순
// 2. 동점시 핸디캡이 낮은 순
// 3. 핸디캡도 같으면 점수 편차가 적은 순
// 4. 점수 편차도 같으면 생년월일 평균이 빠른 순
teamTotals.sort((a, b) => {
// 1. 총점 비교
if (b.totalScore !== a.totalScore) return b.totalScore - a.totalScore;
// 2. 동점시 핸디캡이 적은 팀이 순위가 높음
if (a.handicapSum !== b.handicapSum) return a.handicapSum - b.handicapSum;
// 3. 핸디캡도 같으면 점수 편차가 적은 팀이 순위가 높음
if (a.scoreDeviation !== b.scoreDeviation) return a.scoreDeviation - b.scoreDeviation;
// 4. 점수 편차도 같으면 팀원들의 평균 생년월일이 빠른 팀이 순위가 높음
return a.averageBirthday - b.averageBirthday;
});
// 동점자 비교 함수 - 모든 기준을 적용하여 동점자 판별
const compareTeams = (a, b) => {
// 총점, 핸디캡 합계, 점수 편차, 평균 생년월일이 모두 같을 때만 동점으로 처리
return a.totalScore === b.totalScore &&
a.handicapSum === b.handicapSum &&
a.scoreDeviation === b.scoreDeviation &&
a.averageBirthday.getTime() === b.averageBirthday.getTime();
};
// assignRanks 함수를 사용하여 순위 부여 (동점자 처리 포함)
return assignRanks(teamTotals, 'teamId', compareTeams);
};
-1
View File
@@ -30,7 +30,6 @@
<Button type="submit" label="클럽 생성하기" class="w-full" :loading="loading" /> <Button type="submit" label="클럽 생성하기" class="w-full" :loading="loading" />
</form> </form>
</div> </div>
<Toast />
</div> </div>
</template> </template>
@@ -23,7 +23,6 @@
@remove-participant="removeParticipant" @remove-participant="removeParticipant"
/> />
<Toast />
</div> </div>
</template> </template>
@@ -103,7 +103,6 @@
<Button label="예" icon="pi pi-check" class="p-button-danger" @click="deleteEvent" /> <Button label="예" icon="pi pi-check" class="p-button-danger" @click="deleteEvent" />
</template> </template>
</Dialog> </Dialog>
<Toast />
</div> </div>
</template> </template>
+53 -15
View File
@@ -25,7 +25,17 @@
sortField="name" sortField="name"
:sortOrder="1" :sortOrder="1"
stripedRows responsiveLayout="scroll"> stripedRows responsiveLayout="scroll">
<Column field="name" header="이름" sortable style="width: 15%"></Column> <Column field="name" header="이름" sortable style="width: 15%">
<template #body="slotProps">
<div class="member-name">
<div class="name">{{ slotProps.data.name }}</div>
<div class="contact-info">
<div v-if="slotProps.data.phone" class="phone">{{ slotProps.data.phone }}</div>
<div v-if="slotProps.data.email" class="email">{{ slotProps.data.email }}</div>
</div>
</div>
</template>
</Column>
<Column field="gender" header="성별" sortable style="width: 10%"></Column> <Column field="gender" header="성별" sortable style="width: 10%"></Column>
<Column field="memberType" header="회원 유형" sortable style="width: 15%"> <Column field="memberType" header="회원 유형" sortable style="width: 15%">
<template #body="slotProps"> <template #body="slotProps">
@@ -177,13 +187,12 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'; import { ref, reactive, computed, onMounted, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import apiClient from '@/services/api';
import clubService from '@/services/clubService'; import clubService from '@/services/clubService';
import { useClubStore } from '@/stores/clubStore'; import { useClubStore } from '@/stores/clubStore';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const clubStore = useClubStore(); const clubStore = useClubStore();
const toast = useToast();
// 회원 유형 목록 // 회원 유형 목록
const memberTypes = ref([ const memberTypes = ref([
@@ -412,23 +421,27 @@ const hideDialog = () => {
// 회원 저장 // 회원 저장
const saveMember = async () => { const saveMember = async () => {
try { try {
const memberData = { const memberData = { ...member.value };
...member.value
};
if (dialogMode.value === 'new') { const isNew = dialogMode.value === 'new';
await clubService.addClubMember(clubId.value, memberData); const response = isNew
toast.add({ severity: 'success', summary: '성공', detail: '새 회원이 추가되었습니다.', life: 3000 }); ? await clubService.addClubMember(clubId.value, memberData)
} else { : await clubService.updateClubMember(clubId.value, memberData.id, memberData);
await clubService.updateClubMember(clubId.value, memberData.id, memberData);
toast.add({ severity: 'success', summary: '성공', detail: '회원 정보가 수정되었습니다.', life: 3000 }); toast.add({
} severity: 'success',
summary: '성공',
detail: isNew ? '새 회원이 추가되었습니다.' : '회원 정보가 수정되었습니다.',
life: 3000
});
hideDialog(); hideDialog();
await loadMembers(); await loadMembers();
} catch (error) { } catch (error) {
console.error('회원 저장 중 오류:', error); console.error('회원 저장 중 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 정보 저장 중 오류가 발생했습니다.', life: 3000 }); // 서버에서 오류 메시지가 있는 경우 해당 메시지 표시
const errorMessage = error.response?.data?.message || '회원 정보 저장 중 오류가 발생했습니다.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} }
}; };
@@ -498,6 +511,31 @@ const statusOptions = ref([
font-weight: bold; font-weight: bold;
} }
/* 회원 이름, 연락처 스타일 */
.member-name {
display: flex;
flex-direction: column;
}
.member-name .name {
font-weight: bold;
margin-bottom: 2px;
}
.member-name .contact-info {
display: flex;
flex-direction: column;
font-size: 0.75rem;
color: var(--text-color-secondary);
}
.member-name .phone,
.member-name .email {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 필터 다이얼로그 스타일 */ /* 필터 다이얼로그 스타일 */
:deep(.p-dialog-content) { :deep(.p-dialog-content) {
padding: 1rem; padding: 1rem;
+18 -11
View File
@@ -283,19 +283,16 @@
/* 순위별 색상 */ /* 순위별 색상 */
.rank-badge.rank-1 { .rank-badge.rank-1 {
color: var(--p-yellow-500); color: gold;
font-weight: 900;
} }
.rank-badge.rank-2 { .rank-badge.rank-2 {
color: var(--p-gray-400); color: silver;
font-weight: 900;
} }
.rank-badge.rank-3 { .rank-badge.rank-3 {
color: var(--p-orange-500); color: #cd7f32;
} font-weight: 900;
.rank-badge.rank-4 {
color: var(--p-blue-400);
}
.rank-badge.rank-5 {
color: var(--p-green-400);
} }
/* 필요시 더 추가 가능 */ /* 필요시 더 추가 가능 */
@@ -324,9 +321,8 @@
} }
.rank-badge { .rank-badge {
font-weight: 900;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--p-yellow-500); color: var(--p-black-500);
} }
.score-table th, .score-table th,
@@ -456,6 +452,14 @@
justify-content: flex-end; justify-content: flex-end;
} }
.score-status-icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
min-width: 20px;
}
.score-input-hc-wrap > .score-input, .score-input-hc-wrap > .score-input,
@@ -499,6 +503,9 @@
.member-score-summary { .member-score-summary {
margin-top: 4px; margin-top: 4px;
font-size: 14px; font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
font-weight: 600; font-weight: 600;
} }
+357 -147
View File
@@ -3,8 +3,8 @@
<h1 v-if="clubName" class="p-text-primary p-text-bold p-text-lg p-mb-2">{{ clubName }}</h1> <h1 v-if="clubName" class="p-text-primary p-text-bold p-text-lg p-mb-2">{{ clubName }}</h1>
<div class="event-public-card"> <div class="event-public-card">
<div v-if="loading" class="text-center">로딩 중...</div> <div v-if="loading" class="text-center">로딩 중...</div>
<div v-else-if="error" class="text-center"> <div v-else-if="error" class="text-center text-4xl">
<i class="pi pi-exclamation-triangle text-red block"></i> <i class="pi pi-exclamation-triangle text-red block text-8xl"></i>
<span class="text-red">{{ error }}</span> <span class="text-red">{{ error }}</span>
</div> </div>
<div v-else-if="!isAuthenticated"> <div v-else-if="!isAuthenticated">
@@ -63,11 +63,11 @@
</div> </div>
<div class="p-text-secondary mb-4">{{ event.description || '' }}</div> <div class="p-text-secondary mb-4">{{ event.description || '' }}</div>
<Divider /> <Divider />
<!-- 팀별 게임별 점수/등수 -->
</div> </div>
<template v-if="teams.length > 0"> <template v-if="teams.length > 0">
<Card class="team-score-table-wrapper mb-4"> <Card class="team-score-table-wrapper mb-4">
<template #content> <template #content>
<!-- 팀별 게임별 점수/등수 -->
<DataTable :value="getAllTeamsWithUnassigned()" class="team-score-table" :responsiveLayout="'scroll'"> <DataTable :value="getAllTeamsWithUnassigned()" class="team-score-table" :responsiveLayout="'scroll'">
<Column header="팀"> <Column header="팀">
<template #body="slotProps"> <template #body="slotProps">
@@ -99,14 +99,67 @@
</DataTable> </DataTable>
</template> </template>
</Card> </Card>
<div v-for="team in getAllTeamsWithUnassigned()" :key="team.id" class="mb-4"> <template v-if="event.status != '완료'">
<div v-for="team in getAllTeamsWithUnassigned()" :key="team.id" class="mb-4">
<Card>
<template #content>
<MemberListCard
:title="parseInt(team.teamNumber) ? '팀 ' + team.teamNumber : '미배정 인원'"
:members="team.members"
:teamHandicap="team.handicap"
:teamNumber="team.teamNumber"
:gameCount="event.gameCount"
:canInputScore="canInputScore"
:scoresProxy="scoresProxy"
:scoreStates="scoreStates"
:onScoreInput="onScoreInput"
:onScoreBlur="onScoreBlur"
:onScoreEnter="onScoreEnter"
:getMemberTotalScore="getMemberTotalScore"
:getMemberAverageScore="getMemberAverageScore"
:scoreColorClass="scoreColorClass"
:memberRanks="memberRankMap"
/>
<!-- 카드 요약(총점/등수/게임별 점수) '팀' 노출 -->
<Divider />
<div v-if="team.teamNumber" class="team-game-scores">
<div class="team-total-row team-total-row-responsive team-total-row-block">
<div class="team-games-grid"
:style="{ gridTemplateColumns: `repeat(${event.gameCount > 3 ? 3 : event.gameCount}, 1fr)` }">
<template v-for="n in event.gameCount" :key="n">
<div class="team-game-cell">
<b>{{ n }}G:</b> {{ getTeamTotalScore(team, n) }}
<span v-if="getRanksByGame(n)[(team.id || team.teamNumber) + ''] !== undefined"
class="rank-badge"
:class="'rank-' + getRanksByGame(n)[(team.id || team.teamNumber) + '']">
({{ getRanksByGame(n)[(team.id || team.teamNumber) + ''] }})
</span>
</div>
</template>
</div>
<div class="team-total-main">
<span class="team-total-label">총점</span>
<span class="team-total-scores">{{ Array.from({ length: event.gameCount }, (_, i) => getTeamTotalScore(team, i + 1)).reduce((a, b) => a + b, 0) }}</span>
<span v-if="getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })"
class="rank-badge"
:class="'rank-' + getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })">
{{ getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' }) }}
</span>
</div>
</div>
</div>
</template>
</Card>
</div>
</template>
</template>
<template v-else>
<template v-if="event.status != '완료'">
<Card> <Card>
<template #content> <template #content>
<MemberListCard <MemberListCard
:title="parseInt(team.teamNumber) ? '팀 ' + team.teamNumber : '미배정 인원'" title="참가자"
:members="team.members" :members="sortedParticipants"
:teamHandicap="team.handicap"
:teamNumber="team.teamNumber"
:gameCount="event.gameCount" :gameCount="event.gameCount"
:canInputScore="canInputScore" :canInputScore="canInputScore"
:scoresProxy="scoresProxy" :scoresProxy="scoresProxy"
@@ -117,56 +170,66 @@
:getMemberTotalScore="getMemberTotalScore" :getMemberTotalScore="getMemberTotalScore"
:getMemberAverageScore="getMemberAverageScore" :getMemberAverageScore="getMemberAverageScore"
:scoreColorClass="scoreColorClass" :scoreColorClass="scoreColorClass"
:memberRanks="memberRankMap"
/> />
<!-- 카드 요약(총점/등수/게임별 점수) '팀' 노출 -->
<Divider />
<div v-if="team.teamNumber" class="team-game-scores">
<div class="team-total-row team-total-row-responsive team-total-row-block">
<div class="team-games-grid"
:style="{ gridTemplateColumns: `repeat(${event.gameCount > 3 ? 3 : event.gameCount}, 1fr)` }">
<template v-for="n in event.gameCount" :key="n">
<div class="team-game-cell">
<b>{{ n }}G:</b> {{ getTeamTotalScore(team, n) }}
<span v-if="getRanksByGame(n)[(team.id || team.teamNumber) + ''] !== undefined"
class="rank-badge"
:class="'rank-' + getRanksByGame(n)[(team.id || team.teamNumber) + '']">
({{ getRanksByGame(n)[(team.id || team.teamNumber) + ''] }})
</span>
</div>
</template>
</div>
<div class="team-total-main">
<span class="team-total-label">총점</span>
<span class="team-total-scores">{{ Array.from({ length: event.gameCount }, (_, i) => getTeamTotalScore(team, i + 1)).reduce((a, b) => a + b, 0) }}</span>
<span v-if="getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })"
class="rank-badge"
:class="'rank-' + getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })">
{{ getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' }) }}
</span>
</div>
</div>
</div>
</template> </template>
</Card> </Card>
</div> </template>
</template> </template>
<template v-else> <template v-if="event.status == '완료'">
<Card> <Card>
<template #title>
<div class="card-title-with-button">
<h2>최종 순위</h2>
</div>
</template>
<template #content> <template #content>
<MemberListCard <DataTable :value="getFinalRankingData()" stripedRows :paginator="true" :rows="30"
title="참가자" paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
:members="sortedParticipants" :rowsPerPageOptions="[5,10,20,30,50]" responsiveLayout="scroll">
:gameCount="event.gameCount"
:canInputScore="canInputScore" <!-- 순위 -->
:scoresProxy="scoresProxy" <Column field="rank" header="순위" :sortable="true" style="width: 3%;">
:scoreStates="scoreStates" <template #body="slotProps">
:onScoreInput="onScoreInput" <span :class="'rank-badge rank-' + slotProps.data.rank">
:onScoreBlur="onScoreBlur" {{ slotProps.data.rank }}
:onScoreEnter="onScoreEnter" </span>
:getMemberTotalScore="getMemberTotalScore" </template>
:getMemberAverageScore="getMemberAverageScore" </Column>
:scoreColorClass="scoreColorClass"
/> <!-- 이름 -->
<Column field="name" header="이름" :sortable="true" style="min-width: 7rem;"></Column>
<!-- 핸디캡 -->
<Column field="handicap" header="핸디캡" :sortable="true"></Column>
<!-- 게임별 점수 -->
<template v-for="n in event.gameCount" :key="n">
<Column :field="'game' + n" :header="n + 'G'" :sortable="true">
<template #body="slotProps">
<span :class="{ 'score-red': slotProps.data['game' + n] >= 200 }">
{{ slotProps.data['game' + n] }}
</span>
</template>
</Column>
</template>
<!-- 총점 -->
<Column field="totalScore" header="총점" :sortable="true">
<template #body="slotProps">
<b>{{ slotProps.data.totalScore }}</b>
</template>
</Column>
<!-- 에버리지 -->
<Column field="average" header="에버리지" :sortable="true">
<template #body="slotProps">
<span :class="{ 'score-red': slotProps.data.average >= 200 }">
{{ slotProps.data.average }}
</span>
</template>
</Column>
</DataTable>
</template> </template>
</Card> </Card>
</template> </template>
@@ -176,12 +239,21 @@
<JoinDialog v-if="showJoinDialog && canJoin" v-model:visible="showJoinDialog" :publicHash="publicHash" <JoinDialog v-if="showJoinDialog && canJoin" v-model:visible="showJoinDialog" :publicHash="publicHash"
:participants="participants" :participants="participants"
:availableMembers="availableMembers" :eventStatus="event.status" :availableMembers="availableMembers" :eventStatus="event.status"
@close="showJoinDialog = false" @updated="reload" /> @close="showJoinDialog = false" @updated="reload" @guest-added="handleGuestAdded" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, reactive, onMounted } from 'vue'; import { ref, reactive, computed, onMounted, watch } from 'vue';
import {
calculateIndividualRankings,
calculateGameRankings,
calculateParticipantTotalScore,
calculateMemberAverageScore,
calculateTeamGameScore,
calculateTeamTotalHandicap,
calculateTeamOverallRankings
} from '@/utils/rankingUtils';
import { debounce } from 'lodash-es'; // lodash ( ) import { debounce } from 'lodash-es'; // lodash ( )
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import MemberListCard from '@/components/public/MemberListCard.vue'; import MemberListCard from '@/components/public/MemberListCard.vue';
@@ -236,6 +308,12 @@ const joinButtonLabel = computed(() => {
// : idle, saving, saved, error // : idle, saving, saved, error
const scoreStates = reactive({}); const scoreStates = reactive({});
// (reactive )
const memberRankMap = reactive({});
//
const gameRanks = reactive({});
// //
// ( ) // ( )
function scoreColorClass(state, value, handicap) { function scoreColorClass(state, value, handicap) {
@@ -271,12 +349,81 @@ const saveScore = debounce(async (member, n, oldValue, newValue, teamNumber) =>
member.scores[n-1] = Number(newValue); member.scores[n-1] = Number(newValue);
} }
setTimeout(() => { scoreStates[key] = 'idle'; }, 1200); // setTimeout(() => { scoreStates[key] = 'idle'; }, 1200); //
//
updateMemberRanks();
} catch (e) { } catch (e) {
scoreStates[key] = 'error'; scoreStates[key] = 'error';
setTimeout(() => { scoreStates[key] = 'idle'; }, 2000); setTimeout(() => { scoreStates[key] = 'idle'; }, 2000);
} }
}, 200); }, 200);
//
const getFinalRankingData = () => {
if (!participants.value || participants.value.length === 0) return [];
//
const activeParticipants = participants.value.filter(p => p && p.status !== '취소');
const ranks = calculateIndividualRankings(activeParticipants, scoresProxy.value, event.value.gameCount);
// memberRankMap
Object.keys(ranks).forEach(participantId => {
memberRankMap[participantId] = ranks[participantId];
});
//
const rankingData = activeParticipants.map(participant => {
const participantId = participant.participantId;
const handicap = Number(participant.handicap) || 0;
//
const gameScores = {};
let totalScore = 0;
let playedGames = 0;
for (let i = 1; i <= event.value.gameCount; i++) {
const scoreKey = `${participantId}-${i}`;
const rawScore = scoresProxy.value[scoreKey];
if (rawScore !== undefined && rawScore !== null && rawScore !== '') {
const score = Number(rawScore);
// 300
const scoreWithHc = Math.min(score + handicap, 300);
gameScores[`rawGame${i}`] = score;
gameScores[`game${i}`] = scoreWithHc;
totalScore += scoreWithHc;
playedGames++;
} else {
gameScores[`rawGame${i}`] = 0;
gameScores[`game${i}`] = 0;
}
}
// ( 1 )
const average = playedGames > 0 ? (totalScore / playedGames).toFixed(1) : 0;
return {
participantId,
rank: ranks[participantId] || '-',
name: participant.name || '이름없음',
handicap,
...gameScores,
totalScore,
average
};
});
//
rankingData.sort((a, b) => {
if (a.rank === '-') return 1;
if (b.rank === '-') return -1;
return a.rank - b.rank;
});
return rankingData;
};
function onScoreBlur(member, n, teamNumber) { function onScoreBlur(member, n, teamNumber) {
const key = member.participantId + '-' + n; const key = member.participantId + '-' + n;
@@ -306,31 +453,123 @@ function onScoreEnter(member, n, teamNumber) {
saveScore(member, n, member.scores?.[n-1] ?? '', val, teamNumber); saveScore(member, n, member.scores?.[n-1] ?? '', val, teamNumber);
} }
// ( , 1) // ( , 1) - rankingUtils
function getMemberAverageScore(member) { function getMemberAverageScore(member) {
if (!member || !event.value.gameCount) return 0; if (!member || !event.value.gameCount) return 0;
let total = 0, count = 0; return calculateMemberAverageScore(member, scoresProxy.value, event.value.gameCount);
for (let i = 1; i <= event.value.gameCount; i++) {
const score = Number(scoresProxy.value[member.participantId + '-' + i]);
const hc = Number(member.handicap) || 0;
if (!isNaN(score) && score > 0) {
total += score + hc;
count++;
}
}
return count > 0 ? Math.round((total / count) * 10) / 10 : 0;
} }
// // - rankingUtils
function getMemberTotalScore(member) { function getMemberTotalScore(member) {
if (!member || !event.value.gameCount) return 0; if (!member || !event.value.gameCount) return 0;
let total = 0; return calculateParticipantTotalScore(member, scoresProxy.value, event.value.gameCount);
for (let i = 1; i <= event.value.gameCount; i++) { }
const score = Number(scoresProxy.value[member.participantId + '-' + i]) || 0;
const hc = Number(member.handicap) || 0; // ( )
total += score + hc; function updateMemberRanks() {
//
const activeParticipants = [];
//
if (participants.value && participants.value.length > 0) {
//
activeParticipants.push(...participants.value.filter(p => p && p.status !== '취소'));
} else {
// :
if (teams.value && teams.value.length > 0) {
teams.value.forEach(team => {
if (team.members && team.members.length > 0) {
activeParticipants.push(...team.members.filter(m => m && m.status !== '취소'));
}
});
}
if (unassignedMembers.value && unassignedMembers.value.length > 0) {
activeParticipants.push(...unassignedMembers.value.filter(m => m && m.status !== '취소'));
}
} }
return total;
//
const ranks = calculateIndividualRankings(
activeParticipants,
scoresProxy.value, // .value
event.value?.gameCount || 3
);
//
Object.keys(memberRankMap).forEach(key => {
delete memberRankMap[key];
});
//
Object.keys(ranks).forEach(participantId => {
memberRankMap[participantId] = ranks[participantId];
//
const participant = activeParticipants.find(p => p.participantId == participantId);
const name = participant ? (participant.name || '이름없음') : '알 수 없음';
});
//
updateGameRankings();
}
//
function updateGameRankings() {
//
const gameCount = event.value?.gameCount || 3;
// ( )
const allTeams = getAllTeamsWithUnassigned();
//
for (let i = 1; i <= gameCount; i++) {
//
const teamsWithGameScores = allTeams.map(team => {
const gameScore = getTeamTotalScore(team, i);
return {
teamId: team.id || team.teamNumber,
teamNumber: team.teamNumber,
gameScore: gameScore,
isUnassigned: team.isUnassigned || false
};
});
//
teamsWithGameScores.sort((a, b) => b.gameScore - a.gameScore);
//
gameRanks[i] = {};
//
let rank = 1;
let prevScore = -1;
let sameRankCount = 0;
teamsWithGameScores.forEach((team, index) => {
//
if (prevScore === team.gameScore) {
sameRankCount++;
} else {
rank = index + 1 - sameRankCount;
prevScore = team.gameScore;
sameRankCount = 0;
}
//
gameRanks[i][team.teamId] = rank;
});
}
}
//
function getRanksByGame(gameNumber) {
if (!gameRanks[gameNumber]) {
updateGameRankings();
}
return gameRanks[gameNumber] || {};
} }
// (scores) ( null) // (scores) ( null)
@@ -423,6 +662,30 @@ async function fetchEventData(password) {
// 'idle' ( ) // 'idle' ( )
scoreStates[key] = 'idle'; scoreStates[key] = 'idle';
}); });
//
updateMemberRanks();
//
if (Object.keys(memberRankMap).length === 0) {
//
if (participants.value && participants.value.length > 0) {
//
const activeParticipants = participants.value.filter(p => p && p.status !== '취소');
//
const initialRanks = calculateIndividualRankings(
activeParticipants,
scoresProxy.value, // .value
event.value?.gameCount || 3
);
//
Object.keys(initialRanks).forEach(participantId => {
memberRankMap[participantId] = initialRanks[participantId];
});
}
}
} catch (e) { } catch (e) {
error.value = e.message; error.value = e.message;
event.value = null; event.value = null;
@@ -459,6 +722,12 @@ function reload() {
fetchEventData(); fetchEventData();
} }
//
function handleGuestAdded(guest) {
//
reload();
}
onMounted(() => { onMounted(() => {
const token = sessionStorage.getItem('event_token_' + publicHash); const token = sessionStorage.getItem('event_token_' + publicHash);
if (token) { if (token) {
@@ -502,93 +771,34 @@ function getAllTeamsWithUnassigned() {
return teamList; return teamList;
} }
// - rankingUtils
function getTeamTotalScore(team, gameIdx) { function getTeamTotalScore(team, gameIdx) {
let total = 0;
if (!team || !team.members) return 0; if (!team || !team.members) return 0;
team.members.forEach(member => { return calculateTeamGameScore(team, scoresProxy.value, gameIdx);
const score = Number(scoresProxy.value[member.participantId + '-' + gameIdx]) || 0;
const memberHc = Number(member.handicap) || 0;
total += score + memberHc;
});
total += Number(team.handicap) || 0;
return total;
} }
// ( ) // - rankingUtils
function getTeamTotalHandicap(team) { function getTeamTotalHandicap(team) {
let total = 0;
if (!team || !team.members) return 0; if (!team || !team.members) return 0;
team.members.forEach(member => { return calculateTeamTotalHandicap(team);
const memberHc = Number(member.handicap) || 0;
total += memberHc;
});
total += Number(team.handicap) || 0;
return total;
} }
// // - rankingUtils
function getTeamOverallRank(team) { function getTeamOverallRank(team) {
if (!team || !team.id) return ''; if (!team || !team.id) return '';
//
const allTeams = getAllTeamsWithUnassigned(); const allTeams = getAllTeamsWithUnassigned();
const teamTotals = allTeams.map(t => ({ const rankMap = calculateTeamOverallRankings(allTeams, scoresProxy.value, event.value.gameCount);
teamId: t.id || t.teamNumber,
total: Array.from({ length: event.value.gameCount }, (_, i) => getTeamTotalScore(t, i + 1)).reduce((a, b) => a + b, 0), //
handicapSum: getTeamTotalHandicap(t), const teamId = team.id || team.teamNumber;
memberCount: (t.members ? t.members.length : 0) return rankMap[teamId] || '';
}));
teamTotals.sort((a, b) => {
if (b.total !== a.total) return b.total - a.total;
if (a.handicapSum !== b.handicapSum) return a.handicapSum - b.handicapSum;
return b.memberCount - a.memberCount;
});
let rank = 1;
for (let i = 0; i < teamTotals.length; i++) {
if (
i > 0 && (
teamTotals[i].total !== teamTotals[i - 1].total ||
teamTotals[i].handicapSum !== teamTotals[i - 1].handicapSum ||
teamTotals[i].memberCount !== teamTotals[i - 1].memberCount
)
) {
rank = i + 1;
}
if ((teamTotals[i].teamId + '') === (team.id + '')) {
return rank;
}
}
return '';
} }
// (computed) // (rankingUtils )
// // getRanksByGame .
function getRanksByGame(gameIdx) {
const allTeams = getAllTeamsWithUnassigned();
const scores = allTeams.map(team => ({
teamId: team.id,
score: getTeamTotalScore(team, gameIdx),
handicapSum: getTeamTotalHandicap(team),
memberCount: (team.members ? team.members.length : 0)
}));
scores.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.handicapSum !== b.handicapSum) return a.handicapSum - b.handicapSum;
return b.memberCount - a.memberCount;
});
const ranks = {};
let rank = 1;
scores.forEach((item, i) => {
if (
i > 0 && (
item.score !== scores[i - 1].score ||
item.handicapSum !== scores[i - 1].handicapSum ||
item.memberCount !== scores[i - 1].memberCount
)
) {
rank = i + 1;
}
ranks[item.teamId] = rank;
});
return ranks;
}
</script> </script>
<style src="./EventPublicPage.css"></style> <style src="./EventPublicPage.css"></style>