오류 수정, 통계 보강

This commit is contained in:
2025-06-08 20:21:37 +09:00
parent 1a3706d7a5
commit 141a0de6d0
42 changed files with 2645 additions and 799 deletions
+25 -15
View File
@@ -20,7 +20,7 @@
</Column>
<Column field="owner.name" header="모임장" sortable style="width: 15%">
<template #body="slotProps">
{{ slotProps.data.owner?.name || '-' }}
{{ slotProps.data.owner?.name || 'guest' }}
</template>
</Column>
<Column field="memberCount" header="회원 수" sortable style="width: 10%"></Column>
@@ -125,13 +125,16 @@
</div>
</template>
</Column>
<Column field="gender" header="성별" sortable style="min-width: 6rem"></Column>
<Column field="gender" header="성별" sortable style="min-width: 6rem">
<template #body="slotProps">
{{ getGenderLabel(slotProps.data.gender) }}
</template>
</Column>
<Column field="memberType" header="회원 유형" sortable style="min-width: 8rem">
<template #body="slotProps">
<Select v-model="slotProps.data.memberType"
:options="memberTypes"
@change="updateMemberType(slotProps.data)"
:disabled="slotProps.data.memberType === '모임장' && !canChangeOwner" />
<span>
{{ getMemberTypeLabel(slotProps.data.memberType) }}
</span>
</template>
</Column>
<Column field="handicap" header="핸디캡" sortable style="min-width: 6rem"></Column>
@@ -154,7 +157,7 @@
<Button icon="pi pi-trash"
class="p-button-rounded p-button-danger"
@click="confirmDeleteMember(slotProps.data)"
:disabled="slotProps.data.memberType === '모임장'" />
:disabled="slotProps.data.memberType === 'owner'" />
</template>
</Column>
</DataTable>
@@ -172,16 +175,22 @@
</div>
<div class="field mb-3">
<label for="gender">성별</label>
<Select id="gender" v-model="member.gender"
:options="['남성', '여성']" placeholder="성별 선택"
:class="{'p-invalid': memberSubmitted && !member.gender}" class="w-full" />
<Select id="gender" v-model="member.gender"
:options="[{ label: '남성', value: 'male' }, { label: '여성', value: 'female' }]" optionLabel="label" optionValue="value" placeholder="성별 선택"
:class="{'p-invalid': memberSubmitted && !member.gender}" class="w-full" />
<small class="p-error" v-if="memberSubmitted && !member.gender">성별은 필수입니다.</small>
</div>
<div class="field mb-3">
<label for="memberType">회원 유형</label>
<Select id="memberType" v-model="member.memberType"
:options="memberTypes" placeholder="회원 유형 선택"
:class="{'p-invalid': memberSubmitted && !member.memberType}" class="w-full" />
<Select id="memberType" v-model="member.memberType"
:options="[
{ label: '정회원', value: 'regular' },
{ label: '준회원', value: 'associate' },
{ label: '게스트', value: 'guest' },
{ label: '운영진', value: 'manager' },
{ label: '모임장', value: 'owner' }
]" optionLabel="label" optionValue="value" placeholder="회원 유형 선택"
:class="{'p-invalid': memberSubmitted && !member.memberType}" class="w-full" />
<small class="p-error" v-if="memberSubmitted && !member.memberType">회원 유형은 필수입니다.</small>
</div>
<div class="field mb-3">
@@ -234,6 +243,7 @@
</template>
<script setup>
import { getGenderLabel, getMemberTypeLabel } from '@/utils/enumMappings';
import { ref, reactive, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import dayjs from 'dayjs';
@@ -257,7 +267,7 @@ const clubs = ref([]);
const clubMembers = ref([]);
// 회원 유형 목록
const memberTypes = ref(['모임장', '정회원', '준회원', '운영진']);
const memberTypes = ref(['owner', 'regular', 'associate', 'manager']);
// 기능 목록
const allFeatures = ref([
@@ -585,7 +595,7 @@ const openNewMemberDialog = () => {
id: null,
name: '',
gender: null,
memberType: '준회원',
memberType: 'associate',
handicap: 0,
phone: '',
email: ''
@@ -32,7 +32,7 @@
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
<Badge :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="작업" style="width: 10%">
@@ -198,6 +198,7 @@
</template>
<script setup>
import { getStatusLabel } from '@/utils/enumMappings';
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import axios from 'axios';
@@ -498,7 +499,7 @@ const deleteSubscription = async () => {
};
// 상태 이름 가져오기
const getStatusName = (status) => {
// getStatusName 함수 제거(일원화)
switch (status) {
case 'active':
return '활성';
+8 -2
View File
@@ -41,7 +41,7 @@
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
<Badge :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Select v-model="filterModel.value" @change="filterCallback()" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" class="p-column-filter" />
@@ -123,6 +123,7 @@
</template>
<script setup>
import { getStatusLabel } from '@/utils/enumMappings';
import { ref, onMounted, reactive, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import adminService from '@/services/adminService';
@@ -155,6 +156,11 @@ const roleOptions = [
// 상태 옵션
const statusOptions = [
{ name: getStatusLabel('active'), value: 'active' },
{ name: getStatusLabel('inactive'), value: 'inactive' },
{ name: getStatusLabel('pending'), value: 'pending' },
{ name: getStatusLabel('suspended'), value: 'suspended' }
];
{ name: '활성', value: 'active' },
{ name: '비활성', value: 'inactive' },
{ name: '정지', value: 'suspended' }
@@ -296,7 +302,7 @@ const getRoleSeverity = (role) => {
};
// 상태 이름 가져오기
const getStatusName = (status) => {
// getStatusName 함수 제거(일원화)
switch (status) {
case 'active':
return '활성';
+10
View File
@@ -17,6 +17,14 @@
<small v-if="clubDescriptionError" class="p-error">{{ clubDescriptionError }}</small>
</div>
<div class="form-group">
<label for="averageCalculationPeriod">평균 산정 기준</label>
<select id="averageCalculationPeriod" v-model="averageCalculationPeriod" class="w-full">
<option value="all">전체</option>
<option value="1year">최근 1</option>
<option value="6months">최근 6개월</option>
</select>
</div>
<div class="form-group">
<label for="clubLocation">활동 볼링장</label>
<InputText id="clubLocation" v-model="clubLocation" class="w-full" />
@@ -45,6 +53,7 @@ const clubName = ref('');
const clubDescription = ref('');
const clubLocation = ref('');
const clubContact = ref('');
const averageCalculationPeriod = ref('all');
const loading = ref(false);
const clubNameError = ref('');
const clubDescriptionError = ref('');
@@ -92,6 +101,7 @@ const handleCreateClub = async () => {
description: clubDescription.value,
location: clubLocation.value,
contact: clubContact.value,
averageCalculationPeriod: averageCalculationPeriod.value,
ownerEmail: authStore.user.email
});
+53 -28
View File
@@ -10,31 +10,42 @@
<div class="settings-section">
<h2>기본 정보</h2>
<div class="settings-form">
<div class="form-group">
<label for="clubName">클럽명</label>
<input type="text" id="clubName" v-model="clubInfo.name" class="form-control" />
</div>
<div class="form-group">
<label for="clubDescription">클럽 설명</label>
<textarea id="clubDescription" v-model="clubInfo.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label for="clubLocation">위치</label>
<input type="text" id="clubLocation" v-model="clubInfo.location" class="form-control" />
</div>
<div class="form-group">
<label for="femaleHandicap">여성 기본핸디 설정</label>
<input type="number" id="femaleHandicap" v-model="clubInfo.femaleHandicap" class="form-control" min="0" max="100" />
<small class="form-text text-muted">여성 회원을 멤버(게스트) 등록할 기본 핸디캡 값입니다.</small>
</div>
<div class="form-group">
<label for="clubOwner">모임장 설정</label>
<select id="clubOwner" v-model="selectedOwnerId" class="form-control">
<option v-for="member in members" :key="member.id" :value="member.userId">
{{ member.name }} ({{ member.memberType }}) [ID: {{ member.userId }}]
</option>
</select>
<small class="form-text text-muted">모임장 권한을 이전할 회원을 선택합니다. 모임장은 클럽의 모든 관리 권한을 가집니다.</small>
<div class="grid">
<div class="field col-12">
<label for="clubName">클럽명</label>
<input type="text" id="clubName" v-model="clubInfo.name" class="form-control" />
</div>
<div class="field col-12">
<label for="clubDescription">클럽 설명</label>
<textarea id="clubDescription" v-model="clubInfo.description" class="form-control" rows="3"></textarea>
</div>
<div class="field col-4">
<label for="clubLocation">위치</label>
<input type="text" id="clubLocation" v-model="clubInfo.location" class="form-control" />
</div>
<div class="field col-4">
<label for="femaleHandicap">여성 기본핸디 설정</label>
<input type="number" id="femaleHandicap" v-model="clubInfo.femaleHandicap" class="form-control" min="0" max="100" />
<small class="form-text text-muted">여성 회원을 멤버(게스트) 등록할 기본 핸디캡 값입니다.</small>
</div>
<div class="field col-4">
<label for="averageCalculationPeriod">평균 산정 기준</label>
<select id="averageCalculationPeriod" v-model="clubInfo.averageCalculationPeriod" class="form-control">
<option v-for="option in averageCalculationPeriodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<small class="form-text text-muted">클럽의 에버리지(평균) 산정 기준을 선택하세요.</small>
</div>
<div class="field col-12">
<label for="clubOwner">모임장 설정</label>
<select id="clubOwner" v-model="selectedOwnerId" class="form-control">
<option v-for="member in members" :key="member.id" :value="member.userId">
{{ member.name }} ({{ member.memberType }}) [ID: {{ member.userId }}]
</option>
</select>
<small class="form-text text-muted">모임장 권한을 이전할 회원을 선택합니다. 모임장은 클럽의 모든 관리 권한을 가집니다.</small>
</div>
</div>
<div class="form-actions">
<button @click="saveClubInfo" class="btn btn-primary">저장</button>
@@ -120,6 +131,17 @@
</template>
<script setup>
import { getAverageCalculationPeriodLabel } from '@/utils/enumMappings';
const averageCalculationPeriodOptions = [
{ value: 'total', label: getAverageCalculationPeriodLabel('total') },
{ value: '1year', label: getAverageCalculationPeriodLabel('1year') },
{ value: '6month', label: getAverageCalculationPeriodLabel('6month') },
{ value: '3month', label: getAverageCalculationPeriodLabel('3month') },
{ value: 'firsthalf', label: getAverageCalculationPeriodLabel('firsthalf') },
{ value: 'secondhalf', label: getAverageCalculationPeriodLabel('secondhalf') },
{ value: 'quarterly', label: getAverageCalculationPeriodLabel('quarterly') }
];
import { ref, onMounted, computed, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
@@ -139,7 +161,8 @@ const clubInfo = ref({
description: '',
location: '',
femaleHandicap: 15, // 기본값 설정
ownerId: null // 모임장 ID
ownerId: null, // 모임장 ID
averageCalculationPeriod: 'all' // 평균 산정 기준 기본값
});
// 구독 정보
@@ -208,7 +231,8 @@ const loadClubInfo = async () => {
location: clubData.location || '',
memberCount: clubData.memberCount || 0,
femaleHandicap: clubData.femaleHandicap || 15,
ownerId: clubData.ownerId // 모임장 ID 추가
ownerId: clubData.ownerId, // 모임장 ID 추가
averageCalculationPeriod: clubData.averageCalculationPeriod || '6month' // 평균 산정 기준
};
// 선택된 모임장 ID 설정
@@ -311,7 +335,8 @@ const saveClubInfo = async () => {
name: clubInfo.value.name,
description: clubInfo.value.description,
location: clubInfo.value.location,
femaleHandicap: clubInfo.value.femaleHandicap
femaleHandicap: clubInfo.value.femaleHandicap,
averageCalculationPeriod: clubInfo.value.averageCalculationPeriod
};
// 모임장이 변경되었다면 확인 메시지 표시
+11 -10
View File
@@ -14,9 +14,9 @@
<EventDetailsDialog
v-model:visible="eventDetailsDialog"
:event="selectedEvent"
:getEventTypeName="getEventTypeName"
:getEventTypeLabel="getEventTypeLabel"
:getEventTypeSeverity="getEventTypeSeverity"
:getStatusName="getStatusName"
:getEventStatusLabel="getEventStatusLabel"
:getStatusSeverity="getStatusSeverity"
:formatDate="formatDate"
@edit="navigateToEventManagement"
@@ -27,6 +27,7 @@
</template>
<script setup>
// getEventTypeLabel 중복 import 제거. eventUtils.js의 getEventTypeLabel만 사용.
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
@@ -37,12 +38,12 @@ import koLocale from '@fullcalendar/core/locales/ko';
import EventDetailsDialog from '@/components/event/EventDetailsDialog.vue';
import eventService from '@/services/eventService';
import {
getEventTypeName,
getEventTypeSeverity,
getStatusName,
getStatusSeverity,
formatDate
} from '@/utils/eventUtils';
import { getEventTypeLabel, getEventStatusLabel } from '@/utils/enumMappings';
// getEventTypeLabel, getEventStatusLabel은 enumMappings.js에서만 import, eventUtils.js에서는 import/export하지 않음
const router = useRouter();
const toast = useToast();
@@ -88,17 +89,17 @@ const fetchEvents = async () => {
// 이벤트 타입에 따른 색상 지정
const getEventColor = (eventType) => {
switch (eventType) {
case '정기전':
case 'regular':
return '#FF5722'; // 주황색
case '번개':
case 'lightning':
return '#2196F3'; // 파란색
case '연습':
case 'practice':
return '#4CAF50'; // 초록색
case '교류전':
case 'exchange':
return '#9C27B0'; // 보라색
case '이벤트':
case 'event':
return '#FFC107'; // 노란색
case '기타':
case 'other':
return '#607D8B'; // 회색
default:
return '#3f51b5'; // 기본색
+10 -58
View File
@@ -28,7 +28,7 @@
>
<Column field="eventType" header="유형" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getEventTypeName(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" />
<Badge :value="getEventTypeLabel(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Select v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
@@ -52,7 +52,7 @@
<Column field="maxParticipants" header="최대 인원" sortable style="width: 8%"></Column>
<Column field="status" header="상태" sortable style="width: 8%">
<template #body="slotProps">
<Badge :value="slotProps.data.status" :severity="getStatusSeverity(slotProps.data.status)" />
<Badge :value="getEventStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="작업" style="width: 10%">
@@ -80,9 +80,9 @@
<EventDetailsDialog
v-model:visible="detailsDialog"
:event="event || {}"
:getEventTypeName="getEventTypeName"
:getEventTypeLabel="getEventTypeLabel"
:getEventTypeSeverity="getEventTypeSeverity"
:getStatusName="getStatusName"
:getEventStatusLabel="getEventStatusLabel"
:getStatusSeverity="getStatusSeverity"
:formatDate="formatDate"
:availableMembers="clubMembers"
@@ -120,21 +120,14 @@
</template>
<script setup>
import { getEventTypeLabel, getEventStatusLabel, getStatusSeverity } from '@/utils/enumMappings';
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useRoute } from 'vue-router';
import { useClubStore } from '@/stores/clubStore';
import FileUploadDialog from '@/components/event/FileUploadDialog.vue';
import eventService from '@/services/eventService';
import apiClient from '@/services/api';
import dayjs from 'dayjs';
import clubService from '@/services/clubService';
import ScoreManagement from '@/components/event/ScoreManagement.vue';
import ParticipantManagement from '@/components/event/ParticipantManagement.vue';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import EventDialog from '@/components/event/EventDialog.vue';
import EventDetailsDialog from '@/components/event/EventDetailsDialog.vue';
@@ -164,15 +157,6 @@ const filters = ref({
// 팀 기능 사용 권한 여부
const hasTeamFeature = computed(() => clubStore.hasFeature('team_create'));
// 이벤트 유형 옵션
const eventTypeOptions = [
{ name: '정기전', value: '정기전' },
{ name: '토너먼트', value: '토너먼트' },
{ name: '연습', value: '연습' },
{ name: '친선전', value: '친선전' },
{ name: '기타', value: '기타' }
];
// 클럽 멤버 목록
const clubMembers = ref([]);
@@ -244,42 +228,10 @@ const formatDate = (date) => {
// 이벤트 유형에 따른 Badge 스타일
const getEventTypeSeverity = (type) => {
switch (type) {
case '정기전': return 'success';
case '토너먼트': return 'warning';
case '연습': return 'info';
case '친선전': return 'primary';
default: return 'secondary';
}
};
// 이벤트 유형 이름 반환
const getEventTypeName = (type) => {
return type || '기타';
};
// 이벤트 상태 이름 반환
const STATUS_LABELS = {
'준비': '준비',
'진행중': '진행중',
'완료': '완료',
'취소': '취소',
'예정됨': '예정됨',
'in_progress': '진행중',
'completed': '완료',
'cancelled': '취소',
'활성': '활성',
'비활성': '비활성',
'삭제': '삭제',
};
const getStatusName = (status) => STATUS_LABELS[status] || status;
// 이벤트 상태에 따른 Badge 스타일
const getStatusSeverity = (status) => {
switch (status) {
case '준비': return 'info';
case '진행중': return 'warning';
case '완료': return 'success';
case '취소': return 'danger';
case 'regular': return 'success';
case 'tournament': return 'warning';
case 'practice': return 'info';
case 'friendly': return 'primary';
default: return 'secondary';
}
};
@@ -289,7 +241,7 @@ const openNewEventDialog = () => {
event.value = {
clubId: clubId.value,
status: '준비',
eventType: '정기전',
eventType: 'regular',
title: '',
description: '',
startDate: null,
+25 -45
View File
@@ -36,11 +36,15 @@
</div>
</template>
</Column>
<Column field="gender" header="성별" sortable style="width: 10%"></Column>
<Column field="gender" header="성별" sortable style="width: 10%">
<template #body="slotProps">
{{ getGenderLabel(slotProps.data.gender) }}
</template>
</Column>
<Column field="memberType" header="회원 유형" sortable style="width: 15%">
<template #body="slotProps">
<span :class="getMemberTypeClass(slotProps.data.memberType)">
{{ slotProps.data.memberType }}
<span>
{{ getMemberTypeLabel(slotProps.data.memberType) }}
</span>
</template>
</Column>
@@ -54,7 +58,7 @@
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<span :class="`status-${slotProps.data.status}`">
<span>
{{ getStatusLabel(slotProps.data.status) }}
</span>
</template>
@@ -81,11 +85,11 @@
</div>
<div class="field col-6 mb-3">
<label for="gender" class="mb-1">성별</label>
<Select id="gender" v-model="member.gender" :options="genderOptions" optionLabel="name" optionValue="code" placeholder="성별 선택" class="w-full" />
<Select id="gender" v-model="member.gender" :options="GENDER_OPTIONS" optionLabel="name" optionValue="value" placeholder="성별 선택" class="w-full" />
</div>
<div class="field col-6 mb-3">
<label for="memberType" class="mb-1">회원 유형</label>
<Select id="memberType" v-model="member.memberType" :options="memberTypeOptions" optionLabel="name" optionValue="code" placeholder="회원 유형 선택" class="w-full" />
<Select id="memberType" v-model="member.memberType" :options="filteredMemberTypeOptions" optionLabel="name" optionValue="code" placeholder="회원 유형 선택" class="w-full" />
</div>
<div class="field col-6 mb-3">
<label for="phone" class="mb-1">전화번호</label>
@@ -101,7 +105,7 @@
</div>
<div class="field col-6 mb-3">
<label for="status" class="mb-1">상태</label>
<Select id="status" v-model="member.status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="상태 선택" class="w-full" />
<Select id="status" v-model="member.status" :options="STATUS_OPTIONS" optionLabel="name" optionValue="value" placeholder="상태 선택" class="w-full" />
</div>
</div>
<div class="flex justify-content-end gap-2 mt-4">
@@ -129,7 +133,7 @@
<div class="col-12 md:col-4">
<div class="field">
<label>회원 유형</label>
<MultiSelect v-model="filters.memberTypes" :options="memberTypes" optionLabel="name"
<MultiSelect v-model="filters.memberTypes" :options="filteredMemberTypeOptions" optionLabel="name"
placeholder="선택" class="w-full" />
</div>
</div>
@@ -190,23 +194,21 @@ import { ref, reactive, computed, onMounted, watch } from 'vue';
import clubService from '@/services/clubService';
import { useClubStore } from '@/stores/clubStore';
import { useToast } from 'primevue/usetoast';
import {
MEMBER_TYPE_OPTIONS,
GENDER_OPTIONS,
STATUS_OPTIONS,
getGenderLabel,
getMemberTypeLabel,
getStatusLabel
} from '@/utils/enumMappings';
// 모임장(owner) 옵션을 제외한 회원 유형 옵션만 노출
const filteredMemberTypeOptions = computed(() => MEMBER_TYPE_OPTIONS.filter(opt => opt.value !== 'owner' && opt.code !== 'owner'));
const clubStore = useClubStore();
const toast = useToast();
// 회원 유형 옵션
const memberTypeOptions = ref([
{ name: '정회원', code: '정회원' },
{ name: '준회원', code: '준회원' },
{ name: '운영진', code: '운영진' }
// 모임장 옵션은 제거 - 클럽 설정에서만 변경 가능하도록 제한
]);
// 성별 옵션
const genderOptions = ref([
{ name: '남성', code: '남성' },
{ name: '여성', code: '여성' }
]);
// 회원 목록
const members = ref([]);
@@ -327,16 +329,7 @@ const formatDate = (dateStr) => {
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
};
// 회원 상태 표시 함수
const getStatusLabel = (status) => {
switch (status) {
case 'active': return '활성';
case 'inactive': return '비활성';
case 'suspended': return '정지';
case 'deleted': return '삭제됨';
default: return status;
}
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
@@ -367,16 +360,7 @@ const loadMembers = async () => {
}
};
// 회원 유형에 따른 클래스 반환
const getMemberTypeClass = (type) => {
switch (type) {
case '모임장': return 'member-type-owner';
case '운영진': return 'member-type-admin';
case '정회원': return 'member-type-regular';
case '준회원': return 'member-type-associate';
default: return '';
}
};
// 새 회원 추가 다이얼로그 열기
const openNewMemberDialog = () => {
@@ -464,11 +448,7 @@ const deleteMember = async () => {
}
};
const statusOptions = ref([
{ label: '활성', value: 'active' },
{ label: '비활성', value: 'inactive' },
{ label: '정지', value: 'suspended' }
]);
</script>
<style scoped>
+106 -14
View File
@@ -105,6 +105,76 @@
</table>
</div>
</div>
<!-- ===== 추가 통계 표시 영역 ===== -->
<!-- 1. 회원별 최고/최저/총점 -->
<h3>회원별 최고/최저/총점</h3>
<table>
<thead>
<tr><th>이름</th><th>최고점</th><th>최저점</th><th>총점</th></tr>
</thead>
<tbody>
<tr v-for="m in stats.memberScoreStats" :key="m.memberId">
<td>{{ m.name }}</td>
<td>{{ m.maxScore }}</td>
<td>{{ m.minScore }}</td>
<td>{{ m.totalScore }}</td>
</tr>
</tbody>
</table>
<!-- 2. 모임별 최고/최저/평균점수 -->
<h3>모임별 점수 통계(확장)</h3>
<table>
<thead>
<tr><th>모임명</th><th>최고점</th><th>최저점</th><th>평균점</th></tr>
</thead>
<tbody>
<tr v-for="e in stats.meetingScoreStats" :key="e.eventId">
<td>{{ e.title }}</td>
<td>{{ e.maxScore }}</td>
<td>{{ e.minScore }}</td>
<td>{{ e.avgScore }}</td>
</tr>
</tbody>
</table>
<!-- 4. 참가자 변화 추이 (그래프) -->
<h3>참가자 변화 추이</h3>
<LineChart v-if="participantTrendChartData" :data="participantTrendChartData" />
<!-- 5. 회원별 모임 참가 횟수 -->
<h3>회원별 모임 참가 횟수</h3>
<table>
<thead>
<tr><th>이름</th><th>참가 횟수</th></tr>
</thead>
<tbody>
<tr v-for="m in stats.memberAttendanceStats" :key="m.memberId">
<td>{{ m.name }}</td>
<td>{{ m.attendCount }}</td>
</tr>
</tbody>
</table>
<!-- 6. 팀별 통계 -->
<h3>팀별 통계</h3>
<table>
<thead>
<tr><th>이벤트ID</th><th>팀ID</th><th>평균점수</th><th>최고점</th><th>참가자수</th></tr>
</thead>
<tbody>
<tr v-for="t in stats.teamStats" :key="`${t.eventId}-${t.teamId}`">
<td>{{ t.eventId }}</td>
<td>{{ t.teamId }}</td>
<td>{{ t.avgScore }}</td>
<td>{{ t.maxScore }}</td>
<td>{{ t.memberCount }}</td>
</tr>
</tbody>
</table>
<!-- ===== 추가 통계 표시 영역 ===== -->
</div>
</div>
</template>
@@ -119,10 +189,7 @@ ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointEleme
export default {
name: 'Statistics',
components: {
BarChart,
LineChart
},
components: { BarChart, LineChart },
setup() {
const stats = ref({
summary: {},
@@ -144,9 +211,11 @@ export default {
});
stats.value = response.data;
console.log('[통계] API 응답:', response.data);
} catch (error) {
console.error('통계 데이터 로드 오류:', error);
} finally {
console.log('[통계] 최종 stats 상태:', stats.value);
loading.value = false;
}
};
@@ -194,18 +263,39 @@ export default {
const attendanceTrendChartOptions = {
responsive: true,
plugins: {
legend: {
display: false
}
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
max: 100
}
}
scales: { y: { beginAtZero: true, max: 100 } }
};
// 추가 통계용 차트 데이터 (순서 주의!)
const participantTrendChartData = computed(() => {
const arr = stats.value.participantTrend || [];
return {
labels: arr.map(x => formatDate(x.date)),
datasets: [{
label: '참가자 수',
data: arr.map(x => x.participantCount),
backgroundColor: '#42b983',
borderColor: '#42b983',
fill: false
}]
};
});
function getHandicapTrendChartData(trendArr) {
if (!trendArr) return null;
return {
labels: trendArr.map(t => formatDate(t.date)),
datasets: [{
label: '평균 핸디캡',
data: trendArr.map(t => t.avgHandicap),
borderColor: '#f87979',
fill: false
}]
};
}
onMounted(() => {
loadStatistics();
});
@@ -219,7 +309,9 @@ export default {
scoreDistributionChartData,
scoreDistributionChartOptions,
attendanceTrendChartData,
attendanceTrendChartOptions
attendanceTrendChartOptions,
participantTrendChartData,
getHandicapTrendChartData
};
}
};
+9 -32
View File
@@ -25,10 +25,8 @@
<template v-if="event">
<Divider class="mb-4" />
<div class="event-tag mb-2">
<Tag :value="event.eventType" class="event-type-tag" />
<Tag :value="event.status"
:severity="event.status === '완료' ? 'success' : event.status === '활성' ? 'info' : 'warning'"
class="ml-1" />
<Tag :value="getEventTypeLabel(event.eventType)" :severity="getEventTypeSeverity(event.eventType)" />
<Tag :value="getEventStatusLabel(event.status)" :severity="getStatusSeverity(event.status)" class="ml-1" />
</div>
<div>
<div class="flex justify-content-end gap-2">
@@ -99,7 +97,7 @@
</DataTable>
</template>
</Card>
<template v-if="event.status != '완료'">
<template v-if="event.status != 'completed'">
<div v-for="team in getAllTeamsWithUnassigned()" :key="team.id" class="mb-4">
<Card>
<template #content>
@@ -154,7 +152,7 @@
</template>
</template>
<template v-else>
<template v-if="event.status != '완료'">
<template v-if="event.status != 'completed'">
<Card>
<template #content>
<MemberListCard
@@ -176,7 +174,7 @@
</Card>
</template>
</template>
<template v-if="event.status == '완료'">
<template v-if="event.status == 'completed'">
<Card>
<template #title>
<div class="card-title-with-button">
@@ -244,6 +242,7 @@
</template>
<script setup>
import { getGenderLabel, getMemberTypeLabel, getStatusLabel, getPaymentStatusLabel, getEventStatusLabel, getEventTypeLabel, getStatusSeverity, getEventTypeSeverity } from '@/utils/enumMappings';
import { ref, reactive, computed, onMounted, watch } from 'vue';
import {
calculateIndividualRankings,
@@ -279,9 +278,9 @@ const showJoinDialog = ref(false);
const allMembers = ref([]); // 전체 클럽 멤버 목록
const availableMembers = ref([]); // 참가 신청 가능 명단
// ===== 이벤트 상태별 권한 제어 =====
const isReady = computed(() => event.value.status === '준비');
const isActive = computed(() => event.value.status === '활성');
const isFinished = computed(() => event.value.status === '완료');
const isReady = computed(() => event.value.status === 'ready');
const isActive = computed(() => event.value.status === 'active');
const isFinished = computed(() => event.value.status === 'completed');
// 신청 마감일시 체크
const isRegistrationClosed = computed(() => {
@@ -730,17 +729,6 @@ function verifyPassword() {
});
}
async function updateScore(team, member) {
if (!canEdit.value) return;
if (!isAuthenticated.value) return;
try {
await PublicEventService.updateScore(publicHash, member.id, member.score);
reload();
} catch (e) {
// 에러 핸들링
}
}
function reload() {
fetchEventData();
}
@@ -760,17 +748,6 @@ onMounted(() => {
}
});
// (핸디캡 제외) 팀별 게임별 실제 점수 합계
function getTeamRawScore(team, gameIdx) {
let total = 0;
if (!team || !team.members) return 0;
team.members.forEach(member => {
const score = Number(scoresProxy.value[member.participantId + '-' + gameIdx]) || 0;
total += score;
});
return total;
}
// 팀별 게임별 합산 점수 계산 (computed)
// 팀별 게임별 합산 점수 계산 함수 (단순 값 반환)
function getAllTeamsWithUnassigned() {