랭킹표시 수정

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
@@ -38,7 +38,7 @@
</div>
<div class="col-12 md:col-6 mb-2">
<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 class="col-12 md:col-6 mb-2 flex align-items-center">
<span class="font-bold text-color-secondary">공개 URL:</span>
@@ -47,7 +47,7 @@
</div>
<div class="col-12 md:col-6 mb-2">
<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>
@@ -5,10 +5,20 @@
:loading="loading"
class="mt-4"
:paginator="true"
:rows="10"
:rows="30"
stripedRows
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>
<template #body="slotProps">
{{ slotProps.data.participant?.name }}
@@ -28,9 +38,27 @@
</template>
</Column>
</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>
<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>
</Column>
<Column header="작업">
@@ -55,7 +83,7 @@
optionValue="id"
placeholder="참가자 선택"
:class="{'p-invalid': submitted && !editingScore.participantId}"
:disabled="!!editingScore.id"
:disabled="editingScore.isEdit"
/>
<small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small>
</div>
@@ -122,6 +150,7 @@
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService';
import { calculateIndividualRankings } from '@/utils/rankingUtils';
const props = defineProps({
eventId: {
@@ -368,7 +397,36 @@ const scoreDialogTitle = computed(() => {
const getTotalScore = (game) => {
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) => {
@@ -387,6 +445,32 @@ const formatScoreWithHandicap = (game) => {
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(() => {
if (!participants.value || !scores.value) {
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과 연결
eventStatus: String
});
const emits = defineEmits(['close', 'updated', 'update:visible']);
const emits = defineEmits(['close', 'updated', 'update:visible', 'guest-added']);
const selectedMember = ref(null);
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 = [
...(props.participants || []).map(m => ({ ...m, group: '참가자', id: m.participantId, participantId: m.participantId, memberId: m.memberId })),
...(props.availableMembers || []).map(m => ({ ...m, group: '미신청', id: m.memberId, participantId: null, memberId: m.memberId }))
...participantOptions,
...unregisteredOptions
].sort((a, b) => {
const nameA = a.name || '';
const nameB = b.name || '';
@@ -185,8 +212,8 @@ import PublicEventService from '@/services/PublicEventService';
const newGuest = ref({ name: '', phone: '', gender: '' });
const genderOptions = [
{ label: '남', value: '남성' },
{ label: '여', value: '여성' }
{ label: '남', value: '남성' },
{ label: '여', value: '여성' }
];
async function addNewGuest() {
@@ -204,24 +231,66 @@ async function addNewGuest() {
average: newGuest.value.average
});
if (!res || !res.data || !res.data.memberId) throw new Error('게스트 등록 실패');
// 새 게스트를 멤버 옵션에 추가하고 자동 선택
const guest = {
...res.data,
group: '참가자',
id: 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);
// 참가자 목록에도 추가
props.participants.push(guest);
// 이미 있는 게스트 업데이트 (선택 가능한 회원 목록)
const existingGuestIndex = props.availableMembers.findIndex(m =>
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;
selectedParticipantId.value = guest.participantId;
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: '' };
// 부모 컴포넌트에 이벤트 발생
emits('guest-added', guest);
} catch (e) {
error.value = e.message || '게스트 등록 중 오류 발생';
}
@@ -7,6 +7,11 @@
<div v-for="member in members" :key="member.participantId" class="member-row">
<div class="member-info-block">
<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>
<Tag v-if="member.isGuest" value="G" severity="warning" class="ml-2" />
</div>
@@ -31,6 +36,14 @@
<template v-for="n in gameCount" :key="n">
<div class="score-input-hc-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
v-if="canInputScore"
v-model.number="scoresProxy[member.participantId + '-' + n]"
@@ -43,29 +56,29 @@
@keydown.enter="onScoreEnter(member, n, teamNumber)"
/>
<span v-else>{{ member.scores?.[n - 1] ?? '-' }}</span>
<!-- 핸디캡 포함 점수 표시 -->
<span :class="scoreColorClass(scoreStates[member.participantId + '-' + n], scoresProxy[member.participantId + '-' + n], member.handicap)"
class="score-hc-value score-status-text"
:key="scoreStates[member.participantId + '-' + n] + '-' + scoresProxy[member.participantId + '-' + n]">
{{ (scoresProxy[member.participantId + '-' + n] !== undefined && scoresProxy[member.participantId + '-' + n] !== '') ?
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>
</div>
</div>
</template>
<div class="member-score-summary">
<span>총점: {{ getMemberTotalScore(member) }}</span>
<span class="member-score-avg">/ 에버리지: {{ getMemberAverageScore(member) }}</span>
<span class="member-score-avg">/ 평균: {{ getMemberAverageScore(member) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { toRefs } from 'vue'
import { computed, onMounted, watch, ref } from 'vue';
import { calculateIndividualRankings, getRankColorClass } from '@/utils/rankingUtils';
const props = defineProps({
title: String,
members: Array,
@@ -80,6 +93,127 @@ const props = defineProps({
onScoreEnter: Function,
getMemberTotalScore: 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>
<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>