init
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve 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>
|
||||
@@ -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>
|
||||
|
||||
Vue’s
|
||||
<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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user