This commit is contained in:
2025-04-17 23:32:39 +09:00
commit 5b719b6766
105 changed files with 25653 additions and 0 deletions
+489
View File
@@ -0,0 +1,489 @@
<template>
<header class="app-header">
<div class="logo">
<h1>볼링 클럽 매니저</h1>
</div>
<div v-if="isLoggedIn" class="header-actions">
<!-- 관리자 메뉴 -->
<div v-if="isAdmin" class="admin-menu-wrapper">
<Button @click="toggleAdminMenu" class="admin-menu-button p-button-outlined">
<i class="pi pi-cog"></i>
</Button>
<Menu ref="adminMenu" :model="adminMenuItems" :popup="true" />
</div>
<!-- 알림 버튼 -->
<div class="notification-wrapper">
<Button icon="pi pi-bell" class="notification-button p-button-outlined" @click="toggleNotifications"
:badge="unreadNotificationsCount > 0 ? unreadNotificationsCount.toString() : undefined"
badgeClass="p-badge-danger" />
<div v-if="showNotifications" class="notifications-panel">
<div class="notifications-header">
<h3>알림</h3>
<Button icon="pi pi-times" class="p-button-text p-button-rounded" @click="showNotifications = false" />
</div>
<div class="notifications-content">
<div v-if="notifications.length > 0">
<div v-for="(notification, index) in notifications" :key="index"
class="notification-item"
:class="{ 'unread': !notification.read }"
@click="handleNotificationClick(notification)">
<i :class="notification.icon"></i>
<div class="notification-details">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-message">{{ notification.message }}</div>
<div class="notification-time">{{ notification.time }}</div>
</div>
<Button icon="pi pi-trash" class="p-button-text p-button-rounded" @click="deleteNotification(notification.id, $event)" />
</div>
</div>
<div v-else class="no-notifications">
<i class="pi pi-info-circle"></i>
<p>새로운 알림이 없습니다.</p>
</div>
</div>
<div class="notifications-footer">
<Button label="모든 알림 읽음 표시" class="p-button-text" @click="markAllAsRead" />
</div>
</div>
</div>
<!-- 사용자 메뉴 -->
<div class="user-menu-wrapper">
<Button @click="toggleUserMenu" class="user-menu-button p-button-outlined">
<Avatar :image="userAvatar" :label="userInitials" size="normal" shape="circle" />
<span class="user-name hide-on-mobile">{{ userName }}</span>
<i class="pi pi-chevron-down"></i>
</Button>
<Menu ref="userMenu" :model="userMenuItems" :popup="true" />
</div>
</div>
</header>
</template>
<script setup>
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { API_URL } from '../config';
import Button from 'primevue/button';
import Menu from 'primevue/menu';
import Avatar from 'primevue/avatar';
const router = useRouter();
const userMenu = ref(null);
const adminMenu = ref(null);
const isLoggedIn = computed(() => !!localStorage.getItem('token'));
const isAdmin = computed(() => {
const role = localStorage.getItem('userRole');
return role === 'admin' || role === 'superadmin';
});
// 사용자 정보 (App.vue에서 공유)
const user = inject('user');
// 사용자 정보 computed 속성
const userName = computed(() => user.value?.name || '사용자');
const userInitials = computed(() => {
if (!user.value?.name) return '';
return user.value.name.split(' ').map(n => n[0]).join('');
});
const userAvatar = computed(() => user.value?.avatar || '');
// 로그아웃
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('clubId');
localStorage.removeItem('userRole');
router.push('/login');
};
// 알림 관련 상태
const showNotifications = ref(false);
const notifications = ref([]);
const unreadNotificationsCount = ref(0);
// 알림 가져오기
const fetchNotifications = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await axios.get(`${API_URL}/notifications`, {
headers: {
Authorization: `Bearer ${token}`
}
});
if (response.data) {
notifications.value = response.data.map(notification => {
const createdAt = new Date(notification.createdAt);
const now = new Date();
const diffMs = now - createdAt;
const diffMins = Math.round(diffMs / 60000);
const diffHours = Math.round(diffMs / 3600000);
const diffDays = Math.round(diffMs / 86400000);
let timeText = '';
if (diffMins < 60) {
timeText = `${diffMins}분 전`;
} else if (diffHours < 24) {
timeText = `${diffHours}시간 전`;
} else {
timeText = `${diffDays}일 전`;
}
return {
id: notification._id,
icon: notification.icon || 'pi pi-info-circle',
title: notification.title,
message: notification.message,
time: timeText,
read: notification.read,
link: notification.link
};
});
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
}
} catch (error) {
console.error('알림 가져오기 오류:', error);
}
};
// 모든 알림 읽음 표시
const markAllAsRead = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
await axios.put(`${API_URL}/notifications/read-all`, {}, {
headers: {
Authorization: `Bearer ${token}`
}
});
notifications.value = notifications.value.map(notification => ({
...notification,
read: true
}));
unreadNotificationsCount.value = 0;
} catch (error) {
console.error('모든 알림 읽음 표시 오류:', error);
}
};
// 단일 알림 읽음 표시
const markAsRead = async (notificationId) => {
try {
const token = localStorage.getItem('token');
if (!token) return;
await axios.put(`${API_URL}/notifications/${notificationId}/read`, {}, {
headers: {
Authorization: `Bearer ${token}`
}
});
const index = notifications.value.findIndex(n => n.id === notificationId);
if (index !== -1) {
notifications.value[index].read = true;
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
}
} catch (error) {
console.error('알림 읽음 표시 오류:', error);
}
};
// 알림 클릭 처리
const handleNotificationClick = (notification) => {
if (!notification.read) {
markAsRead(notification.id);
}
if (notification.link) {
router.push(notification.link);
showNotifications.value = false;
}
};
// 알림 삭제
const deleteNotification = async (notificationId, event) => {
event.stopPropagation();
try {
const token = localStorage.getItem('token');
if (!token) return;
await axios.delete(`${API_URL}/notifications/${notificationId}`, {
headers: {
Authorization: `Bearer ${token}`
}
});
notifications.value = notifications.value.filter(n => n.id !== notificationId);
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
} catch (error) {
console.error('알림 삭제 오류:', error);
}
};
// 관리자 메뉴 아이템
const adminMenuItems = ref([
{
label: '대시보드',
icon: 'pi pi-home',
command: () => {
router.push('/admin');
}
},
{
label: '클럽 관리',
icon: 'pi pi-users',
command: () => {
router.push('/admin/clubs');
}
},
{
label: '사용자 관리',
icon: 'pi pi-user',
command: () => {
router.push('/admin/users');
}
},
{
label: '구독 관리',
icon: 'pi pi-credit-card',
command: () => {
router.push('/admin/subscriptions');
}
}
]);
// 사용자 메뉴 아이템
const userMenuItems = ref([
{
label: '프로필',
icon: 'pi pi-user',
command: () => {
router.push('/profile');
}
},
{
separator: true
},
{
label: '로그아웃',
icon: 'pi pi-power-off',
command: logout
}
]);
const toggleUserMenu = (event) => {
userMenu.value.toggle(event);
};
const toggleAdminMenu = (event) => {
adminMenu.value.toggle(event);
};
// 메뉴 토글
const toggleNotifications = (event) => {
showNotifications.value = !showNotifications.value;
event.stopPropagation();
};
// 문서 클릭 시 알림 패널 닫기
const handleDocumentClick = (event) => {
const notificationWrapper = document.querySelector('.notification-wrapper');
if (notificationWrapper && !notificationWrapper.contains(event.target)) {
showNotifications.value = false;
}
};
// 컴포넌트 마운트/언마운트
onMounted(() => {
if (isLoggedIn.value) {
fetchNotifications();
}
document.addEventListener('click', handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
});
</script>
<style scoped>
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: #1976d2;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.logo h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.user-menu-wrapper,
.admin-menu-wrapper,
.notification-wrapper {
position: relative;
}
.user-menu-button,
.admin-menu-button,
.notification-button {
background-color: transparent !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: white !important;
}
.user-menu-button:hover,
.admin-menu-button:hover,
.notification-button:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.user-menu-button {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
}
.user-name {
margin: 0 0.5rem;
}
.notifications-panel {
position: absolute;
top: 45px;
right: 0;
width: 320px;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
color: #333;
}
.notifications-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.notifications-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
}
.notifications-content {
max-height: 350px;
overflow-y: auto;
}
.notification-item {
display: flex;
align-items: flex-start;
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.notification-item:hover {
background-color: #f5f5f5;
}
.notification-item.unread {
background-color: #f0f7ff;
border-left: 3px solid #1976d2;
}
.notification-item i {
font-size: 1.2rem;
color: #1976d2;
margin-right: 0.75rem;
margin-top: 0.25rem;
}
.notification-details {
flex: 1;
min-width: 0;
}
.notification-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.notification-message {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.notification-time {
font-size: 0.8rem;
color: #999;
}
.no-notifications {
padding: 2rem;
text-align: center;
color: #666;
}
.no-notifications i {
font-size: 2rem;
color: #ccc;
margin-bottom: 0.5rem;
}
.notifications-footer {
padding: 0.75rem;
text-align: center;
border-top: 1px solid #eee;
}
:deep(.p-menu) {
z-index: 1001 !important;
}
@media (max-width: 767px) {
.hide-on-mobile {
display: none;
}
.app-header {
padding: 1rem;
}
.notifications-panel {
position: fixed;
top: 64px;
left: 0;
right: 0;
width: auto;
margin: 0.5rem;
}
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
@@ -0,0 +1,365 @@
<template>
<div class="notification-system">
<Button
icon="pi pi-bell"
class="p-button-rounded p-button-text notification-button"
@click="toggleNotificationPanel"
:badge="unreadCount > 0 ? unreadCount.toString() : undefined"
badge-class="p-badge-danger"
/>
<Dialog
v-model:visible="notificationPanelVisible"
header="알림"
:style="{ width: '400px' }"
:modal="false"
position="right"
:draggable="false"
:closable="true"
:closeOnEscape="true"
>
<div class="notification-panel">
<div class="notification-actions">
<Button
label="모두 읽음으로 표시"
class="p-button-text p-button-sm"
@click="markAllAsRead"
:disabled="notifications.length === 0 || unreadCount === 0"
/>
<Button
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
@click="clearAllNotifications"
:disabled="notifications.length === 0"
/>
</div>
<div v-if="notifications.length === 0" class="empty-state">
<i class="pi pi-bell-slash empty-icon"></i>
<p>알림이 없습니다</p>
</div>
<div v-else class="notification-list">
<div
v-for="notification in notifications"
:key="notification.id"
class="notification-item"
:class="{ 'unread': !notification.read }"
@click="handleNotificationClick(notification)"
>
<div class="notification-icon">
<i :class="getNotificationIcon(notification.type)"></i>
</div>
<div class="notification-content">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-message">{{ notification.message }}</div>
<div class="notification-time">{{ formatTime(notification.createdAt) }}</div>
</div>
<div class="notification-actions">
<Button
icon="pi pi-times"
class="p-button-rounded p-button-text p-button-sm"
@click.stop="removeNotification(notification.id)"
/>
</div>
</div>
</div>
</div>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import axios from 'axios';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/ko';
dayjs.extend(relativeTime);
dayjs.locale('ko');
const toast = useToast();
const API_URL = 'http://localhost:3030/api';
// 상태 변수
const notifications = ref([]);
const notificationPanelVisible = ref(false);
let notificationPolling = null;
// 읽지 않은 알림 수
const unreadCount = computed(() => {
return notifications.value.filter(notification => !notification.read).length;
});
// 컴포넌트 마운트 시 알림 가져오기 및 폴링 시작
onMounted(() => {
fetchNotifications();
startNotificationPolling();
});
// 컴포넌트 언마운트 시 폴링 중지
onUnmounted(() => {
stopNotificationPolling();
});
// 알림 패널 토글
const toggleNotificationPanel = () => {
notificationPanelVisible.value = !notificationPanelVisible.value;
};
// 알림 가져오기
const fetchNotifications = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await axios.get(`${API_URL}/notifications`, {
headers: { Authorization: `Bearer ${token}` }
});
notifications.value = response.data;
} catch (error) {
console.error('알림을 가져오는 중 오류 발생:', error);
}
};
// 알림 폴링 시작 (30초마다)
const startNotificationPolling = () => {
notificationPolling = setInterval(() => {
fetchNotifications();
}, 30000);
};
// 알림 폴링 중지
const stopNotificationPolling = () => {
if (notificationPolling) {
clearInterval(notificationPolling);
notificationPolling = null;
}
};
// 알림 클릭 처리
const handleNotificationClick = async (notification) => {
// 읽음으로 표시
if (!notification.read) {
await markAsRead(notification.id);
}
// 알림 유형에 따른 처리
switch (notification.type) {
case 'club_invitation':
// 클럽 초대 페이지로 이동
window.location.href = `/clubs/invitations`;
break;
case 'meeting_reminder':
// 모임 상세 페이지로 이동
window.location.href = `/meetings/${notification.data.meetingId}`;
break;
case 'score_update':
// 점수 페이지로 이동
window.location.href = `/scores/${notification.data.scoreId}`;
break;
case 'subscription_reminder':
// 구독 페이지로 이동
window.location.href = `/subscriptions`;
break;
default:
// 기본적으로 알림 패널 닫기
notificationPanelVisible.value = false;
break;
}
};
// 알림 읽음으로 표시
const markAsRead = async (notificationId) => {
try {
const token = localStorage.getItem('token');
await axios.put(`${API_URL}/notifications/${notificationId}/read`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
// 로컬 상태 업데이트
const index = notifications.value.findIndex(n => n.id === notificationId);
if (index !== -1) {
notifications.value[index].read = true;
}
} catch (error) {
console.error('알림을 읽음으로 표시하는 중 오류 발생:', error);
}
};
// 모든 알림 읽음으로 표시
const markAllAsRead = async () => {
try {
const token = localStorage.getItem('token');
await axios.put(`${API_URL}/notifications/read-all`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
// 로컬 상태 업데이트
notifications.value = notifications.value.map(notification => ({
...notification,
read: true
}));
toast.add({ severity: 'success', summary: '성공', detail: '모든 알림이 읽음으로 표시되었습니다.', life: 3000 });
} catch (error) {
console.error('모든 알림을 읽음으로 표시하는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '알림 상태를 업데이트하는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 알림 삭제
const removeNotification = async (notificationId) => {
try {
const token = localStorage.getItem('token');
await axios.delete(`${API_URL}/notifications/${notificationId}`, {
headers: { Authorization: `Bearer ${token}` }
});
// 로컬 상태 업데이트
notifications.value = notifications.value.filter(n => n.id !== notificationId);
toast.add({ severity: 'success', summary: '성공', detail: '알림이 삭제되었습니다.', life: 3000 });
} catch (error) {
console.error('알림을 삭제하는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '알림을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 모든 알림 삭제
const clearAllNotifications = async () => {
try {
const token = localStorage.getItem('token');
await axios.delete(`${API_URL}/notifications/clear-all`, {
headers: { Authorization: `Bearer ${token}` }
});
// 로컬 상태 업데이트
notifications.value = [];
toast.add({ severity: 'success', summary: '성공', detail: '모든 알림이 삭제되었습니다.', life: 3000 });
} catch (error) {
console.error('모든 알림을 삭제하는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '알림을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 알림 아이콘 가져오기
const getNotificationIcon = (type) => {
switch (type) {
case 'club_invitation':
return 'pi pi-users';
case 'meeting_reminder':
return 'pi pi-calendar';
case 'score_update':
return 'pi pi-chart-bar';
case 'subscription_reminder':
return 'pi pi-credit-card';
case 'system':
return 'pi pi-info-circle';
default:
return 'pi pi-bell';
}
};
// 시간 포맷팅
const formatTime = (timestamp) => {
return dayjs(timestamp).fromNow();
};
</script>
<style scoped>
.notification-button {
position: relative;
}
.notification-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.notification-actions {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f0f0f0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #9e9e9e;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 1rem;
}
.notification-list {
overflow-y: auto;
max-height: 400px;
}
.notification-item {
display: flex;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.notification-item:hover {
background-color: #f5f5f5;
}
.notification-item.unread {
background-color: #e3f2fd;
}
.notification-item.unread:hover {
background-color: #bbdefb;
}
.notification-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f0f0f0;
margin-right: 0.75rem;
}
.notification-content {
flex: 1;
}
.notification-title {
font-weight: bold;
margin-bottom: 0.25rem;
}
.notification-message {
font-size: 0.9rem;
color: #616161;
margin-bottom: 0.25rem;
}
.notification-time {
font-size: 0.8rem;
color: #9e9e9e;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
@@ -0,0 +1,158 @@
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
:header="eventModel.title"
:style="{ width: '90vw', maxWidth: '1200px' }"
:modal="true"
class="event-details-dialog"
>
<TabView>
<!-- 기본 정보 -->
<TabPanel header="기본 정보">
<div class="event-details">
<div class="event-details-header">
<Badge :value="getEventTypeName(eventModel.eventType)" :severity="getStatusSeverity(eventModel.status)" />
<Badge :value="getStatusName(eventModel.status)" :severity="getStatusSeverity(eventModel.status)" />
</div>
<div class="event-details-content">
<div class="event-details-section">
<h4>이벤트 정보</h4>
<div class="grid">
<div class="col-12 md:col-6">
<p><strong>시작일:</strong> {{ formattedStartDate }}</p>
<p><strong>종료일:</strong> {{ formattedEndDate }}</p>
<p><strong>장소:</strong> {{ eventModel.location }}</p>
</div>
<div class="col-12 md:col-6">
<p><strong>최대 참가자 :</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p>
<p><strong>현재 참가자 :</strong> {{ eventModel.participants?.length || 0 }}</p>
</div>
</div>
</div>
<div class="event-details-section">
<h4>설명</h4>
<p>{{ eventModel.description || '설명이 없습니다.' }}</p>
</div>
</div>
</div>
</TabPanel>
<!-- 참가자 관리 -->
<TabPanel header="참가자 관리">
<ParticipantManagement
:eventId="eventModel.id"
:availableMembers="availableMembers"
@participant-updated="$emit('participant-updated')"
/>
</TabPanel>
<!-- 점수 관리 -->
<TabPanel header="점수 관리">
<ScoreManagement
:eventId="eventModel.id"
:participants="eventModel.participants || []"
:maxGames="eventModel.gameCount"
@score-updated="$emit('score-updated')"
/>
</TabPanel>
</TabView>
<template #footer>
<div class="dialog-footer">
<Button label="수정" icon="pi pi-pencil" class="p-button-text" @click="$emit('edit')" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import Dialog from 'primevue/dialog';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
import ParticipantManagement from './ParticipantManagement.vue';
import ScoreManagement from './ScoreManagement.vue';
import dayjs from 'dayjs';
const props = defineProps({
visible: {
type: Boolean,
required: true
},
event: {
type: Object,
required: true
},
getEventTypeName: {
type: Function,
required: true
},
getStatusName: {
type: Function,
required: true
},
getStatusSeverity: {
type: Function,
required: true
},
formatDate: {
type: Function,
required: true
},
availableMembers: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:visible', 'hide', 'edit', 'participant-updated', 'score-updated']);
const eventModel = computed(() => props.event);
const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate));
const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDate));
const hideDialog = () => {
emit('hide');
};
</script>
<style scoped>
.event-details {
margin-top: 1rem;
}
.event-details-header {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.event-details-section {
margin-bottom: 2rem;
}
.event-details-section h4 {
margin-bottom: 1rem;
color: var(--primary-color);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
:deep(.p-dialog-content) {
padding: 0 !important;
}
:deep(.p-tabview-panels) {
padding: 1.5rem !important;
}
</style>
@@ -0,0 +1,233 @@
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
:header="isNew ? '이벤트 추가' : '이벤트 수정'"
:style="{ width: '650px' }"
:modal="true"
class="p-fluid"
>
<div class="grid">
<div class="col-12">
<div class="field">
<label for="title">제목 *</label>
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" required autofocus />
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
</div>
</div>
<div class="col-12">
<div class="field">
<label for="description">설명</label>
<Textarea id="description" v-model="eventModel.description" rows="3" autoResize />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="eventType">이벤트 유형 *</label>
<Dropdown id="eventType" v-model="eventModel.eventType" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType}" />
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="status">상태 *</label>
<Dropdown id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status}" />
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="startDate">시작일 *</label>
<Calendar id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.startDate}" />
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="endDate">종료일</label>
<Calendar id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.endDate}" />
</div>
</div>
<div class="col-12">
<div class="field">
<label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location}" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" showButtons />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" showButtons />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.entryFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="registrationDeadline">등록 마감일</label>
<Calendar id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" />
</div>
</div>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveEvent" />
</template>
</Dialog>
</template>
<script setup>
import { ref, watch, toRefs, computed } from 'vue';
const statusOptions = [
{ name: '준비', value: '준비' },
{ name: '활성', value: '활성' },
{ name: '완료', value: '완료' },
{ name: '취소', value: '취소' },
{ name: '삭제', value: '삭제' }
];
const props = defineProps({
visible: {
type: Boolean,
required: true
},
event: {
type: Object,
required: true
},
isNew: {
type: Boolean,
required: true
},
submitted: {
type: Boolean,
required: true
}
});
const emit = defineEmits(['update:visible', 'save', 'hide']);
// 이벤트 타입 옵션
const eventTypeOptions = [
{ name: '정기전', value: '정기전' },
{ name: '번개', value: '번개' },
{ name: '연습', value: '연습' },
{ name: '교류전', value: '교류전' },
{ name: '이벤트', value: '이벤트' },
{ name: '기타', value: '기타' }
];
// 이벤트 모델
const eventModel = ref({ ...props.event });
// 날짜 형식 변환
const formatDate = (date) => {
if (!date) return null;
const d = new Date(date);
d.setHours(d.getHours() + 9); // UTC to KST
return d;
};
// props 변경 시 이벤트 모델 업데이트
watch(() => props.event, (newValue) => {
const formattedEvent = {
...newValue,
startDate: formatDate(newValue.startDate),
endDate: formatDate(newValue.endDate),
registrationDeadline: formatDate(newValue.registrationDeadline)
};
eventModel.value = formattedEvent;
}, { deep: true });
// 이벤트 저장 시 UTC로 변환
const saveEvent = () => {
const eventData = { ...eventModel.value };
if (eventData.startDate) {
const d = new Date(eventData.startDate);
d.setHours(d.getHours() - 9); // KST to UTC
eventData.startDate = d;
}
if (eventData.endDate) {
const d = new Date(eventData.endDate);
d.setHours(d.getHours() - 9); // KST to UTC
eventData.endDate = d;
}
if (eventData.registrationDeadline) {
const d = new Date(eventData.registrationDeadline);
d.setHours(d.getHours() - 9); // KST to UTC
eventData.registrationDeadline = d;
}
emit('save', eventData);
};
// 다이얼로그 닫기
const hideDialog = () => {
emit('hide');
};
</script>
<style scoped>
.field {
margin-bottom: 0.75rem;
}
.grid {
display: flex;
flex-wrap: wrap;
margin: -0.25rem;
}
.col-12 {
width: 100%;
padding: 0.25rem;
}
.md\:col-6 {
width: 50%;
padding: 0.25rem;
}
.dialog-footer {
padding: 0.5rem;
}
.p-dialog-content {
padding: 1rem;
}
.p-dialog-footer {
padding: 0.5rem;
}
:deep(.p-dropdown),
:deep(.p-calendar),
:deep(.p-inputnumber) {
width: 100%;
}
:deep(.p-inputtext) {
width: 100%;
}
</style>
@@ -0,0 +1,380 @@
<template>
<div class="participant-management">
<Toast />
<div class="header">
<Button label="참가자 추가" icon="pi pi-plus" class="p-button-success" @click="openNewDialog" />
</div>
<DataTable
:value="participants"
:loading="loading"
class="mt-4"
:paginator="true"
:rows="10"
stripedRows
v-model:filters="filters"
>
<Column field="Member.name" header="이름" sortable>
<template #body="slotProps">
<div class="flex flex-column">
<span>{{ slotProps.data.Member.name }}</span>
<small class="text-500">{{ slotProps.data.Member.phone }}</small>
</div>
</template>
</Column>
<Column field="Member.memberType" header="회원 유형" sortable>
<template #body="slotProps">
<Badge :value="getMemberTypeName(slotProps.data.Member.memberType)" :severity="getMemberTypeSeverity(slotProps.data.Member.memberType)" />
</template>
</Column>
<Column field="status" header="참가 상태" sortable>
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column field="paymentStatus" header="결제 상태" sortable>
<template #body="slotProps">
<Badge :value="getPaymentStatusName(slotProps.data.paymentStatus)" :severity="getPaymentStatusSeverity(slotProps.data.paymentStatus)" />
</template>
</Column>
<Column field="createdAt" header="등록일" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.createdAt) }}
</template>
</Column>
<Column header="작업">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editParticipant(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger" @click="confirmDeleteParticipant(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 참가자 추가/수정 다이얼로그 -->
<Dialog v-model:visible="participantDialog" :header="dialogTitle" modal class="p-fluid" :style="{width: '450px'}">
<div class="field">
<label for="member">회원</label>
<Dropdown
id="member"
v-model="editingParticipant.memberId"
:options="props.availableMembers"
optionLabel="name"
optionValue="id"
placeholder="회원을 선택하세요"
:class="{ 'p-invalid': submitted && !editingParticipant.memberId }"
:disabled="!!editingParticipant.id"
/>
<small v-if="submitted && !editingParticipant.memberId" class="p-error">회원을 선택해주세요.</small>
</div>
<div class="field">
<label for="status">참가 상태</label>
<Dropdown
id="status"
v-model="editingParticipant.status"
:options="participantStatusOptions"
optionLabel="name"
optionValue="value"
placeholder="상태를 선택하세요"
:class="{ 'p-invalid': submitted && !editingParticipant.status }"
/>
<small v-if="submitted && !editingParticipant.status" class="p-error">참가 상태를 선택해주세요.</small>
</div>
<div class="field">
<label for="paymentStatus">결제 상태</label>
<Dropdown
id="paymentStatus"
v-model="editingParticipant.paymentStatus"
:options="paymentStatusOptions"
optionLabel="name"
optionValue="value"
placeholder="결제 상태를 선택하세요"
:class="{ 'p-invalid': submitted && !editingParticipant.paymentStatus }"
/>
<small v-if="submitted && !editingParticipant.paymentStatus" class="p-error">결제 상태를 선택해주세요.</small>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveParticipant" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteDialog" :style="{width: '450px'}" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span> 참가자를 제거하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteParticipant" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, defineEmits } from 'vue';
import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService';
import dayjs from 'dayjs';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import Badge from 'primevue/badge';
import Toast from 'primevue/toast';
const props = defineProps({
eventId: {
type: [String, Number],
required: true
},
availableMembers: {
type: Array,
required: true
}
});
const emit = defineEmits(['participant-updated']);
const toast = useToast();
const participants = ref([]);
const loading = ref(false);
const filters = ref({});
const participantDialog = ref(false);
const deleteDialog = ref(false);
const submitted = ref(false);
const clubId = ref(localStorage.getItem('clubId') || null);
const editingParticipant = ref({
clubId: clubId.value,
memberId: null,
status: '참가예정',
paymentStatus: '미납'
});
const participantToDelete = ref(null);
const participantStatusOptions = [
{ name: '참가 예정', value: '참가예정' },
{ name: '참가 확정', value: '참가확정' },
{ name: '취소', value: '취소' }
];
const paymentStatusOptions = [
{ name: '미납', value: '미납' },
{ name: '납부 완료', value: '납부완료' }
];
const fetchParticipants = async () => {
try {
loading.value = true;
const data = await eventService.getEventParticipants(props.eventId);
participants.value = data;
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 목록을 불러오는 중 오류가 발생했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
const openNewDialog = () => {
editingParticipant.value = {
clubId: localStorage.getItem('clubId') || null,
memberId: null,
status: '참가예정',
paymentStatus: '미납'
};
submitted.value = false;
participantDialog.value = true;
};
const editParticipant = (participant) => {
editingParticipant.value = {
id: participant.id,
clubId: localStorage.getItem('clubId') || null,
memberId: participant.memberId,
status: participant.status,
paymentStatus: participant.paymentStatus
};
participantDialog.value = true;
submitted.value = false;
};
const hideDialog = () => {
participantDialog.value = false;
submitted.value = false;
};
const saveParticipant = async () => {
submitted.value = true;
if (!editingParticipant.value.memberId) {
return;
}
try {
if (editingParticipant.value.id) {
// 수정
await eventService.updateEventParticipant(props.eventId, editingParticipant.value);
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자 정보가 수정되었습니다.',
life: 3000
});
} else {
// 신규 등록
await eventService.addEventParticipant(props.eventId, editingParticipant.value);
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자가 추가되었습니다.',
life: 3000
});
}
await fetchParticipants();
hideDialog();
} catch (error) {
console.error('참가자 저장 오류:', error.message);
toast.add({
severity: 'error',
summary: '오류',
detail: error.message,
life: 3000
});
}
};
const confirmDeleteParticipant = (participant) => {
participantToDelete.value = participant;
deleteDialog.value = true;
};
const deleteParticipant = async () => {
try {
await eventService.removeEventParticipant(props.eventId, { id: participantToDelete.value.id });
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자가 삭제되었습니다.',
life: 3000
});
deleteDialog.value = false;
participantToDelete.value = null;
await fetchParticipants();
} catch (error) {
console.error('참가자 삭제 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 삭제 중 오류가 발생했습니다.',
life: 3000
});
}
};
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm');
};
const getMemberTypeName = (type) => {
const types = {
regular: '정회원',
associate: '준회원',
guest: '게스트'
};
return types[type] || type;
};
const getMemberTypeSeverity = (type) => {
const severities = {
regular: 'success',
associate: 'info',
guest: 'warning'
};
return severities[type] || 'info';
};
const getStatusName = (status) => {
const statuses = {
참가예정: '참가 예정',
참가완료: '참가 완료',
취소: '취소'
};
return statuses[status] || status;
};
const getStatusSeverity = (status) => {
const severities = {
참가예정: 'warning',
참가완료: 'success',
취소: 'danger'
};
return severities[status] || 'info';
};
const getPaymentStatusName = (status) => {
const statuses = {
미납: '미납',
납부완료: '납부 완료'
};
return statuses[status] || status;
};
const getPaymentStatusSeverity = (status) => {
const severities = {
미납: 'danger',
납부완료: 'success'
};
return severities[status] || 'info';
};
const dialogTitle = computed(() => {
return editingParticipant.value.id ? '참가자 정보 수정' : '참가자 추가';
});
onMounted(() => {
fetchParticipants();
});
</script>
<style scoped>
.participant-management {
padding: 1rem;
}
.header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.field {
margin-bottom: 1.5rem;
}
.field label {
display: block;
margin-bottom: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
}
</style>
<style>
:deep(.p-toast) {
z-index: 9999;
}
</style>
@@ -0,0 +1,489 @@
<template>
<div class="score-management">
<DataTable
:value="groupedScores"
:loading="loading"
class="mt-4"
:paginator="true"
:rows="10"
stripedRows
v-model:filters="filters"
>
<Column field="participant.name" header="참가자" sortable>
<template #body="slotProps">
{{ slotProps.data.participant?.name }}
</template>
</Column>
<Column field="teamNumber" header="팀" sortable style="width: 80px">
<template #body="slotProps">
{{ slotProps.data.teamNumber }}
</template>
</Column>
<template v-for="i in maxGames" :key="i">
<Column :field="'games.' + (i-1) + '.score'" :header="i + '게임'" sortable>
<template #body="slotProps">
<div class="flex flex-column">
<span :class="getScoreClass(getTotalScore(slotProps.data.games[i-1]))">
{{ getTotalScore(slotProps.data.games[i-1]) || '-' }}
</span>
<span class="text-xs text-gray-500" v-if="slotProps.data.games[i-1]?.score && slotProps.data.games[i-1]?.handicap !== 0">
{{ formatScoreWithHandicap(slotProps.data.games[i-1]) }}
</span>
</div>
</template>
</Column>
</template>
<Column field="average" header="평균" sortable>
<template #body="slotProps">
{{ calculateAverage(slotProps.data.games) }}
</template>
</Column>
<Column header="작업">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editScore(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text" @click="deleteParticipantScores(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 점수 입력/수정 다이얼로그 -->
<Dialog v-model:visible="scoreDialog" :header="scoreDialogTitle" modal class="p-fluid" :style="{width: '600px'}">
<div class="field">
<label for="participant">참가자</label>
<Dropdown
id="participant"
v-model="editingScore.participantId"
:options="participants"
optionLabel="name"
optionValue="id"
placeholder="참가자 선택"
:class="{'p-invalid': submitted && !editingScore.participantId}"
:disabled="!!editingScore.id"
/>
<small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small>
</div>
<div class="field">
<label for="teamNumber"></label>
<InputNumber
id="teamNumber"
v-model="editingScore.teamNumber"
:min="1"
:max="10"
placeholder="팀 번호"
/>
</div>
<div class="grid">
<template v-for="game in editingScore.games" :key="game.gameNumber">
<div class="col-6">
<div class="field">
<label :for="'game' + game.gameNumber">{{ game.gameNumber }}게임 점수</label>
<InputNumber
:id="'game' + game.gameNumber"
v-model="game.score"
:min="0"
:max="300"
:placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'"
@update:modelValue="() => onScoreChange(game)"
/>
</div>
</div>
<div class="col-6">
<div class="field">
<label :for="'handicap' + game.gameNumber">{{ game.gameNumber }}게임 핸디캡</label>
<InputNumber
:id="'handicap' + game.gameNumber"
v-model="game.handicap"
:min="-300"
:max="300"
placeholder="핸디캡 입력"
@update:modelValue="() => onScoreChange(game)"
/>
</div>
</div>
</template>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideScoreDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveScore" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteScoreDialog" :style="{width: '450px'}" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span> 점수를 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteScoreDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteScore" />
</template>
</Dialog>
<!-- 점수 추가 버튼 -->
<div class="flex justify-content-end mt-4">
<Button label="점수 추가" icon="pi pi-plus" @click="showScoreDialog" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService';
import dayjs from 'dayjs';
const props = defineProps({
eventId: {
type: [String, Number],
required: true
},
maxGames: {
type: Number,
required: true
}
});
const emit = defineEmits(['score-updated']);
const toast = useToast();
const scores = ref([]);
const loading = ref(false);
const filters = ref({});
const scoreDialog = ref(false);
const deleteScoreDialog = ref(false);
const submitted = ref(false);
const participants = ref([]);
const editingScore = ref({
isEdit: false,
participantId: null,
teamNumber: null,
games: []
});
// 점수 목록 조회
const fetchScores = async () => {
loading.value = true;
try {
const response = await eventService.getEventScores(props.eventId);
scores.value = response || [];
await fetchParticipants();
} catch (error) {
console.error('점수 조회 오류:', error);
scores.value = [];
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '점수 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
// 참가자 목록 조회
const fetchParticipants = async () => {
try {
const data = await eventService.getEventParticipants(props.eventId);
if (Array.isArray(data)) {
participants.value = data.map(p => ({
id: p.id,
name: p.Member.name
}));
} else {
participants.value = [];
console.error('참가자 데이터가 배열이 아닙니다:', data);
}
} catch (error) {
console.error('참가자 조회 오류:', error);
participants.value = [];
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '참가자 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
}
};
// 점수 수정 다이얼로그 열기
const editScore = (data) => {
// console.log('Edit score data:', data);
editingScore.value = {
isEdit: true,
participantId: data.participant.id,
teamNumber: data.teamNumber,
games: data.games.map((game, index) => ({
id: game.id,
gameNumber: game.gameNumber ?? index + 1,
score: game.score ?? null,
handicap: game.handicap ?? null,
isModified: false
}))
};
scoreDialog.value = true;
};
// 신규 점수 입력 다이얼로그 열기
const showScoreDialog = async () => {
try {
// 참가자 목록 갱신
await fetchParticipants();
editingScore.value = {
isEdit: false,
participantId: null,
teamNumber: null,
games: Array.from({ length: props.maxGames }, (_, i) => ({
id: null,
gameNumber: i + 1,
score: null,
handicap: null,
isModified: false
}))
};
submitted.value = false;
scoreDialog.value = true;
} catch (error) {
console.error('점수 입력 다이얼로그 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
}
};
// 다이얼로그 닫기
const hideScoreDialog = () => {
scoreDialog.value = false;
submitted.value = false;
};
// 점수 저장
const saveScore = async () => {
submitted.value = true;
if (!editingScore.value.participantId) {
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자를 선택해주세요.',
life: 3000
});
return;
}
try {
if (editingScore.value.isEdit) {
// 수정 모드: 변경된 게임만 업데이트
const updatePromises = editingScore.value.games
.map((game) => {
// 점수가 없으면 건너뛰기
if (game.score === null || game.score === undefined) return null;
// 기존 게임이면서 수정되지 않은 경우 건너뛰기
if (game.id && !game.isModified) return null;
const scoreData = {
gameNumber: game.gameNumber,
score: game.score,
handicap: game.handicap || 0
};
// 기존 게임 수정
if (game.id) {
console.log('Updating existing game:', {
eventId: props.eventId,
scoreId: game.id,
...scoreData
});
return eventService.updateScore(
Number(props.eventId),
Number(game.id),
scoreData
);
}
// 새로운 게임 추가
console.log('Adding new game in edit mode:', {
eventId: props.eventId,
participantId: editingScore.value.participantId,
...scoreData
});
return eventService.addScore(props.eventId, {
...scoreData,
participantId: editingScore.value.participantId,
teamNumber: editingScore.value.teamNumber
});
})
.filter(Boolean);
if (updatePromises.length > 0) {
await Promise.all(updatePromises);
}
} else {
// 신규 등록 모드 (변경 없음)
const addPromises = editingScore.value.games
.filter(game => game.score !== null && game.score !== undefined)
.map(game => {
const scoreData = {
participantId: editingScore.value.participantId,
teamNumber: editingScore.value.teamNumber,
gameNumber: game.gameNumber,
score: Number(game.score),
handicap: Number(game.handicap || 0)
};
console.log('Adding game:', scoreData);
return eventService.addScore(props.eventId, scoreData);
});
if (addPromises.length > 0) {
await Promise.all(addPromises);
}
}
toast.add({
severity: 'success',
summary: '성공',
detail: '점수가 저장되었습니다.',
life: 3000
});
hideScoreDialog();
await fetchScores();
} catch (error) {
console.error('점수 저장 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '점수 저장 중 오류가 발생했습니다.',
life: 3000
});
}
};
const getScoreClass = (score) => {
if (!score) return '';
if (score >= 200) return 'text-red-600 font-bold';
return '';
};
const scoreDialogTitle = computed(() => {
return editingScore.value.isEdit ? '점수 수정' : '점수 등록';
});
const getTotalScore = (game) => {
if (!game || !game.score || game.score === 0) return null;
return game.score + (game.handicap || 0);
};
const calculateAverage = (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 / validGames.length).toFixed(1);
};
const formatScoreWithHandicap = (game) => {
if (!game || !game.score) return '';
const handicap = game.handicap || 0;
if (handicap === 0) return `(${game.score})`;
if (handicap < 0) return `(${game.score}${handicap})`;
return `(${game.score}+${handicap})`;
};
const groupedScores = computed(() => {
if (!participants.value || !scores.value) {
return [];
}
const grouped = {};
const participantsWithScores = new Set();
scores.value.forEach(score => {
if (!grouped[score.participantId]) {
const participant = participants.value.find(p => p.id === score.participantId);
if (participant) {
grouped[score.participantId] = {
participant,
participantId: participant.id,
teamNumber: score.teamNumber,
games: Array(props.maxGames).fill(null).map(() => ({ score: null, handicap: null, gameNumber: null }))
};
participantsWithScores.add(score.participantId);
}
}
if (grouped[score.participantId]) {
grouped[score.participantId].games[score.gameNumber - 1] = {
id: score.id,
score: score.score,
handicap: score.handicap,
gameNumber: score.gameNumber
};
}
});
return Object.values(grouped);
});
// 점수 변경 시 호출
const onScoreChange = (game) => {
if (game) {
game.isModified = true;
/*
console.log('Score changed:', {
gameNumber: game.gameNumber,
score: game.score,
handicap: game.handicap,
isModified: game.isModified
});
*/
}
};
const deleteParticipantScores = async (data) => {
try {
await eventService.deleteParticipantScores(
Number(props.eventId),
Number(data.participant.id)
);
toast.add({
severity: 'success',
summary: '성공',
detail: '점수가 삭제되었습니다.',
life: 3000
});
await fetchScores();
} catch (error) {
console.error('점수 삭제 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '점수 삭제 중 오류가 발생했습니다.',
life: 3000
});
}
};
onMounted(() => {
fetchScores(); // 참가자 목록은 여기서 가져오지 않음
});
</script>
<style scoped>
.score-management {
padding: 1rem;
}
</style>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>