This commit is contained in:
2025-04-17 23:32:39 +09:00
commit 5b719b6766
105 changed files with 25653 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+29
View File
@@ -0,0 +1,29 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+3528
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2",
"chart.js": "^4.4.8",
"dayjs": "^1.11.10",
"jwt-decode": "^4.0.0",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0",
"yup": "^1.3.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.2.1",
"vite-plugin-vue-devtools": "^7.7.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+533
View File
@@ -0,0 +1,533 @@
<template>
<div class="app">
<AppHeader />
<div class="app-content">
<!-- 사이드바 오버레이 추가 -->
<div v-if="isSidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div>
<!-- 사이드바에 모바일 클래스 추가 -->
<aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': isSidebarVisible, 'mobile-hidden': !isSidebarVisible && isMobileView }">
<div class="sidebar-content">
<!-- 클럽 정보 -->
<div class="club-info" v-if="selectedClub">
<div class="club-header">
<h3>{{ selectedClub.name }}</h3>
<p class="club-location"><i class="pi pi-map-marker"></i> {{ selectedClub.location }}</p>
</div>
<div class="club-stats">
<div class="stat-item">
<i class="pi pi-users"></i>
<span>{{ memberCount }} </span>
</div>
<div class="stat-item">
<i class="pi pi-user"></i>
<span>모임장: {{ ownerName }}</span>
</div>
</div>
</div>
<!-- 클럽 선택 기능 (관리자 또는 클럽이 2 이상) -->
<div v-if="isAdmin || clubs.length > 1" class="club-selector">
<label for="club-select">클럽 선택:</label>
<select id="club-select" v-model="selectedClubId" @change="onClubChange">
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
</select>
</div>
<!-- 커스텀 사이드바 메뉴 -->
<div class="custom-sidebar-menu">
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
<div class="menu-group">
<div class="menu-group-header" @click="toggleMenuGroup(groupIndex)">
<i :class="menuGroup.icon"></i>
<span>{{ menuGroup.label }}</span>
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</div>
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
<router-link
v-for="(item, itemIndex) in menuGroup.items"
:key="itemIndex"
:to="item.to"
class="menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</router-link>
</div>
</div>
</template>
<!-- 그룹이 없는 단일 메뉴 아이템 -->
<template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex">
<router-link
:to="menuItem.to"
class="menu-item single-menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="menuItem.icon"></i>
<span>{{ menuItem.label }}</span>
</router-link>
</template>
</div>
</div>
</aside>
<main class="main-content" :class="{ 'full-width': !isLoggedIn }">
<router-view />
</main>
</div>
<footer>
<p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
</footer>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, provide } from 'vue';
import { useRouter } from 'vue-router';
import AppHeader from './components/AppHeader.vue';
import apiClient from './services/api';
import authService from './services/authService';
// 상태 변수
const user = ref(null);
provide('user', user);
const menuItems = ref([]);
const singleMenuItems = ref([]);
const expandedGroups = ref({});
const isMobileView = ref(window.innerWidth < 768);
const isSidebarVisible = ref(!isMobileView.value);
const isLoggedIn = ref(!!localStorage.getItem('token'));
const clubs = ref([]); // 클럽 목록
const selectedClubId = ref(localStorage.getItem('clubId') || null); // 선택된 클럽 ID
const selectedClub = ref(null);
const memberCount = ref(0);
const ownerName = ref('');
const userRole = computed(() => user.value?.role || '');
const isAdmin = computed(() => userRole.value === 'admin' || userRole.value === 'superadmin');
// 화면 크기 변경 감지
const handleResize = () => {
isMobileView.value = window.innerWidth < 768;
if (!isMobileView.value) {
isSidebarVisible.value = true;
}
};
// 사이드바 토글
const toggleSidebar = () => {
isSidebarVisible.value = !isSidebarVisible.value;
};
// 사이드바 닫기
const closeSidebar = () => {
if (isMobileView.value) {
isSidebarVisible.value = false;
}
};
// 모바일에서 메뉴 아이템 클릭 시 사이드바 닫기
const closeSidebarOnMobile = () => {
if (isMobileView.value) {
closeSidebar();
}
};
// 메뉴 그룹 토글
const toggleMenuGroup = (groupIndex) => {
expandedGroups.value[groupIndex] = !expandedGroups.value[groupIndex];
};
// 모든 메뉴 그룹 초기화 (모두 펼치기)
const initializeExpandedGroups = () => {
menuItems.value.forEach((_, index) => {
expandedGroups.value[index] = true;
});
};
// 클럽 목록 가져오기
const fetchClubs = async () => {
try {
const userId = user.value?.id;
const userRole = localStorage.getItem('userRole');
const response = await apiClient.post('/api/club/user', {
userId,
role: userRole
});
clubs.value = response.data.map(club => ({
...club,
memberType: club.memberType || ''
}));
// 첫 번째 클럽의 memberType을 localStorage에 저장
if (clubs.value.length > 0) {
localStorage.setItem('userClubRole', clubs.value[0].memberType || '');
}
} catch (error) {
console.error('클럽 목록 로드 실패:', error);
}
};
// 클럽 정보 가져오기
const fetchClubInfo = async () => {
try {
const response = await apiClient.post(`/api/club`, { clubId: selectedClubId.value ?? clubs.value[0].id });
if (response.data) {
selectedClub.value = {
id: response.data.id,
name: response.data.name,
location: response.data.location,
};
memberCount.value = response.data.memberCount;
ownerName.value = response.data.ownerName;
}
} catch (error) {
console.error('클럽 정보 가져오기 오류:', error);
}
};
// 클럽 변경 시
const router = useRouter();
const onClubChange = async () => {
try {
// 선택된 클럽의 memberType 찾기
const selectedClub = clubs.value.find(club => club.id === selectedClubId.value);
if (selectedClub) {
localStorage.setItem('userClubRole', selectedClub.memberType || '');
localStorage.setItem('clubId', selectedClub.id);
}
await fetchClubInfo();
loadMenuItems();
// 라우터의 네비게이션을 강제로 새로고침
router.go(0);
} catch (error) {
console.error('클럽 변경 실패:', error);
}
};
// 메뉴 아이템 로드
const loadMenuItems = async () => {
try {
const role = localStorage.getItem('userRole');
const clubId = selectedClubId.value;
if (!clubId) {
throw new Error('클럽 ID가 없습니다.');
}
try {
const response = await apiClient.post(`/api/menus`, {
role: role,
clubId: clubId
});
if (response.data && response.data.success && response.data.data && response.data.data.length > 0) {
const groupMenus = [];
const singleMenus = [];
// 서버에서 받은 메뉴 처리 (관리자 메뉴 제외)
response.data.data
.forEach(menuGroup => {
if (menuGroup.children && menuGroup.children.length > 0) {
const processedItems = menuGroup.children.map(item => ({
label: item.title,
icon: item.icon,
to: item.to
}));
groupMenus.push({
label: menuGroup.title,
icon: menuGroup.icon,
items: processedItems
});
} else if (menuGroup.to) {
singleMenus.push({
label: menuGroup.title,
icon: menuGroup.icon,
to: menuGroup.to
});
}
});
menuItems.value = groupMenus;
singleMenuItems.value = singleMenus;
} else {
menuItems.value = [];
singleMenuItems.value = [];
}
} catch (error) {
console.error('메뉴 아이템 로드 오류:', error);
menuItems.value = [];
singleMenuItems.value = [];
}
initializeExpandedGroups();
} catch (error) {
console.error('메뉴 아이템 로드 오류:', error);
menuItems.value = [];
singleMenuItems.value = [];
}
};
// 사용자 정보 로드
const loadUserData = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
const response = await authService.getCurrentUser();
user.value = response.data;
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
}
}
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
await loadUserData();
if (isLoggedIn.value) {
await fetchClubs();
await fetchClubInfo();
loadMenuItems();
}
// 화면 크기 변경 감지
window.addEventListener('resize', handleResize);
// 초기 expandedGroups 설정
initializeExpandedGroups();
});
// 컴포넌트 언마운트 시 이벤트 리스너 제거
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
/* App.vue 스타일 */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-content {
display: flex;
flex: 1;
}
/* 사이드바 스타일 */
.sidebar {
width: 250px;
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
transition: all 0.3s ease;
position: relative;
z-index: 2;
}
.sidebar.mobile-visible {
transform: translateX(0);
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.sidebar.mobile-hidden {
transform: translateX(-100%);
}
.sidebar-content {
padding: 20px;
}
.club-info {
margin-bottom: 20px;
}
.club-header {
margin-bottom: 10px;
}
.club-header h3 {
margin: 0;
font-size: 1.2em;
font-weight: bold;
}
.club-location {
margin: 0;
font-size: 0.9em;
color: #6c757d;
}
.club-stats {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.stat-item {
margin-bottom: 10px;
display: flex;
align-items: center;
}
.stat-item i {
margin-right: 5px;
font-size: 1em;
}
.stat-item span {
font-size: 0.9em;
}
/* 클럽 선택 스타일 */
.club-selector {
margin-bottom: 20px;
}
.club-selector label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.club-selector select {
width: 100%;
padding: 8px;
border: 1px solid #ced4da;
border-radius: 4px;
box-sizing: border-box;
}
/* 사이드바 메뉴 스타일 */
.custom-sidebar-menu {
margin-top: 20px;
}
.menu-group-header {
display: flex;
align-items: center;
padding: 10px 15px;
background-color: #e9ecef;
border-radius: 5px;
margin-bottom: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.menu-group-header:hover {
background-color: #dee2e6;
}
.menu-group-header i {
margin-right: 10px;
font-size: 1.1em;
}
.menu-group-header span {
font-weight: bold;
}
.menu-items {
padding-left: 15px;
margin-bottom: 10px;
}
.menu-item {
display: flex;
align-items: center;
padding: 8px 15px;
border-radius: 5px;
text-decoration: none;
color: #495057;
transition: background-color 0.3s ease;
}
.menu-item:hover {
background-color: #f1f3f5;
}
.menu-item i {
margin-right: 10px;
font-size: 1em;
}
.menu-item span {
font-size: 1em;
}
.router-link-active {
background-color: #007bff;
color: white;
}
.router-link-active:hover {
background-color: #0069d9;
}
.single-menu-item {
margin-bottom: 5px;
}
/* 메인 컨텐츠 스타일 */
.main-content {
flex: 1;
padding: 20px;
transition: margin-left 0.3s ease;
}
.main-content.full-width {
margin-left: 0;
}
/* 푸터 스타일 */
footer {
background-color: #343a40;
color: white;
text-align: center;
padding: 10px;
}
/* 사이드바 오버레이 스타일 */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1;
}
/* 미디어 쿼리 (모바일 뷰) */
@media (max-width: 767px) {
.sidebar {
width: 200px;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 3;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.main-content {
padding: 10px;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 277 B

+35
View File
@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
+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>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="club-layout">
<router-view></router-view>
</div>
</template>
<script>
export default {
name: 'ClubLayout'
}
</script>
<style scoped>
.club-layout {
width: 100%;
height: 100%;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// PrimeVue
import PrimeVue from 'primevue/config'
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
import 'primeflex/primeflex.css'
// PrimeVue Components
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import Dropdown from 'primevue/dropdown'
import Toast from 'primevue/toast'
import ToastService from 'primevue/toastservice'
import Card from 'primevue/card'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Menu from 'primevue/menu'
import Menubar from 'primevue/menubar'
import PanelMenu from 'primevue/panelmenu'
import Chart from 'primevue/chart'
import Avatar from 'primevue/avatar'
import Checkbox from 'primevue/checkbox'
import Password from 'primevue/password'
import Textarea from 'primevue/textarea'
import Badge from 'primevue/badge'
import Calendar from 'primevue/calendar'
import InputNumber from 'primevue/inputnumber'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
const app = createApp(App)
// Register PrimeVue
app.use(PrimeVue)
app.use(ToastService)
app.use(createPinia())
app.use(router)
// Register PrimeVue Components
app.component('Button', Button)
app.component('InputText', InputText)
app.component('DataTable', DataTable)
app.component('Column', Column)
app.component('Dialog', Dialog)
app.component('Dropdown', Dropdown)
app.component('Toast', Toast)
app.component('Card', Card)
app.component('TabView', TabView)
app.component('TabPanel', TabPanel)
app.component('Menu', Menu)
app.component('Menubar', Menubar)
app.component('PanelMenu', PanelMenu)
app.component('Chart', Chart)
app.component('Avatar', Avatar)
app.component('Checkbox', Checkbox)
app.component('Password', Password)
app.component('Textarea', Textarea)
app.component('Badge', Badge)
app.component('Calendar', Calendar)
app.component('InputNumber', InputNumber)
app.component('Divider', Divider)
app.component('ProgressSpinner', ProgressSpinner)
app.mount('#app')
+180
View File
@@ -0,0 +1,180 @@
import { createRouter, createWebHistory } from 'vue-router';
// 인증 관련 뷰
const Login = () => import('@/views/auth/Login.vue');
const Profile = () => import('@/views/auth/Profile.vue');
// 관리자 뷰
const AdminDashboard = () => import('@/views/admin/Dashboard.vue');
const ClubManagement = () => import('@/views/admin/ClubManagement.vue');
const UserManagement = () => import('@/views/admin/UserManagement.vue');
const SubscriptionManagement = () => import('@/views/admin/SubscriptionManagement.vue');
// 클럽 뷰
const ClubDashboard = () => import('@/views/club/Dashboard.vue');
const MemberManagement = () => import('@/views/club/MemberManagement.vue');
const Statistics = () => import('@/views/club/Statistics.vue');
const EventManagement = () => import('@/views/club/EventManagement.vue');
const EventCalendar = () => import('@/views/club/EventCalendar.vue');
const ScoreManagement = () => import('@/views/club/ScoreManagement.vue');
const UserClubCreate = () => import('@/views/club/ClubCreate.vue');
// 구독 관리 컴포넌트 import
const ClubSubscriptionManagement = () => import('@/views/club/subscription/SubscriptionManagement.vue');
const routes = [
// 공개 경로
{
path: '/',
redirect: '/login'
},
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false }
},
// 인증 필요 경로
{
path: '/profile',
name: 'Profile',
component: Profile,
meta: { requiresAuth: true }
},
// 관리자 경로
{
path: '/admin',
name: 'AdminDashboard',
component: AdminDashboard,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/clubs',
name: 'ClubManagement',
component: ClubManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/users',
name: 'UserManagement',
component: UserManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/subscriptions',
name: 'SubscriptionManagement',
component: SubscriptionManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
// 클럽 경로
{
path: '/club',
component: () => import('@/layouts/ClubLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'ClubDashboard',
component: () => import('@/views/club/Dashboard.vue')
},
{
path: 'members',
name: 'MemberManagement',
component: () => import('@/views/club/MemberManagement.vue')
},
{
path: 'events',
name: 'EventManagement',
component: () => import('@/views/club/EventManagement.vue')
},
{
path: 'events/calendar',
name: 'EventCalendar',
component: () => import('@/views/club/EventCalendar.vue')
},
{
path: 'events/:eventId/scores',
name: 'ScoreManagement',
component: () => import('@/views/club/ScoreManagement.vue'),
props: true
},
{
path: 'statistics',
name: 'Statistics',
component: () => import('@/views/club/Statistics.vue')
},
{
path: 'subscription',
name: 'ClubSubscription',
component: ClubSubscriptionManagement,
meta: {
requiresAuth: true,
requiredRoles: ['모임장', '운영진']
}
}
]
},
{
path: '/club/create',
name: 'UserClubCreate',
component: () => import('@/views/club/ClubCreate.vue'),
meta: { requiresAuth: true }
}
];
const router = createRouter({
history: createWebHistory(),
routes,
linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-active'
});
// 네비게이션 가드
router.beforeEach(async (to, from, next) => {
const isAuthenticated = !!localStorage.getItem('token');
const userRole = localStorage.getItem('userRole');
const clubId = localStorage.getItem('clubId');
const userClubRole = localStorage.getItem('userClubRole');
// 인증이 필요한 페이지인데 로그인하지 않은 경우
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'Login' });
return;
}
// 관리자 권한이 필요한 페이지인데 관리자가 아닌 경우
if (to.meta.requiresAdmin && userRole !== 'admin' && userRole !== 'superadmin') {
next({ name: 'ClubDashboard' });
return;
}
// 이미 로그인한 상태에서 로그인 페이지로 이동하려는 경우
if (to.name === 'Login' && isAuthenticated) {
if (userRole === 'admin' || userRole === 'superadmin') {
next({ name: 'AdminDashboard' });
} else {
next({ name: 'ClubDashboard' });
}
return;
}
// 일반 사용자가 클럽이 없는 경우, 클럽 생성 페이지로 리다이렉트 (관리자 제외)
if (isAuthenticated && userRole !== 'admin' && userRole !== 'superadmin' && !clubId &&
to.name !== 'UserClubCreate' && to.name !== 'Profile' && to.name !== 'Login') {
next({ name: 'UserClubCreate' });
return;
}
// 클럽 구독 관리 페이지에 접근하려는 경우, 권한 확인
if (to.name === 'ClubSubscription' && (userClubRole !== '모임장' && userClubRole !== '운영진')) {
next({ name: 'ClubDashboard' });
return;
}
next();
});
export default router;
+351
View File
@@ -0,0 +1,351 @@
import apiClient from './api';
/**
* 관리자 API 서비스
*/
const adminService = {
/**
* 모든 클럽 목록 조회
* @returns {Promise} - 클럽 목록
*/
getClubs() {
return apiClient.get('/api/admin/clubs');
},
/**
* 모든 클럽 조회
* @returns {Promise} - 클럽 목록
*/
getAllClubs() {
return apiClient.get('/api/admin/clubs');
},
/**
* 특정 클럽 조회
* @param {Number} id - 클럽 ID
* @returns {Promise} - 클럽 정보
*/
getClubById(id) {
return apiClient.get(`/api/admin/clubs/${id}`);
},
/**
* 클럽 정보 업데이트
* @param {Number} id - 클럽 ID
* @param {Object} clubData - 업데이트할 클럽 정보
* @returns {Promise} - 업데이트된 클럽 정보
*/
updateClub(id, clubData) {
return apiClient.put(`/api/admin/clubs/${id}`, clubData);
},
/**
* 클럽 삭제
* @param {Number} id - 클럽 ID
* @returns {Promise} - 삭제 결과
*/
deleteClub(id) {
return apiClient.delete(`/api/admin/clubs/${id}`);
},
/**
* 클럽 멤버 조회
* @param {Number} id - 클럽 ID
* @returns {Promise} - 클럽 멤버 목록
*/
getClubMembers(id) {
return apiClient.get(`/api/admin/clubs/${id}/members`);
},
/**
* 클럽 멤버 추가
* @param {Number} id - 클럽 ID
* @param {Object} memberData - 멤버 정보
* @returns {Promise} - 추가된 멤버 정보
*/
addClubMember(id, memberData) {
return apiClient.post(`/api/admin/clubs/${id}/members`, memberData);
},
/**
* 클럽 멤버 정보 업데이트
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 멤버 ID
* @param {Object} memberData - 업데이트할 멤버 정보
* @returns {Promise} - 업데이트된 멤버 정보
*/
updateClubMember(clubId, memberId, memberData) {
return apiClient.put(`/api/admin/clubs/${clubId}/members/${memberId}`, memberData);
},
/**
* 클럽 회원 삭제
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 멤버 ID
* @returns {Promise} - 삭제 결과
*/
deleteClubMember(clubId, memberId) {
return apiClient.delete(`/api/admin/clubs/${clubId}/members/${memberId}`);
},
/**
* 모든 사용자 조회
* @returns {Promise} - 사용자 목록
*/
getAllUsers() {
return apiClient.get('/api/admin/users');
},
/**
* 특정 사용자 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 사용자 정보
*/
getUserById(id) {
return apiClient.get(`/api/admin/users/${id}`);
},
/**
* 사용자 정보 업데이트
* @param {Number} id - 사용자 ID
* @param {Object} userData - 업데이트할 사용자 정보
* @returns {Promise} - 업데이트된 사용자 정보
*/
updateUser(id, userData) {
return apiClient.put(`/api/admin/users/${id}`, userData);
},
/**
* 사용자 삭제
* @param {Number} id - 사용자 ID
* @returns {Promise} - 삭제 결과
*/
deleteUser(id) {
return apiClient.delete(`/api/admin/users/${id}`);
},
/**
* 모든 이벤트 조회
* @returns {Promise} - 이벤트 목록
*/
getAllEvents() {
return apiClient.get('/api/admin/events');
},
/**
* 특정 이벤트 조회
* @param {Number} id - 이벤트 ID
* @returns {Promise} - 이벤트 정보
*/
getEventById(id) {
return apiClient.get(`/api/admin/events/${id}`);
},
/**
* 새 이벤트 생성
* @param {Object} eventData - 이벤트 정보
* @returns {Promise} - 생성된 이벤트 정보
*/
createEvent(eventData) {
return apiClient.post('/api/admin/events', eventData);
},
/**
* 이벤트 정보 업데이트
* @param {Number} id - 이벤트 ID
* @param {Object} eventData - 업데이트할 이벤트 정보
* @returns {Promise} - 업데이트된 이벤트 정보
*/
updateEvent(id, eventData) {
return apiClient.put(`/api/admin/events/${id}`, eventData);
},
/**
* 이벤트 삭제
* @param {Number} id - 이벤트 ID
* @returns {Promise} - 삭제 결과
*/
deleteEvent(id) {
return apiClient.delete(`/api/admin/events/${id}`);
},
/**
* 이벤트 참가자 목록 조회
* @param {Number} eventId - 이벤트 ID
* @returns {Promise} - 참가자 목록
*/
getEventParticipants(eventId) {
return apiClient.get(`/api/admin/events/${eventId}/participants`);
},
/**
* 이벤트 참가자 추가
* @param {Number} eventId - 이벤트 ID
* @param {Number} userId - 사용자 ID
* @returns {Promise} - 추가된 참가자 정보
*/
addEventParticipant(eventId, userId) {
return apiClient.post(`/api/admin/events/${eventId}/participants`, { userId });
},
/**
* 이벤트 참가자 상태 업데이트
* @param {Number} eventId - 이벤트 ID
* @param {Number} participantId - 참가자 ID
* @param {String} status - 새로운 상태
* @returns {Promise} - 업데이트된 참가자 정보
*/
updateEventParticipantStatus(eventId, participantId, status) {
return apiClient.put(`/api/admin/events/${eventId}/participants/${participantId}`, { status });
},
/**
* 이벤트 참가자 제거
* @param {Number} eventId - 이벤트 ID
* @param {Number} participantId - 참가자 ID
* @returns {Promise} - 제거 결과
*/
removeEventParticipant(eventId, participantId) {
return apiClient.delete(`/api/admin/events/${eventId}/participants/${participantId}`);
},
/**
* 구독 플랜 목록 조회
* @returns {Promise} - 구독 플랜 목록
*/
getPlans() {
return apiClient.get('/api/admin/subscriptions/plans');
},
/**
* 특정 구독 플랜 조회
* @param {Number} id - 구독 플랜 ID
* @returns {Promise} - 구독 플랜 정보
*/
getPlanById(id) {
return apiClient.get(`/api/admin/subscriptions/plans/${id}`);
},
/**
* 새 구독 플랜 생성
* @param {Object} planData - 구독 플랜 정보
* @returns {Promise} - 생성된 구독 플랜 정보
*/
createPlan(planData) {
return apiClient.post('/api/admin/subscriptions/plans', planData);
},
/**
* 구독 플랜 정보 업데이트
* @param {Number} id - 구독 플랜 ID
* @param {Object} planData - 업데이트할 구독 플랜 정보
* @returns {Promise} - 업데이트된 구독 플랜 정보
*/
updatePlan(id, planData) {
return apiClient.put(`/api/admin/subscriptions/plans/${id}`, planData);
},
/**
* 구독 플랜 삭제
* @param {Number} id - 구독 플랜 ID
* @returns {Promise} - 삭제 결과
*/
deletePlan(id) {
return apiClient.delete(`/api/admin/subscriptions/plans/${id}`);
},
/**
* 모든 구독 목록 조회
* @returns {Promise} - 구독 목록
*/
getSubscriptions() {
return apiClient.get('/api/admin/subscriptions');
},
/**
* 구독 플랜의 구독 목록 조회
* @param {Number} planId - 구독 플랜 ID
* @returns {Promise} - 구독 목록
*/
getPlanSubscriptions(planId) {
return apiClient.get(`/api/admin/subscriptions/plans/${planId}/subscriptions`);
},
/**
* 모든 기능 목록 조회
* @returns {Promise} - 기능 목록
*/
getFeatures() {
return apiClient.get('/api/admin/features');
},
/**
* 새 구독 생성
* @param {Object} subscriptionData - 구독 정보
* @returns {Promise} - 생성된 구독 정보
*/
createSubscription(subscriptionData) {
return apiClient.post('/api/admin/subscriptions', subscriptionData);
},
/**
* 구독 정보 업데이트
* @param {Number} id - 구독 ID
* @param {Object} subscriptionData - 업데이트할 구독 정보
* @returns {Promise} - 업데이트된 구독 정보
*/
updateSubscription(id, subscriptionData) {
return apiClient.put(`/api/admin/subscriptions/${id}`, subscriptionData);
},
/**
* 구독 삭제
* @param {Number} id - 구독 ID
* @returns {Promise} - 삭제 결과
*/
deleteSubscription(id) {
return apiClient.delete(`/api/admin/subscriptions/${id}`);
},
/**
* 대시보드 데이터 조회
* @returns {Promise} - 대시보드 데이터
*/
getDashboardData() {
return apiClient.get('/api/admin/dashboard');
},
/**
* 최근 활동 조회
* @returns {Promise} - 최근 활동 목록
*/
getRecentActivities() {
return apiClient.get('/api/admin/activities/recent');
},
/**
* 통계 데이터 조회
* @returns {Promise} - 통계 데이터
*/
getStatistics() {
return apiClient.get('/api/admin/statistics');
},
/**
* 클럽 기능 관리
* @param {Number} clubId - 클럽 ID
* @param {Object} features - 클럽 기능 정보
* @returns {Promise} - 클럽 기능 정보
*/
manageClubFeatures: async (clubId, features) => {
try {
const response = await apiClient.put(`/api/admin/clubs/${clubId}/features`, { features });
return response.data;
} catch (error) {
console.error('클럽 기능 관리 중 오류:', error);
throw error;
}
}
};
export default adminService;
+46
View File
@@ -0,0 +1,46 @@
import axios from 'axios';
// 환경 변수에서 API URL을 가져오거나 기본값 사용
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3030';
// axios 인스턴스 생성
const apiClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000
});
// 요청 인터셉터 - 토큰 추가
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 응답 인터셉터 - 에러 처리
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// 401 에러 (인증 실패) 처리
if (error.response && error.response.status === 401) {
// 로컬 스토리지에서 토큰 제거
localStorage.removeItem('token');
// 로그인 페이지로 리디렉션
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
+51
View File
@@ -0,0 +1,51 @@
import apiClient from './api';
/**
* 인증 관련 API 서비스
*/
const authService = {
/**
* 로그인
* @param {Object} credentials - 로그인 정보 (이메일, 비밀번호)
* @returns {Promise} - 로그인 결과
*/
login(credentials) {
return apiClient.post('/api/auth/login', credentials);
},
/**
* 회원가입
* @param {Object} userData - 사용자 정보
* @returns {Promise} - 회원가입 결과
*/
register(userData) {
return apiClient.post('/api/auth/register', userData);
},
/**
* 현재 사용자 정보 조회
* @returns {Promise} - 사용자 정보
*/
getCurrentUser() {
return apiClient.get('/api/auth/me');
},
/**
* 비밀번호 변경
* @param {Object} passwordData - 비밀번호 변경 정보
* @returns {Promise} - 비밀번호 변경 결과
*/
changePassword(passwordData) {
return apiClient.post('/api/auth/change-password', passwordData);
},
/**
* 로그아웃
*/
logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
};
export default authService;
+109
View File
@@ -0,0 +1,109 @@
import apiClient from './api';
/**
* 클럽 관련 API 서비스
*/
const clubService = {
/**
* 모든 클럽 조회
* @returns {Promise} - 클럽 목록
*/
getAllClubs() {
return apiClient.get('/api/club');
},
/**
* 특정 클럽 조회
* @param {Number} id - 클럽 ID
* @returns {Promise} - 클럽 정보
*/
getClubById(id) {
return apiClient.post(`/api/club`, { clubId: id });
},
/**
* 새 클럽 생성
* @param {Object} clubData - 클럽 정보
* @returns {Promise} - 생성된 클럽 정보
*/
createClub(clubData) {
return apiClient.post('/api/club/create', clubData);
},
/**
* 클럽 정보 업데이트
* @param {Number} id - 클럽 ID
* @param {Object} clubData - 업데이트할 클럽 정보
* @returns {Promise} - 업데이트된 클럽 정보
*/
updateClub(id, clubData) {
return apiClient.put(`/api/club`, { clubId: id, ...clubData });
},
/**
* 클럽 삭제
* @param {Number} id - 클럽 ID
* @returns {Promise} - 삭제 결과
*/
deleteClub(id) {
return apiClient.delete(`/api/club`, { clubId: id });
},
/**
* 클럽 회원 목록 조회
* @param {Number} clubId - 클럽 ID
* @returns {Promise} - 회원 목록
*/
getClubMembers(clubId) {
return apiClient.post('/api/club/members', { clubId });
},
/**
* 클럽 회원 추가
* @param {Number} clubId - 클럽 ID
* @param {Object} memberData - 회원 정보
* @returns {Promise} - 추가된 회원 정보
*/
addClubMember(clubId, memberData) {
return apiClient.post('/api/club/members/add', {
clubId,
...memberData
});
},
/**
* 클럽 회원 정보 수정
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 회원 ID
* @param {Object} memberData - 수정할 회원 정보
* @returns {Promise} - 수정된 회원 정보
*/
updateClubMember(clubId, memberId, memberData) {
return apiClient.post('/api/club/members/update', {
clubId,
memberId,
...memberData
});
},
/**
* 클럽 회원 삭제
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 회원 ID
* @returns {Promise} - 삭제 결과
*/
deleteClubMember(clubId, memberId) {
return apiClient.post('/api/club/members/delete', { clubId, memberId });
},
/**
* 클럽 회원 통계 조회
* @param {Number} clubId - 클럽 ID
* @returns {Promise} - 회원 통계 정보
*/
getMemberStats(clubId) {
return apiClient.post('/api/club/members/stats', { clubId });
}
};
export default clubService;
+370
View File
@@ -0,0 +1,370 @@
import apiClient from './api';
import dayjs from 'dayjs';
/**
* 이벤트 서비스 - 이벤트 관련 API 호출을 처리하는 서비스
*/
export default {
/**
* 모든 이벤트 목록 가져오기
* @param {number} clubId - 클럽 ID
* @returns {Promise<Array>} 이벤트 목록
*/
async getEvents(clubId) {
if (!clubId) {
throw new Error('클럽 ID가 필요합니다.');
}
try {
const response = await apiClient.post('/api/club/events', { clubId });
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 특정 이벤트 상세 정보 가져오기
* @param {number} id - 이벤트 ID
* @returns {Promise<Object>} 이벤트 상세 정보
*/
async getEventById(id) {
if (!id) {
throw new Error('이벤트 ID가 필요합니다.');
}
try {
const response = await apiClient.get(`/api/club/events/${id}`);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 새 이벤트 추가
* @param {Object} event - 이벤트 객체
* @returns {Promise<Object>} 생성된 이벤트 객체
*/
async createEvent(event) {
if (!event) {
throw new Error('이벤트 객체가 필요합니다.');
}
try {
const formattedEvent = {
...event,
startDate: dayjs(event.startDate).format('YYYY-MM-DD'),
endDate: event.endDate ? dayjs(event.endDate).format('YYYY-MM-DD') : null
};
const response = await apiClient.post('/api/club/events/create', formattedEvent);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 기존 이벤트 수정
* @param {Object} event - 수정할 이벤트 객체
* @returns {Promise<Object>} 수정된 이벤트 객체
*/
async updateEvent(event) {
if (!event) {
throw new Error('이벤트 객체가 필요합니다.');
}
try {
const formattedEvent = {
...event,
startDate: dayjs(event.startDate).format('YYYY-MM-DD'),
endDate: dayjs(event.endDate).format('YYYY-MM-DD')
};
const response = await apiClient.put(`/api/club/events/${event.id}`, formattedEvent);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 이벤트 삭제
* @param {number} clubId - 클럽 ID
* @param {number} id - 삭제할 이벤트 ID
* @returns {Promise<void>}
*/
async deleteEvent(clubId, id) {
if (!clubId || !id) {
throw new Error('클럽 ID와 이벤트 ID가 필요합니다.');
}
try {
await apiClient.delete(`/api/club/events/${id}`, {
data: { clubId }
});
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 이벤트 참가자 목록 조회
* @param {number} eventId - 이벤트 ID
* @returns {Promise<Array>} 참가자 목록
*/
async getEventParticipants(eventId) {
if (!eventId) {
throw new Error('이벤트 ID가 필요합니다.');
}
try {
const response = await apiClient.post(`/api/club/events/${eventId}/participants/list`, {
eventId,
clubId: localStorage.getItem('clubId')
});
return response.data.participants;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 이벤트 참가자 추가
* @param {number} eventId - 이벤트 ID
* @param {Object} participant - 참가자 정보
* @returns {Promise<Object>} 추가된 참가자 정보
*/
async addEventParticipant(eventId, participant) {
if (!eventId || !participant) {
throw new Error('이벤트 ID와 참가자 정보가 필요합니다.');
}
try {
const response = await apiClient.post(`/api/club/events/${eventId}/participants`, participant);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 참가자 정보 수정
* @param {number} eventId - 이벤트 ID
* @param {Object} participant - 수정할 참가자 정보
* @returns {Promise<Object>} 수정된 참가자 정보
*/
async updateEventParticipant(eventId, participant) {
if (!eventId || !participant) {
throw new Error('이벤트 ID와 참가자 정보가 필요합니다.');
}
try {
const response = await apiClient.put(`/api/club/events/${eventId}/participants`, participant);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 참가자 제거
* @param {number} eventId - 이벤트 ID
* @param {Object} participant - 제거할 참가자 정보
* @returns {Promise<void>}
*/
async removeEventParticipant(eventId, participant) {
if (!eventId || !participant) {
throw new Error('이벤트 ID와 참가자 정보가 필요합니다.');
}
try {
await apiClient.delete(`/api/club/events/${eventId}/participants`, {
data: participant
});
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 참가자 상태 수정
* @param {number} eventId - 이벤트 ID
* @param {number} userId - 사용자 ID
* @param {string} status - 변경할 상태
* @returns {Promise<Object>} 수정된 참가자 정보
*/
async updateParticipantStatus(eventId, userId, status) {
if (!eventId || !userId || !status) {
throw new Error('이벤트 ID, 사용자 ID, 상태가 필요합니다.');
}
try {
const response = await apiClient.put(`/api/club/events/${eventId}/participants/${userId}/status`, { status });
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 점수 조회
* @param {number} eventId - 이벤트 ID
* @returns {Promise<Array>} 점수 목록
*/
async getEventScores(eventId) {
if (!eventId) {
throw new Error('이벤트 ID가 필요합니다.');
}
try {
const response = await apiClient.get(`/api/club/events/${eventId}/scores`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || '점수 조회 중 오류가 발생했습니다.');
}
},
/**
* 점수 등록
* @param {number} eventId - 이벤트 ID
* @param {Object} scoreData - 점수 정보
* @returns {Promise<Object>} 등록된 점수 정보
*/
async addScore(eventId, scoreData) {
if (!eventId || !scoreData) {
throw new Error('이벤트 ID와 점수 정보가 필요합니다.');
}
try {
const response = await apiClient.post(`/api/club/events/${eventId}/scores`, {
participantId: scoreData.participantId,
teamNumber: scoreData.teamNumber,
gameNumber: scoreData.gameNumber,
score: scoreData.score,
handicap: scoreData.handicap
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || '점수 등록 중 오류가 발생했습니다.');
}
},
/**
* 점수 수정
* @param {number} eventId - 이벤트 ID
* @param {number} scoreId - 점수 ID
* @param {Object} scoreData - 수정할 점수 정보
* @returns {Promise<Object>} 수정된 점수 정보
*/
async updateScore(eventId, scoreId, scoreData) {
if (!eventId || !scoreId || !scoreData) {
throw new Error('이벤트 ID, 점수 ID, 점수 정보가 필요합니다.');
}
if (scoreData.gameNumber === undefined || scoreData.gameNumber === null ||
scoreData.score === undefined || scoreData.score === null) {
throw new Error('게임 번호와 점수는 필수입니다.');
}
try {
// URL 파라미터 문자열로 변환
const url = `/api/club/events/${String(eventId)}/scores/${String(scoreId)}`;
const payload = {
gameNumber: Number(scoreData.gameNumber),
score: scoreData.score !== null ? Number(scoreData.score) : undefined,
handicap: scoreData.handicap !== null ? Number(scoreData.handicap) : 0
};
console.log('Updating score:', {
url,
eventId,
scoreId,
payload
});
const response = await apiClient.put(url, payload);
return response.data;
} catch (error) {
console.error('Score update error:', {
error: error.response?.data,
status: error.response?.status,
statusText: error.response?.statusText
});
throw new Error(error.response?.data?.message || '점수 수정 중 오류가 발생했습니다.');
}
},
/**
* 점수 삭제
* @param {number} eventId - 이벤트 ID
* @param {number} scoreId - 점수 ID
* @returns {Promise<void>}
*/
async deleteScore(eventId, scoreId) {
if (!eventId || !scoreId) {
throw new Error('이벤트 ID와 점수 ID가 필요합니다.');
}
try {
const response = await apiClient.delete(`/api/club/events/${eventId}/scores/${scoreId}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || '점수 삭제 중 오류가 발생했습니다.');
}
},
/**
* 참가자의 모든 점수 삭제
* @param {number} eventId - 이벤트 ID
* @param {number} participantId - 참가자 ID
* @returns {Promise<Object>} 삭제 결과
*/
async deleteParticipantScores(eventId, participantId) {
if (!eventId || !participantId) {
throw new Error('이벤트 ID와 참가자 ID가 필요합니다.');
}
try {
const response = await apiClient.delete(`/api/club/events/${eventId}/participants/${participantId}/scores`);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
};
+80
View File
@@ -0,0 +1,80 @@
import apiClient from './apiClient';
/**
* 점수 관리 서비스
*/
export default {
/**
* 모임의 점수 데이터 가져오기
* @param {number} meetingId - 모임 ID
* @returns {Promise} - API 응답
*/
async getMeetingScores(meetingId) {
return apiClient.get(`/meetings/${meetingId}/scores`);
},
/**
* 점수 저장하기
* @param {number} meetingId - 모임 ID
* @param {Array} scores - 점수 데이터 배열
* @returns {Promise} - API 응답
*/
async saveScores(meetingId, scores) {
return apiClient.post(`/meetings/${meetingId}/scores`, { scores });
},
/**
* 특정 참가자의 점수 이력 가져오기
* @param {number} memberId - 회원 ID
* @param {number} limit - 가져올 이력 수
* @returns {Promise} - API 응답
*/
async getMemberScoreHistory(memberId, limit = 5) {
return apiClient.get(`/members/${memberId}/scores/history`, { params: { limit } });
},
/**
* 점수 내보내기
* @param {number} meetingId - 모임 ID
* @param {string} format - 내보내기 형식 (xlsx, csv, pdf)
* @param {Array} content - 내보낼 내용 배열
* @returns {Promise} - API 응답 (파일 다운로드)
*/
async exportScores(meetingId, format, content) {
return apiClient.get(`/meetings/${meetingId}/scores/export`, {
params: { format, content: content.join(',') },
responseType: 'blob'
});
},
/**
* 팀별 점수 가져오기
* @param {number} meetingId - 모임 ID
* @returns {Promise} - API 응답
*/
async getTeamScores(meetingId) {
return apiClient.get(`/meetings/${meetingId}/scores/teams`);
},
/**
* 개인 순위 가져오기
* @param {number} meetingId - 모임 ID
* @param {string} type - 순위 유형 (scratch, handicap)
* @param {string} game - 게임 코드 (all, game1, game2, ...)
* @returns {Promise} - API 응답
*/
async getIndividualRankings(meetingId, type = 'handicap', game = 'all') {
return apiClient.get(`/meetings/${meetingId}/scores/rankings/individual`, {
params: { type, game }
});
},
/**
* 팀 순위 가져오기
* @param {number} meetingId - 모임 ID
* @returns {Promise} - API 응답
*/
async getTeamRankings(meetingId) {
return apiClient.get(`/meetings/${meetingId}/scores/rankings/teams`);
}
};
+83
View File
@@ -0,0 +1,83 @@
import { io } from 'socket.io-client';
import { API_URL } from '../config';
// Socket.IO 서버 URL (API_URL에서 '/api' 부분 제거)
const SOCKET_URL = API_URL.replace('/api', '');
// Socket.IO 클라이언트 인스턴스
let socket;
// Socket.IO 연결 설정
export const initSocket = () => {
if (socket) return socket;
socket = io(SOCKET_URL, {
autoConnect: false,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 3000
});
// 연결 이벤트 핸들러
socket.on('connect', () => {
console.log('Socket.IO 서버에 연결되었습니다.');
// 사용자 인증 정보 전송
const token = localStorage.getItem('token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
socket.emit('authenticate', { id: payload.id });
} catch (error) {
console.error('토큰 파싱 오류:', error);
}
}
});
// 연결 오류 이벤트 핸들러
socket.on('connect_error', (error) => {
console.error('Socket.IO 연결 오류:', error);
});
// 연결 해제 이벤트 핸들러
socket.on('disconnect', (reason) => {
console.log('Socket.IO 연결이 해제되었습니다:', reason);
});
return socket;
};
// Socket.IO 연결
export const connectSocket = () => {
if (!socket) initSocket();
if (!socket.connected) socket.connect();
};
// Socket.IO 연결 해제
export const disconnectSocket = () => {
if (socket && socket.connected) socket.disconnect();
};
// 이벤트 리스너 등록
export const onEvent = (event, callback) => {
if (!socket) initSocket();
socket.on(event, callback);
};
// 이벤트 리스너 제거
export const offEvent = (event, callback) => {
if (!socket) return;
if (callback) {
socket.off(event, callback);
} else {
socket.off(event);
}
};
export default {
initSocket,
connectSocket,
disconnectSocket,
onEvent,
offEvent
};
+70
View File
@@ -0,0 +1,70 @@
import apiClient from './api';
/**
* 사용자 관련 API 서비스
*/
const userService = {
/**
* 모든 사용자 조회
* @returns {Promise} - 사용자 목록
*/
getAllUsers() {
return apiClient.get('/users/all');
},
/**
* 특정 사용자 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 사용자 정보
*/
getUserById(id) {
return apiClient.get(`/users/${id}`);
},
/**
* 사용자 정보 업데이트
* @param {Number} id - 사용자 ID
* @param {Object} userData - 업데이트할 사용자 정보
* @returns {Promise} - 업데이트된 사용자 정보
*/
updateUser(id, userData) {
return apiClient.put(`/users/${id}`, userData);
},
/**
* 사용자 프로필 조회
* @returns {Promise} - 사용자 프로필 정보
*/
getUserProfile() {
return apiClient.get('/users/profile');
},
/**
* 사용자 프로필 업데이트
* @param {Object} profileData - 업데이트할 프로필 정보
* @returns {Promise} - 업데이트된 프로필 정보
*/
updateUserProfile(profileData) {
return apiClient.put('/users/profile', profileData);
},
/**
* 사용자 참가 이벤트 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 이벤트 목록
*/
getUserEvents(id) {
return apiClient.get(`/users/${id}/events`);
},
/**
* 사용자 클럽 멤버십 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 클럽 멤버십 목록
*/
getUserClubs(id) {
return apiClient.get(`/users/${id}/clubs`);
}
};
export default userService;
+65
View File
@@ -0,0 +1,65 @@
import { defineStore } from 'pinia';
import axios from 'axios';
export const useAdminStore = defineStore('admin', {
state: () => ({
users: [],
clubs: [],
subscriptions: [],
loading: false,
error: null
}),
actions: {
async fetchUsers() {
try {
this.loading = true;
const response = await axios.get('/api/admin/users', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
});
this.users = response.data;
} catch (error) {
this.error = error.response?.data?.message || '사용자 목록을 불러오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
async fetchClubs() {
try {
this.loading = true;
const response = await axios.get('/api/admin/clubs', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
});
this.clubs = response.data;
} catch (error) {
this.error = error.response?.data?.message || '클럽 목록을 불러오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
async fetchSubscriptions() {
try {
this.loading = true;
const response = await axios.get('/api/admin/subscriptions', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
});
this.subscriptions = response.data;
} catch (error) {
this.error = error.response?.data?.message || '구독 정보를 불러오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
}
}
});
+50
View File
@@ -0,0 +1,50 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import apiClient from '../services/api';
import { jwtDecode } from 'jwt-decode';
export const useAuthStore = defineStore('auth', () => {
const user = ref(null);
const isAuthenticated = ref(false);
// 현재 사용자 정보 로드
const loadUserInfo = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
user.value = null;
isAuthenticated.value = false;
return null;
}
const decoded = jwtDecode(token);
user.value = {
id: decoded.id,
email: decoded.email,
name: decoded.name,
role: decoded.role
};
isAuthenticated.value = true;
return user.value;
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
user.value = null;
isAuthenticated.value = false;
return null;
}
};
// 로그아웃
const logout = () => {
localStorage.removeItem('token');
user.value = null;
isAuthenticated.value = false;
};
return {
user,
isAuthenticated,
loadUserInfo,
logout
};
});
+48
View File
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia';
import apiClient from '../services/api';
export const useClubStore = defineStore('club', {
state: () => ({
clubs: [],
selectedClub: null,
loading: false,
error: null
}),
actions: {
async fetchClubs() {
this.loading = true;
try {
const response = await apiClient.get('/api/club');
this.clubs = response.data;
} catch (error) {
this.error = error.response?.data?.message || '클럽 목록을 가져오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
async fetchClubById(clubId) {
this.loading = true;
try {
const response = await apiClient.post(`/api/club`, { clubId });
this.selectedClub = response.data;
return response.data;
} catch (error) {
this.error = error.response?.data?.message || '클럽 정보를 가져오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
setSelectedClub(club) {
this.selectedClub = club;
},
clearSelectedClub() {
this.selectedClub = null;
}
}
});
+141
View File
@@ -0,0 +1,141 @@
import dayjs from 'dayjs';
/**
* 이벤트 유형 이름 가져오기
* @param {string} eventType - 이벤트 유형 코드
* @returns {string} - 이벤트 유형 이름
*/
export const getEventTypeName = (eventType) => {
switch (eventType) {
case 'meeting':
return '정기 모임';
case 'tournament':
return '토너먼트';
case 'training':
return '교육/강습';
case 'social':
return '친목 모임';
case 'other':
return '기타';
default:
return eventType;
}
};
/**
* 이벤트 유형 심각도 가져오기
* @param {string} eventType - 이벤트 유형 코드
* @returns {string} - 심각도 클래스
*/
export const getEventTypeSeverity = (eventType) => {
switch (eventType) {
case 'meeting':
return 'info';
case 'tournament':
return 'danger';
case 'training':
return 'success';
case 'social':
return 'warning';
case 'other':
return 'secondary';
default:
return 'info';
}
};
/**
* 상태 이름 가져오기
* @param {string} status - 상태 코드
* @returns {string} - 상태 이름
*/
export const getStatusName = (status) => {
switch (status) {
case 'scheduled':
return '예정됨';
case 'in_progress':
return '진행중';
case 'completed':
return '완료됨';
case 'cancelled':
return '취소됨';
default:
return status;
}
};
/**
* 상태 심각도 가져오기
* @param {string} status - 상태 코드
* @returns {string} - 심각도 클래스
*/
export const getStatusSeverity = (status) => {
switch (status) {
case 'scheduled':
return 'info';
case 'in_progress':
return 'warning';
case 'completed':
return 'success';
case 'cancelled':
return 'danger';
default:
return 'info';
}
};
/**
* 날짜 포맷팅
* @param {string|Date} date - 날짜
* @returns {string} - 포맷팅된 날짜 문자열
*/
export const formatDate = (date) => {
if (!date) return '';
return dayjs(date).format('YYYY-MM-DD HH:mm');
};
/**
* 이벤트 유형 옵션
*/
export const eventTypeOptions = [
{ name: '정기 모임', value: 'meeting' },
{ name: '토너먼트', value: 'tournament' },
{ name: '교육/강습', value: 'training' },
{ name: '친목 모임', value: 'social' },
{ name: '기타', value: 'other' }
];
/**
* 상태 옵션
*/
export const statusOptions = [
{ name: '예정됨', value: 'scheduled' },
{ name: '진행중', value: 'in_progress' },
{ name: '완료됨', value: 'completed' },
{ name: '취소됨', value: 'cancelled' }
];
/**
* 새 이벤트 객체 생성
* @param {number} clubId - 클럽 ID
* @returns {Object} - 새 이벤트 객체
*/
export const createNewEvent = (clubId) => {
const now = new Date();
return {
clubId,
title: '',
description: '',
eventType: 'meeting',
startDate: now,
endDate: now,
location: '',
status: 'scheduled',
maxParticipants: 0,
teamCount: 1,
gameCount: 3,
participantFee: 0,
laneCount: 0,
registrationDeadline: now
};
};
+718
View File
@@ -0,0 +1,718 @@
<template>
<div class="club-management">
<div class="header">
<h2>클럽 관리</h2>
<Button label="새 클럽 추가" icon="pi pi-plus" @click="openNewClubDialog" />
</div>
<DataTable :value="clubs" :paginator="true" :rows="10"
:rowsPerPageOptions="[5, 10, 20]"
stripedRows responsiveLayout="scroll"
:loading="loading">
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="클럽명" sortable style="width: 20%">
<template #body="slotProps">
<div>
<div class="club-name">{{ slotProps.data.name }}</div>
<div class="club-location">{{ slotProps.data.location }}</div>
</div>
</template>
</Column>
<Column field="owner.name" header="모임장" sortable style="width: 15%">
<template #body="slotProps">
{{ slotProps.data.owner?.name || '-' }}
</template>
</Column>
<Column field="memberCount" header="회원 수" sortable style="width: 10%"></Column>
<Column field="createdAt" header="생성일" sortable style="width: 15%">
<template #body="slotProps">
{{ formatDate(slotProps.data.createdAt) }}
</template>
</Column>
<Column header="관리" style="width: 20%">
<template #body="slotProps">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-success mr-2"
@click="editClub(slotProps.data)" />
<Button icon="pi pi-cog" class="p-button-rounded p-button-info mr-2"
@click="manageFeatures(slotProps.data)" />
<Button icon="pi pi-users" class="p-button-rounded p-button-warning mr-2"
@click="manageMembers(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-danger"
@click="confirmDeleteClub(slotProps.data)" />
</template>
</Column>
</DataTable>
<!-- 클럽 추가/수정 다이얼로그 -->
<Dialog v-model:visible="clubDialog" :style="{width: '450px'}"
:header="dialogMode === 'new' ? '새 클럽 추가' : '클럽 수정'"
:modal="true" class="p-fluid">
<div class="field">
<label for="name">클럽명</label>
<InputText id="name" v-model="club.name" required autofocus :class="{'p-invalid': submitted && !club.name}" />
<small class="p-error" v-if="submitted && !club.name">클럽명은 필수입니다.</small>
</div>
<div class="field">
<label for="description">클럽 설명</label>
<Textarea id="description" v-model="club.description" rows="3" />
</div>
<div class="field">
<label for="ownerEmail">모임장 이메일</label>
<InputText id="ownerEmail" v-model="club.ownerEmail" required :class="{'p-invalid': submitted && !club.ownerEmail}" />
<small class="p-error" v-if="submitted && !club.ownerEmail">모임장 이메일은 필수입니다.</small>
</div>
<div class="field">
<label for="location">볼링장</label>
<InputText id="location" v-model="club.location" />
</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="saveClub" :loading="saving" />
</template>
</Dialog>
<!-- 기능 관리 다이얼로그 -->
<Dialog v-model:visible="featureDialog" :style="{width: '500px'}"
header="클럽 기능 관리" :modal="true">
<div v-if="selectedClub">
<h3>{{ selectedClub.name }} - 기능 관리</h3>
<div v-for="feature in clubFeatures" :key="feature.id" class="feature-item">
<div class="feature-header">
<h4>{{ feature.name }}</h4>
<div class="feature-toggle">
<span>{{ feature.enabled ? '활성화됨' : '비활성화됨' }}</span>
<Button :icon="feature.enabled ? 'pi pi-check' : 'pi pi-times'"
:class="feature.enabled ? 'p-button-success' : 'p-button-secondary'"
@click="toggleFeature(feature)" />
</div>
</div>
<p>{{ feature.description }}</p>
<p class="feature-price"> {{ feature.price.toLocaleString() }}</p>
</div>
</div>
<template #footer>
<Button label="닫기" icon="pi pi-times" class="p-button-text" @click="featureDialog = false" />
<Button label="저장" icon="pi pi-check" @click="saveFeatures" :loading="savingFeatures" />
</template>
</Dialog>
<!-- 회원 관리 다이얼로그 -->
<Dialog v-model:visible="memberDialog" :style="{width: '800px'}"
header="회원 관리" :modal="true">
<div v-if="selectedClub">
<h3>{{ selectedClub.name }} - 회원 관리</h3>
<div class="mb-3">
<Button label="새 회원 추가" icon="pi pi-plus" @click="openNewMemberDialog" />
</div>
<DataTable :value="clubMembers" :paginator="true" :rows="10"
:rowsPerPageOptions="[5, 10, 20]"
stripedRows
scrollable scrollHeight="400px"
:loading="loadingMembers">
<Column field="name" header="이름" sortable style="min-width: 12rem">
<template #body="slotProps">
<div>
<div class="member-name">{{ slotProps.data.name }}</div>
<div class="member-contact">
<div>{{ slotProps.data.phone }}</div>
<div>{{ slotProps.data.email }}</div>
</div>
</div>
</template>
</Column>
<Column field="gender" header="성별" sortable style="min-width: 6rem"></Column>
<Column field="memberType" header="회원 유형" sortable style="min-width: 8rem">
<template #body="slotProps">
<Dropdown v-model="slotProps.data.memberType"
:options="memberTypes"
@change="updateMemberType(slotProps.data)"
:disabled="slotProps.data.memberType === '모임장' && !canChangeOwner" />
</template>
</Column>
<Column field="handicap" header="핸디캡" sortable style="min-width: 6rem"></Column>
<Column field="average" header="평균" sortable style="min-width: 6rem">
<template #body="slotProps">
{{ slotProps.data.average.toFixed(1) }}
</template>
</Column>
<Column field="games" header="게임 수" sortable style="min-width: 6rem"></Column>
<Column field="joinDate" header="가입일" sortable style="min-width: 8rem">
<template #body="slotProps">
{{ formatDate(slotProps.data.joinDate) }}
</template>
</Column>
<Column header="관리" style="min-width: 8rem">
<template #body="slotProps">
<Button icon="pi pi-pencil"
class="p-button-rounded p-button-success mr-2"
@click="editMember(slotProps.data)" />
<Button icon="pi pi-trash"
class="p-button-rounded p-button-danger"
@click="confirmDeleteMember(slotProps.data)"
:disabled="slotProps.data.memberType === '모임장'" />
</template>
</Column>
</DataTable>
</div>
</Dialog>
<!-- 회원 추가/수정 다이얼로그 -->
<Dialog v-model:visible="memberFormDialog" :style="{width: '450px'}"
:header="memberDialogMode === 'new' ? '새 회원 추가' : '회원 정보 수정'"
:modal="true" class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="member.name" required autofocus :class="{'p-invalid': memberSubmitted && !member.name}" />
<small class="p-error" v-if="memberSubmitted && !member.name">이름은 필수입니다.</small>
</div>
<div class="field">
<label for="gender">성별</label>
<Dropdown id="gender" v-model="member.gender"
:options="['남성', '여성']" placeholder="성별 선택"
:class="{'p-invalid': memberSubmitted && !member.gender}" />
<small class="p-error" v-if="memberSubmitted && !member.gender">성별은 필수입니다.</small>
</div>
<div class="field">
<label for="memberType">회원 유형</label>
<Dropdown id="memberType" v-model="member.memberType"
:options="memberTypes" placeholder="회원 유형 선택"
:class="{'p-invalid': memberSubmitted && !member.memberType}" />
<small class="p-error" v-if="memberSubmitted && !member.memberType">회원 유형은 필수입니다.</small>
</div>
<div class="field">
<label for="handicap">핸디캡</label>
<InputNumber id="handicap" v-model="member.handicap" :min="0" :max="300" />
</div>
<div class="field">
<label for="phone">연락처</label>
<InputText id="phone" v-model="member.phone" />
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="member.email" type="email" />
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="memberFormDialog = false" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveMember" :loading="savingMember" />
</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 v-if="selectedClub">정말로 <b>{{ selectedClub.name }}</b> 클럽을 삭제하시겠습니까?</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="deleteClub" :loading="deleting" />
</template>
</Dialog>
<!-- 회원 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteMemberDialog" :style="{width: '450px'}"
header="회원 삭제" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="selectedMember">정말로 <b>{{ selectedMember.name }}</b> 회원을 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteMemberDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteMember" :loading="deletingMember" />
</template>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import dayjs from 'dayjs';
import adminService from '@/services/adminService';
const toast = useToast();
// 상태 변수
const loading = ref(false);
const saving = ref(false);
const savingFeatures = ref(false);
const deleting = ref(false);
const loadingMembers = ref(false);
const deletingMember = ref(false);
const savingMember = ref(false);
const submitted = ref(false);
const memberSubmitted = ref(false);
// 클럽 목록
const clubs = ref([]);
const clubMembers = ref([]);
// 회원 유형 목록
const memberTypes = ref(['모임장', '정회원', '준회원', '운영진']);
// 기능 목록
const allFeatures = ref([
{ id: 1, name: '회원 관리', description: '클럽 회원 목록 관리 및 회원 정보 수정', price: 5000 },
{ id: 2, name: '정기 모임 관리', description: '정기 모임 일정 및 참석자 관리', price: 8000 },
{ id: 3, name: '점수 관리', description: '게임별 점수 기록 및 통계', price: 10000 },
{ id: 4, name: '팀 관리', description: '팀 구성 및 팀별 점수 관리', price: 7000 },
{ id: 5, name: '통계 대시보드', description: '상세한 통계 및 분석 대시보드', price: 12000 }
]);
// 현재 클럽의 기능 목록
const clubFeatures = ref([]);
// 다이얼로그 상태
const clubDialog = ref(false);
const featureDialog = ref(false);
const deleteDialog = ref(false);
const memberDialog = ref(false);
const memberFormDialog = ref(false);
const deleteMemberDialog = ref(false);
const dialogMode = ref('new');
const memberDialogMode = ref('new');
// 선택된 클럽 및 편집중인 클럽 데이터
const selectedClub = ref(null);
const selectedMember = ref(null);
const club = reactive({
id: null,
name: '',
description: '',
ownerEmail: '',
location: '',
memberCount: 0,
createdAt: ''
});
// 편집중인 회원 데이터
const member = reactive({
id: null,
name: '',
gender: null,
memberType: null,
handicap: 0,
phone: '',
email: ''
});
// 모임장 변경 권한
const canChangeOwner = ref(true);
// 페이지 로드 시 클럽 목록 가져오기
onMounted(async () => {
await fetchClubs();
});
// 클럽 목록 가져오기
const fetchClubs = async () => {
loading.value = true;
try {
const response = await adminService.getAllClubs();
clubs.value = response.data;
} catch (error) {
console.error('클럽 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 날짜 포맷팅
const formatDate = (date) => {
if (!date) return '';
return dayjs(date).format('YYYY-MM-DD');
};
// 새 클럽 추가 다이얼로그 열기
const openNewClubDialog = () => {
club.id = null;
club.name = '';
club.description = '';
club.ownerEmail = '';
club.location = '';
submitted.value = false;
dialogMode.value = 'new';
clubDialog.value = true;
};
// 클럽 수정 다이얼로그 열기
const editClub = async (data) => {
dialogMode.value = 'edit';
selectedClub.value = { ...data };
try {
// 클럽 상세 정보 가져오기
const response = await adminService.getClubById(data.id);
const clubData = response.data;
club.id = clubData.id;
club.name = clubData.name;
club.description = clubData.description;
club.ownerEmail = clubData.owner?.email; // owner 객체에서 이메일 가져오기
club.location = clubData.location;
club.memberCount = clubData.memberCount;
club.createdAt = clubData.createdAt;
clubDialog.value = true;
} catch (error) {
console.error('클럽 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 다이얼로그 닫기
const hideDialog = () => {
clubDialog.value = false;
submitted.value = false;
};
// 클럽 저장
const saveClub = async () => {
submitted.value = true;
if (!club.name || !club.ownerEmail) {
toast.add({ severity: 'error', summary: '오류', detail: '필수 항목을 모두 입력해주세요.', life: 3000 });
return;
}
saving.value = true;
try {
if (dialogMode.value === 'new') {
// 새 클럽 추가
const clubData = {
name: club.name,
description: club.description,
ownerEmail: club.ownerEmail,
location: club.location
};
await adminService.createClub(clubData);
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 추가되었습니다.', life: 3000 });
} else {
// 클럽 수정
await adminService.updateClub(club.id, club);
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 수정되었습니다.', life: 3000 });
}
clubDialog.value = false;
await fetchClubs();
} catch (error) {
console.error('클럽 저장 중 오류 발생:', error);
let errorMessage = '클럽 저장 중 오류가 발생했습니다.';
if (error.response && error.response.data && error.response.data.message) {
errorMessage = error.response.data.message;
}
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
saving.value = false;
}
};
// 기능 관리 다이얼로그 열기
const manageFeatures = async (data) => {
selectedClub.value = { ...data };
try {
// 클럽의 현재 기능 목록 가져오기
const response = await adminService.getClubById(data.id);
const clubData = response.data;
// 모든 기능을 복사하고 활성화 상태 설정
clubFeatures.value = allFeatures.value.map(feature => {
const isEnabled = clubData.features && clubData.features.some(f => f.id === feature.id);
return { ...feature, enabled: isEnabled };
});
featureDialog.value = true;
} catch (error) {
console.error('클럽 기능 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 기능 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 기능 토글
const toggleFeature = (feature) => {
feature.enabled = !feature.enabled;
};
// 기능 설정 저장
const saveFeatures = async () => {
if (!selectedClub.value) return;
savingFeatures.value = true;
try {
// 활성화된 기능만 필터링
const enabledFeatures = clubFeatures.value
.filter(feature => feature.enabled)
.map(feature => ({ id: feature.id, name: feature.name }));
// 기능 업데이트 API 호출
await adminService.manageClubFeatures(selectedClub.value.id, enabledFeatures);
toast.add({ severity: 'success', summary: '성공', detail: '클럽 기능이 업데이트되었습니다.', life: 3000 });
featureDialog.value = false;
// 클럽 목록 새로고침
await fetchClubs();
} catch (error) {
console.error('클럽 기능 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 기능 저장 중 오류가 발생했습니다.', life: 3000 });
} finally {
savingFeatures.value = false;
}
};
// 회원 관리 다이얼로그 열기
const manageMembers = async (data) => {
selectedClub.value = data;
loadingMembers.value = true;
memberDialog.value = true;
try {
const response = await adminService.getClubMembers(data.id);
clubMembers.value = response.data;
} catch (error) {
console.error('회원 목록 조회 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loadingMembers.value = false;
}
};
// 회원 유형 업데이트
const updateMemberType = async (member) => {
try {
if (!selectedClub.value || !selectedClub.value.id) {
toast.add({ severity: 'error', summary: '오류', detail: '선택된 클럽 정보가 없습니다.', life: 3000 });
return;
}
const clubId = parseInt(selectedClub.value.id);
const memberId = parseInt(member.id);
const response = await adminService.updateClubMember(clubId, memberId, {
memberType: member.memberType
});
// 응답 데이터로 회원 정보 업데이트
const index = clubMembers.value.findIndex(m => m.id === memberId);
if (index !== -1) {
clubMembers.value[index] = response.data;
}
toast.add({ severity: 'success', summary: '성공', detail: '회원 유형이 업데이트되었습니다.', life: 3000 });
} catch (error) {
console.error('회원 유형 업데이트 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 유형을 업데이트하는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 회원 삭제 확인
const confirmDeleteMember = (member) => {
selectedMember.value = member;
deleteMemberDialog.value = true;
};
// 회원 삭제
const deleteMember = async () => {
if (!selectedMember.value || !selectedClub.value) return;
deletingMember.value = true;
try {
await adminService.deleteClubMember(selectedClub.value.id, selectedMember.value.id);
// 회원 목록에서 삭제된 회원 제거
clubMembers.value = clubMembers.value.filter(m => m.id !== selectedMember.value.id);
toast.add({ severity: 'success', summary: '성공', detail: '회원이 삭제되었습니다.', life: 3000 });
deleteMemberDialog.value = false;
} catch (error) {
console.error('회원 삭제 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
} finally {
deletingMember.value = false;
}
};
// 클럽 삭제 확인 다이얼로그 열기
const confirmDeleteClub = (data) => {
selectedClub.value = data;
deleteDialog.value = true;
};
// 클럽 삭제
const deleteClub = async () => {
if (!selectedClub.value) return;
deleting.value = true;
try {
// 클럽 삭제 API 호출
await adminService.deleteClub(selectedClub.value.id);
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 삭제되었습니다.', life: 3000 });
deleteDialog.value = false;
// 클럽 목록 새로고침
await fetchClubs();
} catch (error) {
console.error('클럽 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 삭제 중 오류가 발생했습니다.', life: 3000 });
} finally {
deleting.value = false;
}
};
// 새 회원 추가 다이얼로그 열기
const openNewMemberDialog = () => {
memberDialogMode.value = 'new';
memberSubmitted.value = false;
Object.assign(member, {
id: null,
name: '',
gender: null,
memberType: '준회원',
handicap: 0,
phone: '',
email: ''
});
memberFormDialog.value = true;
};
// 회원 수정 다이얼로그 열기
const editMember = (data) => {
memberDialogMode.value = 'edit';
memberSubmitted.value = false;
Object.assign(member, {
id: data.id,
name: data.name,
gender: data.gender,
memberType: data.memberType,
handicap: data.handicap,
phone: data.phone,
email: data.email
});
memberFormDialog.value = true;
};
// 회원 저장
const saveMember = async () => {
memberSubmitted.value = true;
if (!member.name || !member.gender || !member.memberType) {
return;
}
savingMember.value = true;
try {
let response;
if (memberDialogMode.value === 'new') {
response = await adminService.addClubMember(selectedClub.value.id, member);
clubMembers.value.push(response.data);
toast.add({ severity: 'success', summary: '성공', detail: '새 회원이 추가되었습니다.', life: 3000 });
} else {
response = await adminService.updateClubMember(selectedClub.value.id, member.id, member);
const index = clubMembers.value.findIndex(m => m.id === member.id);
if (index !== -1) {
clubMembers.value[index] = response.data;
}
toast.add({ severity: 'success', summary: '성공', detail: '회원 정보가 수정되었습니다.', life: 3000 });
}
memberFormDialog.value = false;
} catch (error) {
console.error('회원 저장 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 정보를 저장하는 중 오류가 발생했습니다.', life: 3000 });
} finally {
savingMember.value = false;
}
};
</script>
<style scoped>
.club-management {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.feature-item {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.feature-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.feature-header h4 {
margin: 0;
}
.feature-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
}
.feature-price {
font-weight: bold;
color: #6366f1;
margin-top: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0;
}
.mr-2 {
margin-right: 0.5rem;
}
.club-name {
font-weight: 500;
margin-bottom: 0.2rem;
}
.club-location {
font-size: 0.85rem;
color: #666;
}
.member-name {
font-weight: 500;
margin-bottom: 0.2rem;
}
.member-contact {
font-size: 0.75rem;
color: #666;
line-height: 1.2;
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="admin-dashboard">
<h2>관리자 대시보드</h2>
<div v-if="loading" class="loading-spinner">
<ProgressSpinner />
</div>
<div v-else class="grid">
<!-- 요약 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-users"></i>
<h3>사용자</h3>
</div>
<div class="card-content">
<p> 사용자 : {{ summary.totalUsers }}</p>
</div>
<div class="card-footer">
<Button label="사용자 관리" icon="pi pi-arrow-right" @click="navigateTo('/admin/users')" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-building"></i>
<h3>클럽</h3>
</div>
<div class="card-content">
<p> 클럽 : {{ summary.totalClubs }}</p>
</div>
<div class="card-footer">
<Button label="클럽 관리" icon="pi pi-arrow-right" @click="navigateTo('/admin/clubs')" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-credit-card"></i>
<h3>구독</h3>
</div>
<div class="card-content">
<p>활성 구독: {{ summary.activeSubscriptions }}</p>
</div>
<div class="card-footer">
<Button label="구독 관리" icon="pi pi-arrow-right" @click="navigateTo('/admin/subscriptions')" />
</div>
</div>
</div>
<!-- 최근 데이터 -->
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-users"></i>
<h3>최근 가입한 사용자</h3>
</div>
<div class="card-content">
<DataTable :value="recent.users" :rows="5" stripedRows>
<Column field="name" header="이름"></Column>
<Column field="email" header="이메일"></Column>
<Column field="role" header="권한"></Column>
<Column field="createdAt" header="가입일">
<template #body="{ data }">
{{ formatDate(data.createdAt) }}
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-building"></i>
<h3>최근 생성된 클럽</h3>
</div>
<div class="card-content">
<DataTable :value="recent.clubs" :rows="5" stripedRows>
<Column field="name" header="클럽명"></Column>
<Column field="memberCount" header="회원 수"></Column>
<Column field="owner.name" header="모임장"></Column>
<Column field="createdAt" header="생성일">
<template #body="{ data }">
{{ formatDate(data.createdAt) }}
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- 월별 통계 차트 -->
<div class="col-12">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-chart-line"></i>
<h3>월별 통계</h3>
</div>
<div class="card-content">
<Chart type="line" :data="chartData" :options="chartOptions" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import dayjs from 'dayjs';
import adminService from '@/services/adminService';
// 컴포넌트들은 main.js에서 전역으로 등록되어 있으므로 import 불필요
const router = useRouter();
const toast = useToast();
// 데이터 상태
const loading = ref(true);
const summary = ref({
totalUsers: 0,
totalClubs: 0,
activeSubscriptions: 0
});
const recent = ref({
users: [],
clubs: [],
subscriptions: []
});
const stats = ref({
users: [],
clubs: [],
subscriptions: []
});
// 차트 데이터 계산
const chartData = computed(() => {
const months = stats.value.users.map(item => item.month);
return {
labels: months,
datasets: [
{
label: '사용자',
data: stats.value.users.map(item => item.count),
borderColor: '#42A5F5',
tension: 0.4
},
{
label: '클럽',
data: stats.value.clubs.map(item => item.count),
borderColor: '#66BB6A',
tension: 0.4
},
{
label: '구독',
data: stats.value.subscriptions.map(item => item.count),
borderColor: '#FFA726',
tension: 0.4
}
]
};
});
// 차트 옵션
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
try {
loading.value = true;
await loadDashboardData();
} catch (error) {
console.error('관리자 대시보드 데이터 로딩 오류:', error);
} finally {
loading.value = false;
}
});
// 대시보드 데이터 로드 함수
const loadDashboardData = async () => {
try {
const response = await adminService.getDashboardData();
const data = response.data;
summary.value = data.summary;
recent.value = data.recent;
stats.value = data.stats;
} catch (error) {
console.error('관리자 대시보드 데이터 로딩 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '관리자 대시보드 데이터를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 날짜 포맷 함수
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD');
};
const navigateTo = (path) => {
router.push(path);
};
</script>
<style scoped>
.admin-dashboard {
padding: 1rem;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.dashboard-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.card-header i {
font-size: 1.5rem;
margin-right: 0.5rem;
color: #3B82F6;
}
.card-header h3 {
margin: 0;
font-size: 1.25rem;
}
.card-content {
flex: 1;
margin-bottom: 1rem;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
:deep(.p-datatable) {
box-shadow: none;
}
:deep(.p-datatable .p-datatable-thead > tr > th) {
background-color: #f8f9fa;
}
:deep(.p-datatable .p-datatable-tbody > tr) {
background-color: transparent;
}
</style>
@@ -0,0 +1,655 @@
<template>
<div class="subscription-management-container">
<div class="header">
<h2>구독 플랜 관리</h2>
<Button label="플랜 추가" icon="pi pi-plus" @click="openNewPlanDialog" />
</div>
<DataTable
:value="plans"
:loading="loading"
:paginator="true"
:rows="10"
stripedRows
responsiveLayout="scroll"
v-model:expandedRows="expandedRows"
dataKey="id"
@row-expand="onPlanExpand"
>
<Column :expander="true" style="width: 3%"/>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="플랜명" sortable style="width: 15%"></Column>
<Column field="description" header="설명" style="width: 25%"></Column>
<Column field="maxMembers" header="최대 회원수" sortable style="width: 10%">
<template #body="slotProps">
{{ slotProps.data.maxMembers }}
</template>
</Column>
<Column field="price" header="가격" sortable style="width: 10%">
<template #body="slotProps">
{{ formatCurrency(slotProps.data.price) }}
</template>
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="작업" style="width: 10%">
<template #body="slotProps">
<div class="action-buttons">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editPlan(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDeletePlan(slotProps.data)" />
</div>
</template>
</Column>
<template #expansion="slotProps">
<div class="plan-details">
<div class="plan-features">
<h4>포함된 기능</h4>
<div class="features-grid">
<div v-for="feature in slotProps.data.Features" :key="feature.id" class="feature-item">
<i class="pi pi-check-circle" style="color: var(--green-500)"></i>
<div class="feature-details">
<h5>{{ feature.name }}</h5>
<p>{{ feature.description }}</p>
</div>
</div>
</div>
</div>
<div class="plan-subscriptions">
<div class="subscriptions-header">
<h4>구독 중인 클럽</h4>
<Button label="클럽 구독 추가" icon="pi pi-plus" @click="openNewSubscriptionDialog(slotProps.data)" />
</div>
<DataTable :value="planSubscriptions[slotProps.data.id] || []" :loading="subscriptionsLoading[slotProps.data.id]">
<Column field="Club.name" header="클럽명"></Column>
<Column field="startDate" header="시작일">
<template #body="subProps">
{{ formatDate(subProps.data.startDate) }}
</template>
</Column>
<Column field="expiryDate" header="만료일">
<template #body="subProps">
{{ formatDate(subProps.data.expiryDate) }}
</template>
</Column>
<Column field="status" header="상태">
<template #body="subProps">
<Badge :value="getStatusName(subProps.data.status)" :severity="getStatusSeverity(subProps.data.status)" />
</template>
</Column>
<Column header="작업">
<template #body="subProps">
<div class="action-buttons">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editSubscription(subProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDeleteSubscription(subProps.data)" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</template>
</DataTable>
<!-- 구독 플랜 추가/수정 다이얼로그 -->
<Dialog v-model:visible="planDialog" :header="dialogTitle" :style="{ width: '600px' }" :modal="true" class="p-fluid">
<div class="field">
<label for="name">플랜명</label>
<InputText id="name" v-model="plan.name" :class="{'p-invalid': submitted && !plan.name}" />
<small class="p-error" v-if="submitted && !plan.name">플랜명은 필수입니다.</small>
</div>
<div class="field">
<label for="description">설명</label>
<Textarea id="description" v-model="plan.description" rows="3" />
</div>
<div class="field">
<label for="maxMembers">최대 회원수</label>
<InputNumber id="maxMembers" v-model="plan.maxMembers" :min="1" />
</div>
<div class="field">
<label for="price">가격</label>
<InputNumber id="price" v-model="plan.price" mode="currency" currency="KRW" locale="ko-KR" />
</div>
<div class="field">
<label for="features">기능</label>
<MultiSelect id="features" v-model="plan.features" :options="availableFeatures"
optionLabel="name" optionValue="id" display="chip" :class="{'p-invalid': submitted && !plan.features?.length}" />
<small class="p-error" v-if="submitted && !plan.features?.length">하나 이상의 기능을 선택해주세요.</small>
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="plan.status" :options="statusOptions" optionLabel="name" optionValue="value" />
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hidePlanDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="savePlan" />
</template>
</Dialog>
<!-- 클럽 구독 추가/수정 다이얼로그 -->
<Dialog v-model:visible="subscriptionDialog" :header="subscriptionDialogTitle" :style="{ width: '500px' }" :modal="true" class="p-fluid">
<div class="field">
<label for="club">클럽</label>
<Dropdown id="club" v-model="subscription.clubId" :options="availableClubs" optionLabel="name" optionValue="id"
:class="{'p-invalid': subscriptionSubmitted && !subscription.clubId}" placeholder="클럽 선택" />
<small class="p-error" v-if="subscriptionSubmitted && !subscription.clubId">클럽을 선택해주세요.</small>
</div>
<div class="field">
<label for="startDate">시작일</label>
<Calendar id="startDate" v-model="subscription.startDate" :showIcon="true" dateFormat="yy-mm-dd"
:class="{'p-invalid': subscriptionSubmitted && !subscription.startDate}" />
<small class="p-error" v-if="subscriptionSubmitted && !subscription.startDate">시작일을 선택해주세요.</small>
</div>
<div class="field">
<label for="expiryDate">만료일</label>
<Calendar id="expiryDate" v-model="subscription.expiryDate" :showIcon="true" dateFormat="yy-mm-dd"
:class="{'p-invalid': subscriptionSubmitted && !subscription.expiryDate}" :minDate="subscription.startDate" />
<small class="p-error" v-if="subscriptionSubmitted && !subscription.expiryDate">만료일을 선택해주세요.</small>
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="subscription.status" :options="subscriptionStatusOptions" optionLabel="name" optionValue="value" />
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideSubscriptionDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveSubscription" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deletePlanDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="plan"> 구독 플랜을 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deletePlanDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deletePlan" />
</template>
</Dialog>
<!-- 구독 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteSubscriptionDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="subscription"> 구독을 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteSubscriptionDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteSubscription" />
</template>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import axios from 'axios';
import adminService from '@/services/adminService';
// PrimeVue 컴포넌트
import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber';
import Textarea from 'primevue/textarea';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dropdown from 'primevue/dropdown';
import Badge from 'primevue/badge';
import Toast from 'primevue/toast';
import Calendar from 'primevue/calendar';
const toast = useToast();
const API_URL = 'http://localhost:3030/api';
// 데이터 정의
const plans = ref([]);
const clubs = ref([]);
const features = ref([]);
const subscriptions = ref([]);
const planSubscriptions = ref({});
const loading = ref(false);
const subscriptionsLoading = ref({});
// 상태 변수
const plan = ref({});
const planDialog = ref(false);
const deletePlanDialog = ref(false);
const submitted = ref(false);
const isNewPlan = ref(false);
const expandedRows = ref([]);
const availableFeatures = ref([]);
const subscription = ref({});
const subscriptionDialog = ref(false);
const deleteSubscriptionDialog = ref(false);
const subscriptionSubmitted = ref(false);
const availableClubs = ref([]);
const selectedPlan = ref(null);
// 상태 옵션
const statusOptions = [
{ name: '활성', value: 'active' },
{ name: '비활성', value: 'inactive' }
];
const subscriptionStatusOptions = [
{ name: '활성', value: 'active' },
{ name: '만료', value: 'expired' },
{ name: '일시중지', value: 'paused' }
];
// 다이얼로그 제목
const dialogTitle = computed(() => {
return isNewPlan.value ? '구독 플랜 추가' : '구독 플랜 수정';
});
const subscriptionDialogTitle = computed(() => {
return !subscription.value.id ? '클럽 구독 추가' : '클럽 구독 수정';
});
// 구독 목록 가져오기
const fetchSubscriptions = async () => {
loading.value = true;
try {
const response = await adminService.getSubscriptions();
subscriptions.value = response.data;
} catch (error) {
console.error('구독 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 플랜별 구독 목록 가져오기
const fetchPlanSubscriptions = async (planId) => {
subscriptionsLoading.value[planId] = true;
try {
const response = await adminService.getPlanSubscriptions(planId);
planSubscriptions.value[planId] = response.data;
} catch (error) {
console.error('구독 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
subscriptionsLoading.value[planId] = false;
}
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
await fetchPlans();
await fetchFeatures();
await fetchClubs();
await fetchSubscriptions();
});
// 플랜 확장 시 구독 목록 로드
const onPlanExpand = (event) => {
const planId = event.data.id;
fetchPlanSubscriptions(planId);
};
// 구독 플랜 목록 가져오기
const fetchPlans = async () => {
loading.value = true;
try {
const response = await adminService.getPlans();
plans.value = response.data;
} catch (error) {
console.error('구독 플랜 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 플랜 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 기능 목록 가져오기
const fetchFeatures = async () => {
try {
const response = await adminService.getFeatures();
availableFeatures.value = response.data;
} catch (error) {
console.error('기능 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '기능 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 클럽 목록 가져오기
const fetchClubs = async () => {
try {
const response = await adminService.getClubs();
availableClubs.value = response.data;
} catch (error) {
console.error('클럽 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 새 구독 플랜 다이얼로그 열기
const openNewPlanDialog = () => {
plan.value = {
name: '',
description: '',
maxMembers: 30,
price: 30000,
features: [],
status: 'active'
};
submitted.value = false;
planDialog.value = true;
isNewPlan.value = true;
};
// 구독 플랜 수정 다이얼로그 열기
const editPlan = (planData) => {
plan.value = { ...planData };
plan.value.features = planData.Features.map(f => f.id);
submitted.value = false;
planDialog.value = true;
isNewPlan.value = false;
};
// 다이얼로그 닫기
const hidePlanDialog = () => {
planDialog.value = false;
submitted.value = false;
};
// 구독 플랜 저장
const savePlan = async () => {
submitted.value = true;
if (!plan.value.name || !plan.value.features?.length) {
return;
}
try {
if (isNewPlan.value) {
await adminService.createPlan(plan.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독 플랜이 생성되었습니다.', life: 3000 });
} else {
await adminService.updatePlan(plan.value.id, plan.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독 플랜이 수정되었습니다.', life: 3000 });
}
planDialog.value = false;
await fetchPlans();
} catch (error) {
console.error('구독 플랜 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 플랜 저장 중 오류가 발생했습니다.', life: 3000 });
}
};
// 구독 플랜 삭제 확인
const confirmDeletePlan = (planData) => {
plan.value = planData;
deletePlanDialog.value = true;
};
// 구독 플랜 삭제
const deletePlan = async () => {
try {
await adminService.deletePlan(plan.value.id);
deletePlanDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '구독 플랜이 삭제되었습니다.', life: 3000 });
await fetchPlans();
} catch (error) {
console.error('구독 플랜 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 플랜 삭제 중 오류가 발생했습니다.', life: 3000 });
}
};
// 새 구독 다이얼로그 열기
const openNewSubscriptionDialog = (plan) => {
selectedPlan.value = plan;
subscription.value = {
planId: plan.id,
clubId: null,
startDate: new Date(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
status: 'active'
};
subscriptionSubmitted.value = false;
subscriptionDialog.value = true;
};
// 구독 수정 다이얼로그 열기
const editSubscription = (subscriptionData) => {
subscription.value = {
id: subscriptionData.id,
planId: subscriptionData.planId,
clubId: subscriptionData.Club.id,
startDate: new Date(subscriptionData.startDate),
expiryDate: new Date(subscriptionData.expiryDate),
status: subscriptionData.status
};
subscriptionSubmitted.value = false;
subscriptionDialog.value = true;
};
// 구독 다이얼로그 닫기
const hideSubscriptionDialog = () => {
subscriptionDialog.value = false;
subscriptionSubmitted.value = false;
};
// 구독 저장
const saveSubscription = async () => {
subscriptionSubmitted.value = true;
if (!subscription.value.clubId || !subscription.value.startDate || !subscription.value.expiryDate) {
return;
}
try {
if (!subscription.value.id) {
await adminService.createSubscription(subscription.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독이 추가되었습니다.', life: 3000 });
} else {
await adminService.updateSubscription(subscription.value.id, subscription.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독이 수정되었습니다.', life: 3000 });
}
subscriptionDialog.value = false;
await fetchPlanSubscriptions(subscription.value.planId);
} catch (error) {
console.error('구독 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독을 저장하는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 구독 삭제 확인
const confirmDeleteSubscription = (subscriptionData) => {
subscription.value = subscriptionData;
deleteSubscriptionDialog.value = true;
};
// 구독 삭제
const deleteSubscription = async () => {
try {
await adminService.deleteSubscription(subscription.value.id);
deleteSubscriptionDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '구독이 삭제되었습니다.', life: 3000 });
await fetchPlanSubscriptions(subscription.value.planId);
} catch (error) {
console.error('구독 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 삭제 중 오류가 발생했습니다.', life: 3000 });
}
};
// 상태 이름 가져오기
const getStatusName = (status) => {
switch (status) {
case 'active':
return '활성';
case 'inactive':
return '비활성';
default:
return status;
}
};
// 상태 심각도 가져오기
const getStatusSeverity = (status) => {
switch (status) {
case 'active':
return 'success';
case 'inactive':
return 'danger';
default:
return 'info';
}
};
// 금액 포맷팅
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(amount);
};
// 날짜 포맷팅
const formatDate = (date) => {
if (!date) return '-';
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
</script>
<style scoped>
.subscription-management-container {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h2 {
margin: 0;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.plan-features {
padding: 1rem;
background-color: var(--surface-ground);
border-radius: 4px;
}
.plan-features h4 {
margin: 0 0 1rem 0;
color: var(--text-color);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
background-color: var(--surface-card);
border-radius: 4px;
}
.feature-details h5 {
margin: 0 0 0.25rem 0;
color: var(--text-color);
}
.feature-details p {
margin: 0;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.plan-details {
padding: 1rem;
background-color: var(--surface-ground);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.plan-subscriptions {
background-color: var(--surface-card);
padding: 1rem;
border-radius: 4px;
}
.subscriptions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.subscriptions-header h4 {
margin: 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.confirmation-content {
display: flex;
align-items: center;
gap: 1rem;
}
.mr-3 {
margin-right: 0.75rem;
}
:deep(.p-inputnumber),
:deep(.p-calendar),
:deep(.p-dropdown),
:deep(.p-multiselect) {
width: 100%;
}
:deep(.p-badge) {
font-size: 0.8rem;
padding: 0.3rem 0.5rem;
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<template>
<div class="user-management-container">
<div class="header">
<h2>사용자 관리</h2>
<Button label="사용자 추가" icon="pi pi-plus" @click="openNewUserDialog" />
</div>
<DataTable
:value="users"
:loading="loading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="{first}에서 {last}까지 {totalRecords} 중"
responsiveLayout="scroll"
stripedRows
filterDisplay="menu"
v-model:filters="filters"
scrollable
scrollHeight="calc(100vh - 200px)"
>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="이름" sortable style="width: 15%">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="이름으로 검색" />
</template>
</Column>
<Column field="email" header="이메일" sortable style="width: 20%">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="이메일로 검색" />
</template>
</Column>
<Column field="role" header="역할" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getRoleName(slotProps.data.role)" :severity="getRoleSeverity(slotProps.data.role)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" class="p-column-filter" />
</template>
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" class="p-column-filter" />
</template>
</Column>
<Column field="createdAt" header="가입일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.createdAt) }}
</template>
</Column>
<Column field="lastLoginAt" header="최근 로그인" sortable style="width: 12%">
<template #body="slotProps">
{{ slotProps.data.lastLoginAt ? formatDate(slotProps.data.lastLoginAt) : '-' }}
</template>
</Column>
<Column header="작업" style="width: 8%">
<template #body="slotProps">
<div class="action-buttons">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editUser(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDeleteUser(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 사용자 추가/수정 다이얼로그 -->
<Dialog v-model:visible="userDialog" :header="dialogTitle" :style="{ width: '450px' }" :modal="true" class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="user.name" :class="{'p-invalid': submitted && !user.name}" required autofocus />
<small class="p-error" v-if="submitted && !user.name">이름은 필수입니다.</small>
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="user.email" :class="{'p-invalid': submitted && !user.email}" required />
<small class="p-error" v-if="submitted && !user.email">이메일은 필수입니다.</small>
</div>
<div class="field">
<label for="password">비밀번호</label>
<Password id="password" v-model="user.password" :class="{'p-invalid': submitted && isNewUser && !user.password}" toggleMask :feedback="isNewUser" />
<small class="p-error" v-if="submitted && isNewUser && !user.password">비밀번호는 필수입니다.</small>
<small v-if="!isNewUser">비밀번호를 변경하지 않으려면 비워두세요.</small>
</div>
<div class="field">
<label for="role">역할</label>
<Dropdown id="role" v-model="user.role" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" required />
<small class="p-error" v-if="submitted && !user.role">역할은 필수입니다.</small>
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="user.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" required />
<small class="p-error" v-if="submitted && !user.status">상태는 필수입니다.</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="saveUser" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteUserDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="user">{{ user.name }} 사용자를 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteUserDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteUser" />
</template>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, onMounted, reactive, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { FilterMatchMode } from 'primevue/api';
import Badge from 'primevue/badge';
import dayjs from 'dayjs';
import adminService from '@/services/adminService';
const toast = useToast();
// 상태 변수
const users = ref([]);
const user = ref({});
const userDialog = ref(false);
const deleteUserDialog = ref(false);
const submitted = ref(false);
const loading = ref(false);
const isNewUser = ref(false);
// 필터 설정
const filters = ref({
name: { value: null, matchMode: FilterMatchMode.CONTAINS },
email: { value: null, matchMode: FilterMatchMode.CONTAINS },
role: { value: null, matchMode: FilterMatchMode.EQUALS },
status: { value: null, matchMode: FilterMatchMode.EQUALS }
});
// 역할 옵션
const roleOptions = [
{ name: '관리자', value: 'admin' },
{ name: '클럽 관리자', value: 'clubadmin' },
{ name: '일반 사용자', value: 'user' }
];
// 상태 옵션
const statusOptions = [
{ name: '활성', value: 'active' },
{ name: '비활성', value: 'inactive' },
{ name: '정지', value: 'suspended' }
];
// 다이얼로그 제목
const dialogTitle = computed(() => {
return isNewUser.value ? '사용자 추가' : '사용자 수정';
});
// 페이지 로드 시 사용자 목록 가져오기
onMounted(async () => {
await fetchUsers();
});
// 사용자 목록 가져오기
const fetchUsers = async () => {
loading.value = true;
try {
const response = await adminService.getAllUsers();
users.value = response.data;
} catch (error) {
console.error('사용자 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 새 사용자 다이얼로그 열기
const openNewUserDialog = () => {
user.value = {
name: '',
email: '',
password: '',
role: 'user',
status: 'active'
};
submitted.value = false;
isNewUser.value = true;
userDialog.value = true;
};
// 사용자 수정 다이얼로그 열기
const editUser = async (userData) => {
try {
const response = await adminService.getUserById(userData.id);
user.value = { ...response.data, password: '' };
isNewUser.value = false;
submitted.value = false;
userDialog.value = true;
} catch (error) {
console.error('사용자 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 다이얼로그 닫기
const hideDialog = () => {
userDialog.value = false;
submitted.value = false;
};
// 사용자 저장 (추가 또는 수정)
const saveUser = async () => {
submitted.value = true;
if (!user.value.name || !user.value.email || !user.value.role || !user.value.status || (isNewUser.value && !user.value.password)) {
toast.add({ severity: 'warn', summary: '입력 오류', detail: '모든 필수 항목을 입력해주세요.', life: 3000 });
return;
}
try {
if (isNewUser.value) {
await adminService.createUser(user.value);
toast.add({ severity: 'success', summary: '성공', detail: '사용자가 생성되었습니다.', life: 3000 });
} else {
const userData = { ...user.value };
if (!userData.password) {
delete userData.password;
}
await adminService.updateUser(userData.id, userData);
toast.add({ severity: 'success', summary: '성공', detail: '사용자 정보가 수정되었습니다.', life: 3000 });
}
userDialog.value = false;
await fetchUsers();
} catch (error) {
console.error('사용자 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 저장 중 오류가 발생했습니다.', life: 3000 });
}
};
// 사용자 삭제 확인 다이얼로그 열기
const confirmDeleteUser = (userData) => {
user.value = userData;
deleteUserDialog.value = true;
};
// 사용자 삭제
const deleteUser = async () => {
try {
await adminService.deleteUser(user.value.id);
deleteUserDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '사용자가 삭제되었습니다.', life: 3000 });
await fetchUsers();
} catch (error) {
console.error('사용자 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 삭제 중 오류가 발생했습니다.', life: 3000 });
}
};
// 역할 이름 가져오기
const getRoleName = (role) => {
switch (role) {
case 'admin':
return '관리자';
case 'clubadmin':
return '클럽 관리자';
case 'user':
return '일반 사용자';
default:
return role;
}
};
// 역할 심각도 가져오기
const getRoleSeverity = (role) => {
switch (role) {
case 'admin':
return 'danger';
case 'clubadmin':
return 'warning';
case 'user':
return 'info';
default:
return 'info';
}
};
// 상태 이름 가져오기
const getStatusName = (status) => {
switch (status) {
case 'active':
return '활성';
case 'inactive':
return '비활성';
case 'suspended':
return '정지';
default:
return status;
}
};
// 상태 심각도 가져오기
const getStatusSeverity = (status) => {
switch (status) {
case 'active':
return 'success';
case 'inactive':
return 'secondary';
case 'suspended':
return 'danger';
default:
return 'info';
}
};
// 날짜 포맷팅
const formatDate = (date) => {
return date ? dayjs(date).format('YYYY-MM-DD') : '-';
};
</script>
<style scoped>
.user-management-container {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
}
.mr-3 {
margin-right: 0.75rem;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="login-container">
<div class="login-card">
<h2>볼링 클럽 매니저 로그인</h2>
<form @submit.prevent="handleLogin" v-if="!showRegisterForm">
<div class="form-group">
<label for="email">이메일</label>
<InputText id="email" v-model="email" class="w-full" :class="{'p-invalid': emailError}" />
<small v-if="emailError" class="p-error">{{ emailError }}</small>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<InputText id="password" v-model="password" type="password" class="w-full" :class="{'p-invalid': passwordError}" />
<small v-if="passwordError" class="p-error">{{ passwordError }}</small>
</div>
<div class="form-options">
<div class="remember-me">
<Checkbox v-model="rememberMe" :binary="true" inputId="rememberMe" />
<label for="rememberMe" class="ml-2">로그인 상태 유지</label>
</div>
<a href="#" class="forgot-password">비밀번호 찾기</a>
</div>
<Button type="submit" label="로그인" class="w-full" :loading="loading" />
<div class="register-link">
<p>계정이 없으신가요? <a href="#" @click.prevent="showRegisterForm = true">회원가입</a></p>
</div>
</form>
<!-- 회원가입 -->
<form @submit.prevent="handleRegister" v-if="showRegisterForm">
<h3>회원가입</h3>
<div class="form-group">
<label for="registerName">이름</label>
<InputText id="registerName" v-model="registerName" class="w-full" :class="{'p-invalid': registerNameError}" />
<small v-if="registerNameError" class="p-error">{{ registerNameError }}</small>
</div>
<div class="form-group">
<label for="registerEmail">이메일</label>
<InputText id="registerEmail" v-model="registerEmail" class="w-full" :class="{'p-invalid': registerEmailError}" />
<small v-if="registerEmailError" class="p-error">{{ registerEmailError }}</small>
</div>
<div class="form-group">
<label for="registerPassword">비밀번호</label>
<Password id="registerPassword" v-model="registerPassword" :feedback="true" toggleMask
:class="{'p-invalid': registerPasswordError}"
:promptLabel="'비밀번호 강도'"
:weakLabel="'약함'"
:mediumLabel="'중간'"
:strongLabel="'강함'"
class="w-full" />
<small v-if="registerPasswordError" class="p-error">{{ registerPasswordError }}</small>
</div>
<div class="form-group">
<label for="registerPasswordConfirm">비밀번호 확인</label>
<Password id="registerPasswordConfirm" v-model="registerPasswordConfirm" toggleMask
:class="{'p-invalid': registerPasswordConfirmError}"
:feedback="false"
class="w-full" />
<small v-if="registerPasswordConfirmError" class="p-error">{{ registerPasswordConfirmError }}</small>
</div>
<div class="form-group">
<label for="registerPhone">전화번호 (선택)</label>
<InputText id="registerPhone" v-model="registerPhone" class="w-full" />
</div>
<Button type="submit" label="회원가입" class="w-full" :loading="loading" />
<div class="login-link">
<p>이미 계정이 있으신가요? <a href="#" @click.prevent="showRegisterForm = false">로그인</a></p>
</div>
</form>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import authService from '@/services/authService';
const user = inject('user');
const router = useRouter();
const toast = useToast();
const email = ref('');
const password = ref('');
const rememberMe = ref(false);
const loading = ref(false);
const emailError = ref('');
const passwordError = ref('');
// 회원가입 관련 상태
const showRegisterForm = ref(false);
const registerName = ref('');
const registerEmail = ref('');
const registerPassword = ref('');
const registerPasswordConfirm = ref('');
const registerPhone = ref('');
const registerNameError = ref('');
const registerEmailError = ref('');
const registerPasswordError = ref('');
const registerPasswordConfirmError = ref('');
// 비밀번호 강도 검사
const passwordHasMinLength = computed(() => registerPassword.value.length >= 8);
const passwordHasUppercase = computed(() => /[A-Z]/.test(registerPassword.value));
const passwordHasLowercase = computed(() => /[a-z]/.test(registerPassword.value));
const passwordHasNumber = computed(() => /[0-9]/.test(registerPassword.value));
const passwordHasSpecial = computed(() => /[!@#$%^&*(),.?":{}|<>]/.test(registerPassword.value));
const validateForm = () => {
let isValid = true;
// 이메일 유효성 검사
if (!email.value) {
emailError.value = '이메일을 입력해주세요.';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
emailError.value = '유효한 이메일 주소를 입력해주세요.';
isValid = false;
} else {
emailError.value = '';
}
// 비밀번호 유효성 검사
if (!password.value) {
passwordError.value = '비밀번호를 입력해주세요.';
isValid = false;
} else {
passwordError.value = '';
}
return isValid;
};
// 회원가입 폼 유효성 검사
const validateRegisterForm = () => {
let isValid = true;
// 이름 유효성 검사
if (!registerName.value) {
registerNameError.value = '이름을 입력해주세요.';
isValid = false;
} else {
registerNameError.value = '';
}
// 이메일 유효성 검사
if (!registerEmail.value) {
registerEmailError.value = '이메일을 입력해주세요.';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerEmail.value)) {
registerEmailError.value = '유효한 이메일 주소를 입력해주세요.';
isValid = false;
} else {
registerEmailError.value = '';
}
// 비밀번호 유효성 검사
if (!registerPassword.value) {
registerPasswordError.value = '비밀번호를 입력해주세요.';
isValid = false;
} else if (registerPassword.value.length < 8) {
registerPasswordError.value = '비밀번호는 최소 8자 이상이어야 합니다.';
isValid = false;
} else if (!passwordHasUppercase.value || !passwordHasLowercase.value ||
!passwordHasNumber.value || !passwordHasSpecial.value) {
registerPasswordError.value = '비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다.';
isValid = false;
} else {
registerPasswordError.value = '';
}
// 비밀번호 확인 유효성 검사
if (!registerPasswordConfirm.value) {
registerPasswordConfirmError.value = '비밀번호 확인을 입력해주세요.';
isValid = false;
} else if (registerPassword.value !== registerPasswordConfirm.value) {
registerPasswordConfirmError.value = '비밀번호가 일치하지 않습니다.';
isValid = false;
} else {
registerPasswordConfirmError.value = '';
}
return isValid;
};
// 로그인 처리 함수
const handleLogin = async () => {
if (!validateForm()) return;
loading.value = true;
try {
const response = await authService.login({
email: email.value,
password: password.value
});
// 토큰 저장
const { token, user: userData } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('userRole', userData.role);
localStorage.setItem('clubId', userData.clubId);
localStorage.setItem('userClubRole', userData.memberType || '');
// 사용자 정보 저장
user.value = userData;
// 로그인 성공 메시지
toast.add({ severity: 'success', summary: '성공', detail: '로그인에 성공했습니다.', life: 3000 });
// 사용자 정보 및 메뉴 아이템 로드 이벤트 발생
window.dispatchEvent(new CustomEvent('user-logged-in'));
// 사용자 역할에 따라 리디렉션
if (userData.role === 'admin' || userData.role === 'superadmin') {
router.push('/admin');
} else {
router.push('/club');
}
} catch (error) {
console.error('로그인 오류:', error);
// 오류 메시지 처리
const errorMessage = error.response?.data?.message || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
// 회원가입 처리 함수
const handleRegister = async () => {
if (!validateRegisterForm()) return;
loading.value = true;
try {
await authService.register({
name: registerName.value,
email: registerEmail.value,
password: registerPassword.value,
phone: registerPhone.value
});
// 회원가입 성공 메시지
toast.add({ severity: 'success', summary: '성공', detail: '회원가입에 성공했습니다. 로그인해주세요.', life: 3000 });
// 로그인 폼으로 전환
showRegisterForm.value = false;
// 입력한 이메일을 로그인 폼에 자동 입력
email.value = registerEmail.value;
password.value = '';
// 회원가입 폼 초기화
registerName.value = '';
registerEmail.value = '';
registerPassword.value = '';
registerPasswordConfirm.value = '';
registerPhone.value = '';
} catch (error) {
console.error('회원가입 오류:', error);
// 오류 메시지 처리
const errorMessage = error.response?.data?.message || '회원가입에 실패했습니다. 다시 시도해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 80vh;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: white;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.remember-me {
display: flex;
align-items: center;
}
.forgot-password {
color: #2196F3;
text-decoration: none;
font-size: 0.875rem;
}
.forgot-password:hover {
text-decoration: underline;
}
.register-link, .login-link {
text-align: center;
margin-top: 1.5rem;
}
.register-link a, .login-link a {
color: #2196F3;
text-decoration: none;
font-weight: 500;
}
.register-link a:hover, .login-link a:hover {
text-decoration: underline;
}
.ml-2 {
margin-left: 0.5rem;
}
.w-full {
width: 100%;
}
:deep(.p-password-input) {
width: 100%;
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<template>
<div class="profile-container">
<div class="card">
<h2> 프로필</h2>
<div class="profile-content">
<div class="profile-header">
<div class="profile-avatar">
<Avatar :image="user.avatar || null" :label="getInitials(user.name)" size="xlarge" shape="circle" />
</div>
<div class="profile-info">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<p><Badge :value="user.role === 'admin' ? '관리자' : '일반 사용자'" :severity="user.role === 'admin' ? 'success' : 'info'" /></p>
</div>
</div>
<Divider />
<div class="profile-form">
<form @submit.prevent="updateProfile">
<div class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="form.name" :class="{'p-invalid': v$.name.$invalid && submitted}" />
<small v-if="v$.name.$invalid && submitted" class="p-error">{{ v$.name.$errors[0].$message }}</small>
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="form.email" :class="{'p-invalid': v$.email.$invalid && submitted}" />
<small v-if="v$.email.$invalid && submitted" class="p-error">{{ v$.email.$errors[0].$message }}</small>
</div>
<Divider />
<div class="field">
<label for="currentPassword">현재 비밀번호 (변경 시에만 입력)</label>
<Password id="currentPassword" v-model="form.currentPassword" :feedback="false" toggleMask />
</div>
<div class="field">
<label for="newPassword"> 비밀번호 (변경 시에만 입력)</label>
<Password id="newPassword" v-model="form.newPassword" :class="{'p-invalid': v$.newPassword.$invalid && passwordChangeAttempted}" toggleMask />
<small v-if="v$.newPassword.$invalid && passwordChangeAttempted" class="p-error">{{ v$.newPassword.$errors[0].$message }}</small>
</div>
<div class="field">
<label for="confirmPassword">비밀번호 확인</label>
<Password id="confirmPassword" v-model="form.confirmPassword" :class="{'p-invalid': v$.confirmPassword.$invalid && passwordChangeAttempted}" :feedback="false" toggleMask />
<small v-if="v$.confirmPassword.$invalid && passwordChangeAttempted" class="p-error">{{ v$.confirmPassword.$errors[0].$message }}</small>
</div>
</div>
<div class="button-container">
<Button type="submit" label="프로필 업데이트" :loading="loading" />
</div>
</form>
</div>
</div>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength, sameAs, helpers } from '@vuelidate/validators';
import axios from 'axios';
const toast = useToast();
const API_URL = 'http://localhost:3030/api';
// 상태 변수
const loading = ref(false);
const submitted = ref(false);
const passwordChangeAttempted = ref(false);
// 사용자 정보
const user = reactive({
id: null,
name: '',
email: '',
role: '',
avatar: null
});
// 폼 데이터
const form = reactive({
name: '',
email: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
// 유효성 검사 규칙
const rules = computed(() => ({
name: { required: helpers.withMessage('이름을 입력해주세요.', required) },
email: {
required: helpers.withMessage('이메일을 입력해주세요.', required),
email: helpers.withMessage('유효한 이메일 주소를 입력해주세요.', email)
},
newPassword: {
minLength: helpers.withMessage('비밀번호는 최소 8자 이상이어야 합니다.', minLength(8))
},
confirmPassword: {
sameAsPassword: helpers.withMessage('비밀번호가 일치하지 않습니다.', sameAs(form.newPassword))
}
}));
const v$ = useVuelidate(rules, form);
// 페이지 로드 시 사용자 정보 가져오기
onMounted(async () => {
await fetchUserProfile();
});
// 사용자 프로필 가져오기
const fetchUserProfile = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(`${API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }
});
// 사용자 정보 설정
Object.assign(user, response.data);
// 폼 데이터 초기화
form.name = user.name;
form.email = user.email;
} catch (error) {
console.error('사용자 프로필을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 프로필을 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 이니셜 가져오기 (아바타용)
const getInitials = (name) => {
if (!name) return '';
return name.split(' ')
.map(part => part.charAt(0))
.join('')
.toUpperCase();
};
// 프로필 업데이트
const updateProfile = async () => {
submitted.value = true;
const profileResult = await v$.value.$validate();
if (!profileResult) return;
loading.value = true;
try {
const token = localStorage.getItem('token');
const profileData = {
name: form.name,
email: form.email
};
// 비밀번호 변경이 요청된 경우
if (form.currentPassword && form.newPassword) {
passwordChangeAttempted.value = true;
if (form.newPassword !== form.confirmPassword) {
toast.add({ severity: 'error', summary: '오류', detail: '새 비밀번호가 일치하지 않습니다.', life: 3000 });
return;
}
profileData.currentPassword = form.currentPassword;
profileData.password = form.newPassword;
}
await axios.put(`${API_URL}/auth/profile`, profileData, {
headers: { Authorization: `Bearer ${token}` }
});
// 사용자 정보 업데이트
Object.assign(user, {
name: profileData.name,
email: profileData.email
});
// 비밀번호 필드 초기화
form.currentPassword = '';
form.newPassword = '';
form.confirmPassword = '';
passwordChangeAttempted.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '프로필이 성공적으로 업데이트되었습니다.', life: 3000 });
} catch (error) {
console.error('프로필 업데이트 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: error.response?.data?.message || '프로필 업데이트 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.profile-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #ffffff;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
}
.profile-content {
margin-top: 2rem;
}
.profile-header {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.profile-info {
h3 {
margin: 0;
margin-bottom: 0.5rem;
}
p {
margin: 0;
margin-bottom: 0.5rem;
color: #666;
}
}
.profile-form {
margin-top: 2rem;
.field {
margin-bottom: 1.5rem;
}
}
.button-container {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="club-create-container">
<div class="club-create-card">
<h2>클럽 생성</h2>
<p class="info-text">볼링 클럽을 생성하여 회원들을 관리하고 활동을 시작해보세요.</p>
<form @submit.prevent="handleCreateClub">
<div class="form-group">
<label for="clubName">클럽 이름</label>
<InputText id="clubName" v-model="clubName" class="w-full" :class="{'p-invalid': clubNameError}" />
<small v-if="clubNameError" class="p-error">{{ clubNameError }}</small>
</div>
<div class="form-group">
<label for="clubDescription">클럽 설명</label>
<Textarea id="clubDescription" v-model="clubDescription" rows="4" class="w-full" :class="{'p-invalid': clubDescriptionError}" />
<small v-if="clubDescriptionError" class="p-error">{{ clubDescriptionError }}</small>
</div>
<div class="form-group">
<label for="clubLocation">활동 볼링장</label>
<InputText id="clubLocation" v-model="clubLocation" class="w-full" />
</div>
<div class="form-group">
<label for="clubContact">연락처</label>
<InputText id="clubContact" v-model="clubContact" class="w-full" />
</div>
<Button type="submit" label="클럽 생성하기" class="w-full" :loading="loading" />
</form>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import clubService from '@/services/clubService';
import { useAuthStore } from '@/stores/authStore';
import axios from 'axios'
const toast = useToast();
const authStore = useAuthStore();
const router = useRouter();
const clubName = ref('');
const clubDescription = ref('');
const clubLocation = ref('');
const clubContact = ref('');
const loading = ref(false);
const clubNameError = ref('');
const clubDescriptionError = ref('');
onMounted(() => {
authStore.loadUserInfo();
});
const validateForm = () => {
let isValid = true;
// 클럽 이름 유효성 검사
if (!clubName.value) {
clubNameError.value = '클럽 이름을 입력해주세요.';
isValid = false;
} else {
clubNameError.value = '';
}
// 클럽 설명 유효성 검사
if (!clubDescription.value) {
clubDescriptionError.value = '클럽 설명을 입력해주세요.';
isValid = false;
} else {
clubDescriptionError.value = '';
}
return isValid;
};
const handleCreateClub = async () => {
if (!validateForm()) return;
if (!authStore.user) {
toast.add({ severity: 'error', summary: '오류', detail: '로그인이 필요합니다.', life: 3000 });
return;
}
loading.value = true;
try {
const response = await clubService.createClub({
name: clubName.value,
description: clubDescription.value,
location: clubLocation.value,
contact: clubContact.value,
ownerEmail: authStore.user.email
});
// 클럽 ID 저장
const clubId = response.data.id;
localStorage.setItem('clubId', clubId);
// 성공 메시지
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 성공적으로 생성되었습니다.', life: 3000 });
// 클럽 페이지로 이동
router.push('/club');
} catch (error) {
console.error('클럽 생성 오류:', error);
// 오류 메시지 처리
const errorMessage = error.response?.data?.message || '클럽 생성에 실패했습니다. 다시 시도해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.club-create-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
padding: 2rem 0;
}
.club-create-card {
width: 100%;
max-width: 600px;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: white;
}
.info-text {
margin-bottom: 1.5rem;
color: #666;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.w-full {
width: 100%;
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<div class="club-dashboard">
<h2>클럽 대시보드</h2>
<div v-if="loading" class="card flex justify-content-center">
<ProgressSpinner />
</div>
<div v-else class="grid">
<!-- 회원 통계 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-users"></i>
<h3>회원 현황</h3>
</div>
<div class="card-content">
<p> 회원: {{ memberStats.totalCount }}</p>
<p>정회원: {{ memberStats.regularCount }}</p>
<p>준회원: {{ memberStats.associateCount }}</p>
</div>
<div class="card-footer">
<Button label="회원 관리" icon="pi pi-arrow-right" @click="navigateTo('/club/members')" />
</div>
</div>
</div>
<!-- 다음 모임 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-calendar"></i>
<h3>다음 모임</h3>
</div>
<div class="card-content" v-if="nextMeeting">
<p>일시: {{ formatDate(nextMeeting.date) }}</p>
<p>장소: {{ nextMeeting.location || '미정' }}</p>
<p>참가 예정: {{ nextMeeting.participantsCount }}</p>
</div>
<div class="card-content" v-else>
<p>예정된 모임이 없습니다.</p>
</div>
<div class="card-footer">
<Button label="모임 관리" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
</div>
</div>
</div>
<!-- 최근 모임 통계 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-chart-bar"></i>
<h3>최근 모임</h3>
</div>
<div class="card-content" v-if="lastMeeting">
<p>일시: {{ formatDate(lastMeeting.date) }}</p>
<p>참가자: {{ lastMeeting.participantsCount }}</p>
<p>게임 : {{ lastMeeting.games }}게임</p>
</div>
<div class="card-content" v-else>
<p>최근 모임 기록이 없습니다.</p>
</div>
<div class="card-footer">
<Button label="기록 보기" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
</div>
</div>
</div>
<!-- 점수 통계 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-chart-line"></i>
<h3>점수 통계</h3>
</div>
<div class="card-content">
<p>최고 게임: {{ scoreStats.topGame || 0 }}</p>
<p>평균 점수: {{ scoreStats.averageScore || 0 }}</p>
<p>전체 게임: {{ scoreStats.totalGames || 0 }}게임</p>
</div>
<div class="card-footer">
<Button label="통계 보기" icon="pi pi-arrow-right" @click="navigateTo('/club/statistics')" />
</div>
</div>
</div>
</div>
<div class="grid mt-4">
<!-- 상위 회원 순위 -->
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<h3>에버리지 순위</h3>
</div>
<DataTable :value="topMembers" :rows="5" stripedRows responsiveLayout="scroll">
<Column field="rank" header="#" :body="rankTemplate" style="width: 10%"></Column>
<Column field="name" header="이름" style="width: 30%"></Column>
<Column field="average" header="에버리지" :body="averageTemplate" style="width: 20%"></Column>
<Column field="games" header="게임 수" style="width: 20%"></Column>
<Column field="handicap" header="핸디캡" style="width: 20%"></Column>
</DataTable>
</div>
</div>
<!-- 최근 모임 목록 -->
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<h3>최근 모임</h3>
</div>
<DataTable :value="recentMeetings" :rows="5" stripedRows responsiveLayout="scroll">
<Column field="date" header="일시" :body="dateTemplate" style="width: 25%"></Column>
<Column field="title" header="제목" style="width: 40%"></Column>
<Column field="participants" header="참가자" style="width: 15%"></Column>
<Column field="games" header="게임 수" style="width: 20%"></Column>
</DataTable>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import apiClient from '@/services/api';
import dayjs from 'dayjs';
import { useToast } from 'primevue/usetoast';
const router = useRouter();
const toast = useToast();
// 데이터 상태 변수들
const loading = ref(false);
const clubId = ref(null);
// 회원 통계
const memberStats = ref({
totalCount: 0,
regularCount: 0,
associateCount: 0
});
// 다음 모임 정보
const nextMeeting = ref(null);
// 최근 모임 정보
const lastMeeting = ref(null);
// 점수 통계
const scoreStats = ref({
topGame: 0,
averageScore: 0,
totalGames: 0
});
// 상위 회원 목록
const topMembers = ref([]);
// 최근 모임 목록
const recentMeetings = ref([]);
// 날짜 포맷 함수
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm');
};
// 템플릿 함수들
const rankTemplate = (rowData, column) => {
return rowData.rank || topMembers.value.indexOf(rowData) + 1;
};
const averageTemplate = (rowData) => {
return rowData.average ? rowData.average.toFixed(1) : '0.0';
};
const dateTemplate = (rowData) => {
return formatDate(rowData.date);
};
// 데이터 로드 함수
const loadDashboardData = async () => {
if (!clubId.value) {
console.error('클럽 ID가 없습니다.');
return;
}
try {
loading.value = true;
// 회원 통계 로드
const membersResponse = await apiClient.post('/api/club/members/stats', {
clubId: clubId.value
});
memberStats.value = membersResponse.data;
// 다음 모임 정보 로드
const nextMeetingResponse = await apiClient.post('/api/club/meetings/next', {
clubId: clubId.value
});
nextMeeting.value = nextMeetingResponse.data;
// 최근 모임 정보 로드
const lastMeetingResponse = await apiClient.post('/api/club/meetings/last', {
clubId: clubId.value
});
lastMeeting.value = lastMeetingResponse.data;
// 점수 통계 로드
const scoreStatsResponse = await apiClient.post('/api/club/scores/stats', {
clubId: clubId.value
});
scoreStats.value = scoreStatsResponse.data;
// 상위 회원 목록 로드
const topMembersResponse = await apiClient.post('/api/club/members/top', {
clubId: clubId.value
});
topMembers.value = topMembersResponse.data;
// 최근 모임 목록 로드
const recentMeetingsResponse = await apiClient.post('/api/club/meetings/recent', {
clubId: clubId.value
});
recentMeetings.value = recentMeetingsResponse.data;
} catch (error) {
console.error('대시보드 데이터 로딩 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: '대시보드 데이터를 불러오는 중 오류가 발생했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
// 페이지 이동 함수
const navigateTo = (path) => {
router.push(path);
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
clubId.value = localStorage.getItem('clubId') || null;
await loadDashboardData();
});
</script>
<style scoped>
.club-dashboard {
padding: 1rem;
}
.dashboard-card {
background: #ffffff;
border-radius: 10px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
}
.card-header i {
font-size: 1.5rem;
color: var(--primary-color);
}
.card-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.2rem;
}
.card-content {
flex: 1;
margin-bottom: 1rem;
}
.card-content p {
margin: 0.5rem 0;
color: var(--text-color-secondary);
}
.card-footer {
margin-top: auto;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="event-calendar-container">
<div class="header">
<h2>이벤트 캘린더</h2>
<Button label="이벤트 관리" icon="pi pi-cog" @click="navigateToEventManagement" />
</div>
<FullCalendar
:options="calendarOptions"
class="calendar-wrapper"
/>
<!-- 이벤트 상세 다이얼로그 -->
<EventDetailsDialog
v-model:visible="eventDetailsDialog"
:event="selectedEvent"
:getEventTypeName="getEventTypeName"
:getEventTypeSeverity="getEventTypeSeverity"
:getStatusName="getStatusName"
:getStatusSeverity="getStatusSeverity"
:formatDate="formatDate"
@edit="navigateToEventManagement"
@remove-participant="removeParticipant"
/>
<Toast />
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import koLocale from '@fullcalendar/core/locales/ko';
import EventDetailsDialog from '@/components/event/EventDetailsDialog.vue';
import eventService from '@/services/eventService';
import {
getEventTypeName,
getEventTypeSeverity,
getStatusName,
getStatusSeverity,
formatDate
} from '@/utils/eventUtils';
const router = useRouter();
const toast = useToast();
// 상태 변수
const events = ref([]);
const selectedEvent = ref({});
const eventDetailsDialog = ref(false);
const loading = ref(false);
const clubId = ref(localStorage.getItem('clubId') || null);
// 페이지 로드 시 데이터 가져오기
onMounted(async () => {
await fetchEvents();
});
// 이벤트 목록 가져오기
const fetchEvents = async () => {
loading.value = true;
try {
const eventData = await eventService.getEvents(clubId.value);
events.value = eventData.map(event => ({
id: event.id,
title: event.title,
start: event.startDate,
end: event.endDate,
extendedProps: {
...event
},
backgroundColor: getEventColor(event.eventType),
borderColor: getEventColor(event.eventType),
textColor: '#ffffff'
}));
} catch (error) {
console.error('이벤트 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '이벤트 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 이벤트 타입에 따른 색상 지정
const getEventColor = (eventType) => {
switch (eventType) {
case '정기전':
return '#FF5722'; // 주황색
case '번개':
return '#2196F3'; // 파란색
case '연습':
return '#4CAF50'; // 초록색
case '교류전':
return '#9C27B0'; // 보라색
case '이벤트':
return '#FFC107'; // 노란색
case '기타':
return '#607D8B'; // 회색
default:
return '#3f51b5'; // 기본색
}
};
// 캘린더 이벤트 클릭 핸들러
const handleEventClick = (info) => {
const eventId = info.event.id;
viewEventDetails(eventId);
};
// 이벤트 상세 보기
const viewEventDetails = async (eventId) => {
try {
selectedEvent.value = await eventService.getEventById(eventId);
eventDetailsDialog.value = true;
} catch (error) {
console.error('이벤트 상세 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '이벤트 상세 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// 참가자 제거
const removeParticipant = async (participant) => {
try {
await eventService.removeParticipant(selectedEvent.value.id, participant.id);
// 참가자 목록에서 제거
selectedEvent.value.participants = selectedEvent.value.participants.filter(p => p.id !== participant.id);
toast.add({ severity: 'success', summary: '성공', detail: '참가자가 제거되었습니다.', life: 3000 });
} catch (error) {
console.error('참가자 제거 중 오류 발생:', error);
const errorMessage = error.response?.data?.message || '참가자 제거 중 오류가 발생했습니다.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
}
};
// 이벤트 관리 페이지로 이동
const navigateToEventManagement = () => {
router.push('/club/events');
};
// 캘린더 옵션
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
events: events.value,
eventClick: handleEventClick,
locale: 'ko',
height: 'auto',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
meridiem: false,
hour12: false
},
dayMaxEvents: true
}));
</script>
<style scoped>
.event-calendar-container {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.calendar-wrapper {
height: calc(100vh - 200px);
margin-bottom: 2rem;
}
:deep(.fc-event) {
cursor: pointer;
}
:deep(.fc-toolbar-title) {
font-size: 1.5rem !important;
}
:deep(.fc-daygrid-day-number) {
font-size: 1rem;
}
:deep(.fc-col-header-cell-cushion) {
font-weight: 600;
}
</style>
+480
View File
@@ -0,0 +1,480 @@
<template>
<div class="event-management-container">
<div class="header">
<h2>이벤트 관리</h2>
<div class="header-buttons">
<Button label="캘린더 보기" icon="pi pi-calendar" class="p-button-outlined mr-2" @click="navigateToCalendar" />
<Button label="이벤트 추가" icon="pi pi-plus" @click="openNewEventDialog" />
</div>
</div>
<!-- 이벤트 목록 -->
<DataTable
:value="events"
:loading="loading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="{first}에서 {last}까지 {totalRecords} 중"
responsiveLayout="scroll"
stripedRows
filterDisplay="menu"
v-model:filters="filters"
class="mt-4"
>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="title" header="제목" sortable style="width: 20%">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="제목으로 검색" />
</template>
</Column>
<Column field="eventType" header="유형" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getEventTypeName(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
</template>
</Column>
<Column field="startDate" header="시작일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.startDate) }}
</template>
<template #filter="{ filterModel, filterCallback }">
<Calendar v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
</template>
</Column>
<Column field="endDate" header="종료일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.endDate) }}
</template>
</Column>
<Column field="location" header="장소" sortable style="width: 15%"></Column>
<Column field="maxParticipants" header="최대 인원" sortable style="width: 8%"></Column>
<Column field="status" header="상태" sortable style="width: 8%">
<template #body="slotProps">
<Badge :value="slotProps.data.status" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="작업" style="width: 10%">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-eye" class="p-button-rounded p-button-text" @click="viewEventDetails(slotProps.data)" />
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editEvent(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger" @click="confirmDeleteEvent(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 이벤트 다이얼로그 -->
<EventDialog
v-model:visible="eventDialog"
:event="event || {}"
:isNew="!event?.id"
:submitted="submitted"
@save="saveEvent"
@hide="hideDialog"
/>
<!-- 이벤트 상세 정보 다이얼로그 -->
<EventDetailsDialog
v-model:visible="detailsDialog"
:event="event || {}"
:getEventTypeName="getEventTypeName"
:getEventTypeSeverity="getEventTypeSeverity"
:getStatusName="getStatusName"
:getStatusSeverity="getStatusSeverity"
:formatDate="formatDate"
:availableMembers="clubMembers"
@edit="editFromDetails"
@hide="hideDialog"
@participant-updated="fetchEvents"
@score-updated="fetchEvents"
/>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteEventDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="event">{{ event.title }} 이벤트를 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteEventDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-danger" @click="deleteEvent" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useRoute } from 'vue-router';
import { useClubStore } from '@/stores/clubStore';
import eventService from '@/services/eventService';
import clubService from '@/services/clubService';
import ScoreManagement from '@/components/event/ScoreManagement.vue';
import ParticipantManagement from '@/components/event/ParticipantManagement.vue';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import EventDialog from '@/components/event/EventDialog.vue';
import EventDetailsDialog from '@/components/event/EventDetailsDialog.vue';
const toast = useToast();
const route = useRoute();
const clubStore = useClubStore();
// 상태 관리
const events = ref([]);
const event = ref({});
const eventDialog = ref(false);
const deleteEventDialog = ref(false);
const detailsDialog = ref(false);
const loading = ref(false);
const submitted = ref(false);
const clubId = ref(localStorage.getItem('clubId') || null);
// 필터 상태
const filters = ref({
title: { value: null, matchMode: 'contains' },
eventType: { value: null, matchMode: 'equals' },
startDate: { value: null, matchMode: 'equals' }
});
// 이벤트 유형 옵션
const eventTypeOptions = [
{ name: '정기전', value: '정기전' },
{ name: '토너먼트', value: '토너먼트' },
{ name: '연습', value: '연습' },
{ name: '친선전', value: '친선전' },
{ name: '기타', value: '기타' }
];
// 클럽 멤버 목록
const clubMembers = ref([]);
// 이벤트 목록 가져오기
const fetchEvents = async () => {
loading.value = true;
try {
const response = await eventService.getEvents(clubId.value);
events.value = response;
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 목록을 가져오는데 실패했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
// 클럽 멤버 목록 가져오기
const fetchClubMembers = async () => {
try {
const response = await clubService.getClubMembers(clubId.value);
clubMembers.value = response.data.map(member => ({
id: member.memberId || member.id, // memberId 또는 id 필드 사용
name: member.name,
memberType: member.memberType
}));
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '클럽 멤버 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
}
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
if (!clubId.value) {
toast.add({
severity: 'error',
summary: '오류',
detail: '클럽 정보가 없습니다.',
life: 3000
});
return;
}
await Promise.all([
fetchEvents(),
fetchClubMembers()
]);
});
// 날짜 포맷 함수
const formatDate = (date) => {
if (!date) return '';
const d = new Date(date);
return d.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// 이벤트 유형에 따른 Badge 스타일
const getEventTypeSeverity = (type) => {
switch (type) {
case '정기전': return 'success';
case '토너먼트': return 'warning';
case '연습': return 'info';
case '친선전': return 'primary';
default: return 'secondary';
}
};
// 이벤트 유형 이름 반환
const getEventTypeName = (type) => {
return type || '기타';
};
// 이벤트 상태 이름 반환
const getStatusName = (status) => {
switch (status) {
case '준비': return '준비';
case '진행중': return '진행중';
case '완료': return '완료';
case '취소': return '취소';
default: return '알 수 없음';
}
};
// 이벤트 상태에 따른 Badge 스타일
const getStatusSeverity = (status) => {
switch (status) {
case '준비': return 'info';
case '진행중': return 'warning';
case '완료': return 'success';
case '취소': return 'danger';
default: return 'secondary';
}
};
// 새 이벤트 다이얼로그 열기
const openNewEventDialog = () => {
event.value = {
clubId: clubId.value,
status: '준비',
eventType: '정기전',
title: '',
description: '',
startDate: null,
endDate: null,
location: '',
maxParticipants: 0,
teamCount: 1,
gameCount: 1,
laneCount: 0,
participantFee: 0
};
submitted.value = false;
eventDialog.value = true;
};
// 이벤트 수정 다이얼로그 열기
const editEvent = (eventData) => {
event.value = { ...eventData };
submitted.value = false;
eventDialog.value = true;
};
// 이벤트 상세 정보 보기
const viewEventDetails = async (eventData) => {
try {
const response = await eventService.getEventById(eventData.id);
event.value = response;
detailsDialog.value = true;
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 상세 정보를 가져오는데 실패했습니다.',
life: 3000
});
}
};
// 상세 보기에서 수정으로 전환
const editFromDetails = () => {
detailsDialog.value = false;
eventDialog.value = true;
};
// 다이얼로그 닫기
const hideDialog = () => {
eventDialog.value = false;
detailsDialog.value = false;
deleteEventDialog.value = false;
event.value = {};
submitted.value = false;
};
// 이벤트 삭제 확인
const confirmDeleteEvent = (eventData) => {
event.value = eventData;
deleteEventDialog.value = true;
};
// 이벤트 저장
const saveEvent = async (eventData) => {
try {
if (eventData.id) {
// 이벤트 수정
const response = await eventService.updateEvent({
...eventData,
clubId: clubId.value
});
const updatedEvent = response;
const index = events.value.findIndex(e => e.id === updatedEvent.id);
if (index !== -1) {
events.value[index] = updatedEvent;
}
} else {
// 이벤트 생성
const response = await eventService.createEvent({
...eventData,
clubId: clubId.value
});
events.value.push(response);
}
toast.add({
severity: 'success',
summary: '성공',
detail: eventData.id ? '이벤트가 수정되었습니다.' : '이벤트가 생성되었습니다.',
life: 3000
});
hideDialog();
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: eventData.id ? '이벤트 수정에 실패했습니다.' : '이벤트 생성에 실패했습니다.',
life: 3000
});
}
};
// 이벤트 삭제
const deleteEvent = async () => {
try {
await eventService.deleteEvent(clubId.value, event.value.id);
toast.add({
severity: 'success',
summary: '성공',
detail: '이벤트가 삭제되었습니다.',
life: 3000
});
deleteEventDialog.value = false;
const index = events.value.findIndex(e => e.id === event.value.id);
if (index > -1) {
events.value.splice(index, 1);
}
event.value = {};
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 삭제에 실패했습니다.',
life: 3000
});
}
};
// 참가자 제거
const removeParticipant = async (participant) => {
try {
await eventService.removeParticipant(event.value.id, participant.id);
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자가 제거되었습니다.',
life: 3000
});
// 참가자 목록 업데이트
const index = event.value.participants.findIndex(p => p.id === participant.id);
if (index > -1) {
event.value.participants.splice(index, 1);
}
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 제거에 실패했습니다.',
life: 3000
});
}
};
// 점수 입력 페이지로 이동
const goToScoreEntry = () => {
const url = `/club/events/${event.value.id}/scores?clubId=${clubId.value}`;
window.location.href = url;
};
// 이벤트 캘린더 페이지로 이동
const navigateToCalendar = () => {
const url = `/club/calendar?clubId=${clubId.value}`;
window.location.href = url;
};
</script>
<style scoped>
.event-management-container {
padding: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header h2 {
margin: 0;
}
.header-buttons {
display: flex;
gap: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.flex {
display: flex;
}
.gap-2 {
gap: 0.5rem;
}
:deep(.p-datatable) {
margin-top: 1rem;
}
:deep(.p-column-filter) {
width: 100%;
}
</style>
@@ -0,0 +1,558 @@
<template>
<div class="member-management">
<div class="header">
<h2>회원 관리</h2>
<Button label="새 회원 추가" icon="pi pi-plus" @click="openNewMemberDialog" />
</div>
<div class="filter-section">
<div class="grid">
<div class="col-12 md:col-6">
<div class="w-full">
<i class="pi pi-search" />
<InputText v-model="filters.searchText" placeholder="이름, 전화번호, 이메일로 검색" class="w-full searchText" />
</div>
</div>
<div class="col-12 md:col-6 text-right">
<Button icon="pi pi-filter" label="상세 필터" @click="filterDialog = true"
:class="{'p-button-info': isFilterActive}" />
</div>
</div>
</div>
<DataTable :value="filteredMembers" :paginator="true" :rows="10"
:rowsPerPageOptions="[5, 10, 20]"
stripedRows responsiveLayout="scroll">
<Column field="name" header="이름" sortable style="width: 15%"></Column>
<Column field="gender" header="성별" sortable style="width: 10%"></Column>
<Column field="memberType" header="회원 유형" sortable style="width: 15%">
<template #body="slotProps">
<span :class="getMemberTypeClass(slotProps.data.memberType)">
{{ slotProps.data.memberType }}
</span>
</template>
</Column>
<Column field="handicap" header="핸디캡" sortable style="width: 10%"></Column>
<Column field="average" header="에버리지" sortable style="width: 10%"></Column>
<Column field="games" header="게임 수" sortable style="width: 10%"></Column>
<Column field="joinDate" header="가입일" sortable style="width: 10%">
<template #body="slotProps">
{{ formatDate(slotProps.data.joinDate) }}
</template>
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<span :class="`status-${slotProps.data.status}`">
{{ getStatusLabel(slotProps.data.status) }}
</span>
</template>
</Column>
<Column header="관리" style="width: 15%">
<template #body="slotProps">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-success mr-2"
@click="editMember(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-danger"
@click="confirmDeleteMember(slotProps.data)" />
</template>
</Column>
</DataTable>
<!-- 회원 추가/수정 다이얼로그 -->
<Dialog v-model:visible="memberDialog" :style="{width: '450px'}"
:header="dialogMode === 'new' ? '새 회원 추가' : '회원 정보 수정'"
:modal="true" class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="member.name" required autofocus />
</div>
<div class="field">
<label for="gender">성별</label>
<Dropdown id="gender" v-model="member.gender" :options="genderOptions"
optionLabel="name" placeholder="성별 선택" />
</div>
<div class="field">
<label for="memberType">회원 유형</label>
<Dropdown id="memberType" v-model="member.memberTypeObj" :options="memberTypes"
optionLabel="name" placeholder="회원 유형 선택" />
</div>
<div class="field">
<label for="phone">전화번호</label>
<InputText id="phone" v-model="member.phone" />
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="member.email" />
</div>
<div class="field">
<label for="handicap">핸디캡</label>
<InputText id="handicap" v-model="member.handicap" type="number" min="0" max="50" />
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="member.status" :options="statusOptions"
optionLabel="label" optionValue="value" placeholder="상태 선택" />
</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="saveMember" />
</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 v-if="selectedMember">정말로 <b>{{ selectedMember.name }}</b> 회원을 삭제하시겠습니까?</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="deleteMember" />
</template>
</Dialog>
<!-- 상세 필터 다이얼로그 -->
<Dialog v-model:visible="filterDialog" header="상세 필터" :style="{width: '450px'}" :modal="true">
<div class="grid p-fluid">
<div class="col-12 md:col-4">
<div class="field">
<label>회원 유형</label>
<MultiSelect v-model="filters.memberTypes" :options="memberTypes" optionLabel="name"
placeholder="선택" class="w-full" />
</div>
</div>
<div class="col-12 md:col-4">
<div class="field">
<label>성별</label>
<MultiSelect v-model="filters.genders" :options="genderOptions" optionLabel="name"
placeholder="선택" class="w-full" />
</div>
</div>
<div class="col-12 md:col-4">
<div class="field">
<label>상태</label>
<MultiSelect v-model="filters.statuses" :options="statusOptions" optionLabel="label"
placeholder="선택" class="w-full" />
</div>
</div>
<div class="col-12 md:col-4">
<div class="field">
<label>핸디캡</label>
<div class="p-inputgroup">
<InputNumber v-model="filters.handicapFrom" placeholder="최소" class="p-inputgroup-left" size="small" />
<InputNumber v-model="filters.handicapTo" placeholder="최대" class="p-inputgroup-right" size="small" />
</div>
</div>
</div>
<div class="col-12 md:col-4">
<div class="field">
<label>에버리지</label>
<div class="p-inputgroup">
<InputNumber v-model="filters.averageFrom" placeholder="최소" mode="decimal"
:minFractionDigits="1" :maxFractionDigits="1" class="p-inputgroup-left" size="small" />
<InputNumber v-model="filters.averageTo" placeholder="최대" mode="decimal"
:minFractionDigits="1" :maxFractionDigits="1" class="p-inputgroup-right" size="small" />
</div>
</div>
</div>
<div class="col-12 md:col-4">
<div class="field">
<label>게임 </label>
<div class="p-inputgroup">
<InputNumber v-model="filters.gamesFrom" placeholder="최소" class="p-inputgroup-left" size="small" />
<InputNumber v-model="filters.gamesTo" placeholder="최대" class="p-inputgroup-right" size="small" />
</div>
</div>
</div>
</div>
<template #footer>
<Button label="필터 초기화" icon="pi pi-refresh" class="p-button-text" @click="resetFilters" />
<Button label="적용" icon="pi pi-check" @click="applyFilters" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber';
import Dialog from 'primevue/dialog';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import apiClient from '@/services/api';
import clubService from '@/services/clubService';
const toast = useToast();
// 회원 유형 목록
const memberTypes = ref([
{ name: '정회원', code: 'regular' },
{ name: '준회원', code: 'associate' },
{ name: '운영진', code: 'admin' },
{ name: '모임장', code: 'owner' }
]);
// 성별 옵션
const genderOptions = ref([
{ name: '남성', code: 'male' },
{ name: '여성', code: 'female' }
]);
// 회원 목록
const members = ref([]);
// 현재 클럽 ID
const clubId = ref(localStorage.getItem('clubId') || null);
// 데이터 로딩 상태
const loading = ref(true);
// 다이얼로그 상태
const memberDialog = ref(false);
const deleteDialog = ref(false);
const filterDialog = ref(false);
const dialogMode = ref('new');
const member = ref({});
const selectedMember = ref(null);
// 필터
const filters = reactive({
searchText: '',
memberTypes: [],
genders: [],
statuses: [],
handicapFrom: null,
handicapTo: null,
averageFrom: null,
averageTo: null,
gamesFrom: null,
gamesTo: null
});
// 필터 활성화 여부 확인
const isFilterActive = computed(() => {
return filters.memberTypes.length > 0 ||
filters.genders.length > 0 ||
filters.statuses.length > 0 ||
filters.handicapFrom ||
filters.handicapTo ||
filters.averageFrom ||
filters.averageTo ||
filters.gamesFrom ||
filters.gamesTo;
});
// 필터링된 회원 목록
const filteredMembers = computed(() => {
if (!members.value) return [];
return members.value.filter(member => {
// 기본 검색 (이름, 전화번호, 이메일)
if (filters.searchText) {
const searchText = filters.searchText.toLowerCase();
const matchesSearch =
member.name?.toLowerCase().includes(searchText) ||
member.phone?.toLowerCase().includes(searchText) ||
member.email?.toLowerCase().includes(searchText);
if (!matchesSearch) return false;
}
// 회원 유형 필터
if (filters.memberTypes.length > 0 &&
!filters.memberTypes.some(type => type.name === member.memberType)) {
return false;
}
// 성별 필터
if (filters.genders.length > 0 &&
!filters.genders.some(gender => gender.name === member.gender)) {
return false;
}
// 상태 필터
if (filters.statuses.length > 0 &&
!filters.statuses.some(status => status.value === member.status)) {
return false;
}
// 핸디캡 범위 필터
if (filters.handicapFrom !== null && member.handicap < filters.handicapFrom) return false;
if (filters.handicapTo !== null && member.handicap > filters.handicapTo) return false;
// 에버리지 범위 필터
if (filters.averageFrom !== null && member.average < filters.averageFrom) return false;
if (filters.averageTo !== null && member.average > filters.averageTo) return false;
// 게임 수 범위 필터
if (filters.gamesFrom !== null && member.games < filters.gamesFrom) return false;
if (filters.gamesTo !== null && member.games > filters.gamesTo) return false;
return true;
});
});
// 필터 초기화
const resetFilters = () => {
filters.memberTypes = [];
filters.genders = [];
filters.statuses = [];
filters.handicapFrom = null;
filters.handicapTo = null;
filters.averageFrom = null;
filters.averageTo = null;
filters.gamesFrom = null;
filters.gamesTo = null;
};
// 필터 적용
const applyFilters = () => {
filterDialog.value = false;
};
// 날짜 형식 변환 함수
const formatDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
};
// 회원 상태 표시 함수
const getStatusLabel = (status) => {
switch (status) {
case 'active': return '활성';
case 'inactive': return '비활성';
case 'suspended': return '정지';
case 'deleted': return '삭제됨';
default: return status;
}
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
try {
loading.value = true;
await loadMembers();
} catch (error) {
console.error('회원 데이터 로딩 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 데이터 로딩 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
});
// 회원 목록 로드 함수
const loadMembers = async () => {
if (!clubId.value) {
console.error('클럽 ID가 없습니다.');
return;
}
try {
const response = await clubService.getClubMembers(clubId.value);
members.value = response.data || [];
} catch (error) {
console.error('회원 목록 로딩 중 오류 발생:', error);
throw error;
}
};
// 회원 유형에 따른 클래스 반환
const getMemberTypeClass = (type) => {
switch (type) {
case '모임장': return 'member-type-owner';
case '운영진': return 'member-type-admin';
case '정회원': return 'member-type-regular';
case '준회원': return 'member-type-associate';
default: return '';
}
};
// 새 회원 추가 다이얼로그 열기
const openNewMemberDialog = () => {
member.value = {
name: '',
gender: null,
memberTypeObj: null,
phone: '',
email: '',
handicap: 0
};
dialogMode.value = 'new';
memberDialog.value = true;
};
// 회원 수정 다이얼로그 열기
const editMember = (data) => {
member.value = { ...data };
dialogMode.value = 'edit';
memberDialog.value = true;
};
// 다이얼로그 닫기
const hideDialog = () => {
memberDialog.value = false;
};
// 회원 저장
const saveMember = async () => {
try {
if (dialogMode.value === 'new') {
await clubService.addClubMember(clubId.value, member.value);
toast.add({ severity: 'success', summary: '성공', detail: '새 회원이 추가되었습니다.', life: 3000 });
} else {
await clubService.updateClubMember(clubId.value, member.value.id, member.value);
toast.add({ severity: 'success', summary: '성공', detail: '회원 정보가 수정되었습니다.', life: 3000 });
}
hideDialog();
await loadMembers();
} catch (error) {
console.error('회원 저장 중 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 정보 저장 중 오류가 발생했습니다.', life: 3000 });
}
};
// 회원 삭제 확인
const confirmDeleteMember = (data) => {
selectedMember.value = data;
deleteDialog.value = true;
};
// 회원 삭제
const deleteMember = async () => {
try {
await clubService.deleteClubMember(clubId.value, selectedMember.value.id);
deleteDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '회원이 삭제되었습니다.', life: 3000 });
await loadMembers();
} catch (error) {
console.error('회원 삭제 중 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 삭제 중 오류가 발생했습니다.', life: 3000 });
}
};
const statusOptions = ref([
{ label: '활성', value: 'active' },
{ label: '비활성', value: 'inactive' },
{ label: '정지', value: 'suspended' }
]);
</script>
<style scoped>
.member-management {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h2 {
margin: 0;
}
.filter-section {
margin-bottom: 1rem;
}
.member-type-regular {
color: #3B82F6;
font-weight: bold;
}
.member-type-associate {
color: #10B981;
font-weight: bold;
}
.member-type-admin {
color: #F59E0B;
font-weight: bold;
}
.member-type-owner {
color: #EF4444;
font-weight: bold;
}
/* 필터 다이얼로그 스타일 */
:deep(.p-dialog-content) {
padding: 1rem;
}
:deep(.field) {
margin-bottom: 0.75rem;
}
:deep(.field label) {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
:deep(.p-inputnumber-input) {
padding: 0.25rem 0.5rem;
}
/* InputGroup 스타일 */
:deep(.p-inputgroup) {
display: flex;
width: 100%;
position: relative;
}
:deep(.p-inputgroup .p-inputnumber) {
flex: 1;
}
:deep(.p-inputgroup .p-inputnumber-input) {
border-radius: 0;
width: 100%;
padding-left: 0.5rem !important;
}
:deep(.p-inputgroup-left .p-inputnumber-input) {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
border-right: none;
}
:deep(.p-inputgroup-right .p-inputnumber-input) {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
border-left: 1px solid var(--surface-border);
}
:deep(.p-inputgroup .p-inputnumber:focus-within) {
z-index: 1;
}
/* 검색 필드 스타일 */
:deep(.filter-section) {
position: relative;
}
:deep(.filter-section .searchText) {
width: 100%;
padding-left: 2.5rem !important;
}
:deep(.filter-section .pi-search) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-color-secondary);
z-index: 2;
}
</style>
+609
View File
@@ -0,0 +1,609 @@
<template>
<div class="score-management">
<div class="header">
<div class="meeting-info">
<h2>{{ meetingTitle }}</h2>
<div class="meeting-details">
<span><i class="pi pi-calendar"></i> {{ meetingDate }}</span>
<span><i class="pi pi-map-marker"></i> {{ meetingLocation }}</span>
<span><i class="pi pi-users"></i> 참가자: {{ participants.length }}</span>
</div>
</div>
<div class="actions">
<Button label="저장" icon="pi pi-save" @click="saveAllScores" />
<Button label="내보내기" icon="pi pi-file-excel" class="p-button-success ml-2" @click="exportScores" />
<Button label="뒤로" icon="pi pi-arrow-left" class="p-button-secondary ml-2" @click="goBack" />
</div>
</div>
<div class="card">
<TabView>
<TabPanel header="점수 입력">
<div class="filter-section mb-3">
<div class="grid">
<div class="col-12 md:col-4">
<span class="p-input-icon-left w-full">
<i class="pi pi-search" />
<InputText v-model="filters.global" placeholder="이름 검색" class="w-full" />
</span>
</div>
<div class="col-12 md:col-4">
<Dropdown v-model="filters.team" :options="teamOptions" optionLabel="name"
placeholder="팀 선택" class="w-full" />
</div>
<div class="col-12 md:col-4">
<MultiSelect v-model="selectedGames" :options="gameOptions" optionLabel="name"
placeholder="게임 선택" class="w-full" />
</div>
</div>
</div>
<DataTable :value="filteredParticipants" :paginator="true" :rows="10"
stripedRows responsiveLayout="scroll"
v-model:expandedRows="expandedRows">
<Column :expander="true" headerStyle="width: 3rem" />
<Column field="name" header="이름" sortable style="width: 15%"></Column>
<Column field="teamNumber" header="팀" sortable style="width: 8%"></Column>
<Column field="handicap" header="핸디캡" sortable style="width: 10%">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.handicap"
:min="0" :max="50"
@blur="calculateTotal(slotProps.data)" />
</template>
</Column>
<template v-for="game in gameCount" :key="game">
<Column :field="`game${game}`" :header="`G${game}`" sortable style="width: 8%">
<template #body="slotProps">
<InputNumber v-model="slotProps.data[`game${game}`]"
:min="0" :max="300"
@blur="calculateTotal(slotProps.data)" />
</template>
</Column>
</template>
<Column field="totalPins" header="총점" sortable style="width: 10%"></Column>
<Column field="totalWithHandicap" header="핸디포함" sortable style="width: 10%"></Column>
<Column field="average" header="에버리지" sortable style="width: 10%"></Column>
<template #expansion="slotProps">
<div class="score-history p-3">
<h4>이전 점수 기록</h4>
<DataTable :value="slotProps.data.history" responsiveLayout="scroll">
<Column field="date" header="날짜"></Column>
<Column field="location" header="장소"></Column>
<Column v-for="game in 3" :key="game" :field="`game${game}`" :header="`G${game}`"></Column>
<Column field="average" header="에버리지"></Column>
</DataTable>
</div>
</template>
</DataTable>
</TabPanel>
<TabPanel header="팀별 점수">
<div class="team-scores">
<div class="grid">
<div v-for="team in teamScores" :key="team.teamNumber" class="col-12 md:col-6 lg:col-4">
<div class="team-card">
<div class="team-header">
<h3> {{ team.teamNumber }}</h3>
<div class="team-stats">
<span>인원: {{ team.members.length }}</span>
<span>평균: {{ team.average.toFixed(1) }}</span>
</div>
</div>
<DataTable :value="team.members" responsiveLayout="scroll">
<Column field="name" header="이름"></Column>
<Column v-for="game in gameCount" :key="game" :field="`game${game}`" :header="`G${game}`"></Column>
<Column field="totalWithHandicap" header="총점"></Column>
</DataTable>
<div class="team-totals">
<div class="grid">
<div v-for="game in gameCount" :key="game" class="col">
<div class="game-total">
<span>G{{ game }}: {{ team.gameTotals[game-1] }}</span>
</div>
</div>
<div class="col">
<div class="game-total total">
<span>총점: {{ team.total }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</TabPanel>
<TabPanel header="개인 순위">
<div class="ranking-filters mb-3">
<div class="grid">
<div class="col-12 md:col-4">
<Dropdown v-model="rankingType" :options="rankingOptions" optionLabel="name"
placeholder="순위 유형" class="w-full" />
</div>
<div class="col-12 md:col-4">
<Dropdown v-model="rankingGame" :options="gameRankingOptions" optionLabel="name"
placeholder="게임 선택" class="w-full" />
</div>
</div>
</div>
<DataTable :value="individualRankings" :paginator="true" :rows="10"
stripedRows responsiveLayout="scroll">
<Column field="rank" header="순위" style="width: 8%">
<template #body="slotProps">
<span class="rank-badge" :class="getRankClass(slotProps.data.rank)">
{{ slotProps.data.rank }}
</span>
</template>
</Column>
<Column field="name" header="이름" sortable style="width: 15%"></Column>
<Column field="teamNumber" header="팀" sortable style="width: 8%"></Column>
<Column field="handicap" header="핸디캡" sortable style="width: 10%"></Column>
<Column v-if="rankingGame.code === 'all'"
v-for="game in gameCount" :key="game"
:field="`game${game}`" :header="`G${game}`" sortable style="width: 8%"></Column>
<Column v-if="rankingGame.code !== 'all'"
:field="rankingGame.code" :header="rankingGame.name" sortable style="width: 12%"></Column>
<Column field="totalPins" header="총점" sortable style="width: 10%"></Column>
<Column field="totalWithHandicap" header="핸디포함" sortable style="width: 10%"></Column>
<Column field="average" header="에버리지" sortable style="width: 10%"></Column>
</DataTable>
</TabPanel>
<TabPanel header="팀 순위">
<DataTable :value="teamRankings" :paginator="true" :rows="10"
stripedRows responsiveLayout="scroll">
<Column field="rank" header="순위" style="width: 8%">
<template #body="slotProps">
<span class="rank-badge" :class="getRankClass(slotProps.data.rank)">
{{ slotProps.data.rank }}
</span>
</template>
</Column>
<Column field="teamNumber" header="팀" style="width: 8%"></Column>
<Column field="memberCount" header="인원" style="width: 8%"></Column>
<Column v-for="game in gameCount" :key="game" :field="`game${game}`" :header="`G${game}`" style="width: 10%"></Column>
<Column field="total" header="총점" style="width: 10%"></Column>
<Column field="average" header="평균" style="width: 10%"></Column>
</DataTable>
</TabPanel>
</TabView>
</div>
<!-- 내보내기 다이얼로그 -->
<Dialog v-model:visible="exportDialog" :style="{width: '450px'}"
header="점수 내보내기" :modal="true">
<div class="export-options">
<div class="field">
<label for="exportFormat">파일 형식</label>
<Dropdown id="exportFormat" v-model="exportFormat" :options="exportFormatOptions"
optionLabel="name" placeholder="형식 선택" class="w-full" />
</div>
<div class="field">
<label for="exportContent">내보낼 내용</label>
<MultiSelect id="exportContent" v-model="exportContent" :options="exportContentOptions"
optionLabel="name" placeholder="내용 선택" class="w-full" />
</div>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="exportDialog = false" />
<Button label="내보내기" icon="pi pi-download" @click="downloadExport" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import scoreService from '@/services/scoreService';
import meetingService from '@/services/meetingService';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const clubId = ref(localStorage.getItem('clubId') || null);
// 모임 정보
const meetingId = ref(route.query.meetingId || null);
const meetingTitle = ref('');
const meetingDate = ref('');
const meetingLocation = ref('');
// 점수 관련 상태
const participants = ref([]);
const expandedRows = ref([]);
const gameCount = ref(3);
const loading = ref(false);
// 필터 관련 상태
const filters = reactive({
global: '',
team: null
});
// 팀 옵션
const teamOptions = ref([
{ name: '전체 팀', code: null }
]);
// 게임 옵션
const gameOptions = computed(() => {
return Array.from({ length: gameCount.value }, (_, i) => ({
name: `게임 ${i + 1}`,
code: `game${i + 1}`
}));
});
const selectedGames = ref([]);
// 순위 관련 상태
const rankingType = ref({ name: '핸디포함', code: 'handicap' });
const rankingOptions = ref([
{ name: '스크래치', code: 'scratch' },
{ name: '핸디포함', code: 'handicap' }
]);
const rankingGame = ref({ name: '전체', code: 'all' });
const gameRankingOptions = computed(() => {
const options = [{ name: '전체', code: 'all' }];
for (let i = 1; i <= gameCount.value; i++) {
options.push({ name: `게임 ${i}`, code: `game${i}` });
}
return options;
});
// 내보내기 관련 상태
const exportDialog = ref(false);
const exportFormat = ref({ name: 'Excel', code: 'xlsx' });
const exportFormatOptions = ref([
{ name: 'Excel', code: 'xlsx' },
{ name: 'CSV', code: 'csv' },
{ name: 'PDF', code: 'pdf' }
]);
const exportContent = ref([]);
const exportContentOptions = ref([
{ name: '개인 점수', code: 'individual' },
{ name: '팀별 점수', code: 'team' },
{ name: '개인 순위', code: 'individual_rank' },
{ name: '팀 순위', code: 'team_rank' }
]);
// 필터링된 참가자 목록
const filteredParticipants = computed(() => {
if (!participants.value) return [];
return participants.value.filter(participant => {
// 이름 필터
if (filters.global && !participant.name.toLowerCase().includes(filters.global.toLowerCase())) {
return false;
}
// 팀 필터
if (filters.team && participant.teamNumber !== filters.team.code) {
return false;
}
return true;
});
});
// 팀별 점수
const teamScores = computed(() => {
const teams = {};
// 팀별로 참가자 그룹화
participants.value.forEach(participant => {
const teamNumber = participant.teamNumber;
if (!teams[teamNumber]) {
teams[teamNumber] = {
teamNumber,
members: [],
gameTotals: Array(gameCount.value).fill(0),
total: 0,
average: 0
};
}
teams[teamNumber].members.push(participant);
// 게임별 팀 총점 계산
for (let i = 1; i <= gameCount.value; i++) {
const gameScore = participant[`game${i}`];
const handicap = participant.handicap || 0;
teams[teamNumber].gameTotals[i-1] += (gameScore + handicap);
}
// 팀 총점 계산
teams[teamNumber].total += participant.totalWithHandicap || 0;
});
// 팀 평균 계산
Object.values(teams).forEach(team => {
if (team.members.length > 0) {
team.average = team.total / (team.members.length * gameCount.value);
}
});
return Object.values(teams).sort((a, b) => a.teamNumber - b.teamNumber);
});
// 개인 순위
const individualRankings = computed(() => {
if (!participants.value.length) return [];
const field = rankingType.value.code === 'handicap' ? 'totalWithHandicap' : 'totalPins';
const gameField = rankingGame.value.code;
let sortedParticipants = [...participants.value];
if (gameField === 'all') {
// 전체 게임 기준 정렬
sortedParticipants.sort((a, b) => b[field] - a[field]);
} else {
// 특정 게임 기준 정렬
sortedParticipants.sort((a, b) => {
const scoreA = (a[gameField] || 0) + (rankingType.value.code === 'handicap' ? (a.handicap || 0) : 0);
const scoreB = (b[gameField] || 0) + (rankingType.value.code === 'handicap' ? (b.handicap || 0) : 0);
return scoreB - scoreA;
});
}
// 순위 부여
return sortedParticipants.map((participant, index) => ({
...participant,
rank: index + 1
}));
});
// 팀 순위
const teamRankings = computed(() => {
if (!teamScores.value.length) return [];
const rankedTeams = teamScores.value.map(team => ({
...team,
memberCount: team.members.length,
game1: team.gameTotals[0],
game2: team.gameTotals[1],
game3: team.gameTotals[2]
}));
// 총점 기준 정렬
rankedTeams.sort((a, b) => b.total - a.total);
// 순위 부여
return rankedTeams.map((team, index) => ({
...team,
rank: index + 1
}));
});
// 순위 클래스 반환
const getRankClass = (rank) => {
if (rank === 1) return 'rank-first';
if (rank === 2) return 'rank-second';
if (rank === 3) return 'rank-third';
return '';
};
// 총점 및 에버리지 계산
const calculateTotal = (participant) => {
let totalPins = 0;
let validGames = 0;
// 총점 계산
for (let i = 1; i <= gameCount.value; i++) {
const score = participant[`game${i}`];
if (score && score > 0) {
totalPins += score;
validGames++;
}
}
participant.totalPins = totalPins;
participant.totalWithHandicap = totalPins + (participant.handicap * validGames);
participant.average = validGames > 0 ? (totalPins / validGames).toFixed(1) : 0;
};
// 모든 점수 저장
const saveAllScores = async () => {
try {
loading.value = true;
await scoreService.saveScores(meetingId.value, participants.value);
toast.add({ severity: 'success', summary: '성공', detail: '점수가 성공적으로 저장되었습니다.', life: 3000 });
} catch (error) {
console.error('점수 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '점수 저장 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
// 점수 내보내기 다이얼로그 열기
const exportScores = () => {
exportContent.value = [exportContentOptions.value[0]];
exportDialog.value = true;
};
// 점수 내보내기 다운로드
const downloadExport = async () => {
try {
const format = exportFormat.value.code;
const content = exportContent.value.map(item => item.code);
await scoreService.exportScores(meetingId.value, format, content);
exportDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '점수가 성공적으로 내보내졌습니다.', life: 3000 });
} catch (error) {
console.error('점수 내보내기 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '점수 내보내기 중 오류가 발생했습니다.', life: 3000 });
}
};
// 뒤로 가기
const goBack = () => {
router.push('/clubs/meetings');
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
if (!clubId.value) {
toast.add({ severity: 'error', summary: '오류', detail: '클럽 ID가 설정되지 않았습니다.', life: 3000 });
return;
}
if (!meetingId.value) {
toast.add({ severity: 'error', summary: '오류', detail: '모임 ID가 설정되지 않았습니다.', life: 3000 });
router.push('/clubs/meetings');
return;
}
loading.value = true;
try {
// 모임 정보 가져오기
const meetingResponse = await meetingService.getMeetingById(meetingId.value);
const meeting = meetingResponse.data;
meetingTitle.value = meeting.title;
meetingDate.value = new Date(meeting.date).toLocaleDateString();
meetingLocation.value = meeting.location;
gameCount.value = meeting.gameCount || 3;
// 점수 데이터 가져오기
const scoresResponse = await scoreService.getMeetingScores(meetingId.value);
participants.value = scoresResponse.data;
// 팀 옵션 설정
const teams = new Set(participants.value.map(p => p.teamNumber));
teamOptions.value = [
{ name: '전체 팀', code: null },
...Array.from(teams).map(team => ({ name: `${team}`, code: team }))
];
// 각 참가자의 총점 및 에버리지 계산
participants.value.forEach(participant => {
calculateTotal(participant);
});
} catch (error) {
console.error('데이터 로드 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '데이터를 로드하는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.score-management {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.meeting-info h2 {
margin: 0 0 0.5rem 0;
}
.meeting-details {
display: flex;
gap: 1rem;
color: #666;
}
.meeting-details i {
margin-right: 0.25rem;
}
.filter-section {
margin-bottom: 1rem;
}
.score-history h4 {
margin-top: 0;
margin-bottom: 1rem;
}
.team-card {
background-color: #f8f9fa;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.team-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.team-header h3 {
margin: 0;
}
.team-stats {
display: flex;
gap: 1rem;
font-size: 0.9rem;
}
.team-totals {
margin-top: 1rem;
padding-top: 0.5rem;
border-top: 1px solid #ddd;
}
.game-total {
text-align: center;
font-weight: bold;
padding: 0.5rem;
background-color: #f0f0f0;
border-radius: 4px;
}
.game-total.total {
background-color: #e6f7ff;
}
.rank-badge {
display: inline-block;
width: 2rem;
height: 2rem;
line-height: 2rem;
text-align: center;
border-radius: 50%;
font-weight: bold;
}
.rank-first {
background-color: #ffd700;
color: #333;
}
.rank-second {
background-color: #c0c0c0;
color: #333;
}
.rank-third {
background-color: #cd7f32;
color: #fff;
}
.export-options {
padding: 1rem 0;
}
</style>
+338
View File
@@ -0,0 +1,338 @@
<template>
<div class="statistics-container">
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>통계 데이터를 불러오는 ...</p>
</div>
<div v-else>
<div class="period-selector">
<select v-model="selectedPeriod" @change="loadStatistics">
<option value="30">최근 30</option>
<option value="90">최근 90</option>
<option value="180">최근 180</option>
<option value="365">최근 1</option>
</select>
</div>
<div class="stats-summary">
<div class="stat-card">
<h3>회원 현황</h3>
<p>전체 회원: {{ stats.summary.totalMembers }}</p>
<p>활동 회원: {{ stats.summary.activeMembers }}</p>
<p>평균 참석률: {{ stats.summary.averageAttendance }}%</p>
</div>
<div class="stat-card">
<h3>점수 현황</h3>
<p>최고 점수: {{ stats.summary.highestGame }}</p>
<p>평균 점수: {{ stats.summary.averageScore }}</p>
<p> 게임 : {{ stats.summary.totalGames }}게임</p>
</div>
<div class="stat-card">
<h3>모임 현황</h3>
<p> 모임 : {{ stats.summary.totalMeetings }}</p>
<p>평균 참가자: {{ stats.summary.averageParticipants }}</p>
<p>모임당 게임 : {{ stats.summary.averageGamesPerMeeting }}게임</p>
</div>
</div>
<div class="charts-container">
<div class="chart-card">
<h3>점수 분포</h3>
<BarChart
:data="scoreDistributionChartData"
:options="scoreDistributionChartOptions"
/>
</div>
<div class="chart-card">
<h3>참석률 추이</h3>
<LineChart
:data="attendanceTrendChartData"
:options="attendanceTrendChartOptions"
/>
</div>
</div>
<div class="rankings-container">
<div class="rankings-card">
<h3>회원 순위</h3>
<table>
<thead>
<tr>
<th>순위</th>
<th>이름</th>
<th>평균</th>
<th>게임 </th>
<th>핸디캡</th>
<th>참석률</th>
</tr>
</thead>
<tbody>
<tr v-for="(member, index) in stats.memberRankings" :key="member.id">
<td>{{ index + 1 }}</td>
<td>{{ member.name }}</td>
<td>{{ member.average }}</td>
<td>{{ member.games }}</td>
<td>{{ member.handicap }}</td>
<td>{{ member.attendance }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="meetings-container">
<div class="meetings-card">
<h3>모임별 통계</h3>
<table>
<thead>
<tr>
<th>날짜</th>
<th>제목</th>
<th>참가자</th>
<th>게임 </th>
<th>평균 점수</th>
</tr>
</thead>
<tbody>
<tr v-for="meeting in stats.meetingStats" :key="meeting.date">
<td>{{ formatDate(meeting.date) }}</td>
<td>{{ meeting.title }}</td>
<td>{{ meeting.participants }}</td>
<td>{{ meeting.games }}게임</td>
<td>{{ meeting.averageScore }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue';
import { Bar as BarChart, Line as LineChart } from 'vue-chartjs';
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend } from 'chart.js';
import apiClient from '@/services/api';
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend);
export default {
name: 'Statistics',
components: {
BarChart,
LineChart
},
setup() {
const stats = ref({
summary: {},
scoreDistribution: [],
attendanceTrend: [],
memberRankings: [],
meetingStats: []
});
const selectedPeriod = ref('90');
const loading = ref(false);
const loadStatistics = async () => {
loading.value = true;
try {
const clubId = localStorage.getItem('clubId');
const response = await apiClient.post('/api/club/statistics', {
clubId,
days: parseInt(selectedPeriod.value)
});
stats.value = response.data;
} catch (error) {
console.error('통계 데이터 로드 오류:', error);
} finally {
loading.value = false;
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const scoreDistributionChartData = computed(() => ({
labels: stats.value.scoreDistribution?.map(d => d.range) || [],
datasets: [{
label: '게임 수',
data: stats.value.scoreDistribution?.map(d => d.count) || [],
backgroundColor: '#4CAF50'
}]
}));
const scoreDistributionChartOptions = {
responsive: true,
plugins: {
legend: {
display: false
},
title: {
display: false
}
}
};
const attendanceTrendChartData = computed(() => ({
labels: stats.value.attendanceTrend?.map(d => formatDate(d.date)) || [],
datasets: [{
label: '참석률',
data: stats.value.attendanceTrend?.map(d => d.attendance) || [],
borderColor: '#2196F3',
tension: 0.1
}]
}));
const attendanceTrendChartOptions = {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100
}
}
};
onMounted(() => {
loadStatistics();
});
return {
stats,
selectedPeriod,
loading,
loadStatistics,
formatDate,
scoreDistributionChartData,
scoreDistributionChartOptions,
attendanceTrendChartData,
attendanceTrendChartOptions
};
}
};
</script>
<style scoped>
.statistics-container {
padding: 20px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.period-selector {
margin-bottom: 20px;
}
.period-selector select {
padding: 8px;
border-radius: 4px;
border: 1px solid #ddd;
}
.stats-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card h3 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.chart-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-card h3 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
}
.rankings-container,
.meetings-container {
margin-bottom: 30px;
}
.rankings-card,
.meetings-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
font-weight: 600;
}
tr:hover {
background-color: #f9f9f9;
}
</style>
@@ -0,0 +1,398 @@
<template>
<div class="subscription-management">
<h2>구독/결제 관리</h2>
<!-- 현재 구독 상태 -->
<div v-if="currentSubscription" class="current-subscription">
<h3>현재 구독 정보</h3>
<div class="subscription-info">
<p><strong>플랜:</strong> {{ currentSubscription.planName }}</p>
<p><strong>상태:</strong> {{ currentSubscription.status }}</p>
<p><strong>시작일:</strong> {{ formatDate(currentSubscription.startDate) }}</p>
<p><strong>만료일:</strong> {{ formatDate(currentSubscription.endDate) }}</p>
</div>
</div>
<!-- 구독 플랜 목록 -->
<div class="subscription-plans">
<h3>이용 가능한 플랜</h3>
<div class="plans-grid">
<div v-for="plan in plans" :key="plan.id" class="plan-card">
<h4>{{ plan.name }}</h4>
<p class="price"> {{ formatPrice(plan.price) }}</p>
<ul class="features">
<li v-for="feature in plan.features" :key="feature.id">
{{ feature.name }}
</li>
</ul>
<button
class="subscribe-btn"
:disabled="isCurrentPlan(plan.id)"
@click="subscribeToPlan(plan.id)">
{{ isCurrentPlan(plan.id) ? '현재 이용중' : '구독하기' }}
</button>
</div>
</div>
</div>
<!-- 기능 구매 섹션 -->
<div class="features-section">
<h3>추가 기능</h3>
<div class="features-grid">
<div v-for="feature in availableFeatures" :key="feature.id" class="feature-card">
<div class="feature-header">
<h4>{{ feature.name }}</h4>
<span class="feature-status" :class="{ 'active': isFeatureActive(feature.id) }">
{{ isFeatureActive(feature.id) ? '사용중' : '미사용' }}
</span>
</div>
<p class="description">{{ feature.description }}</p>
<p class="price">{{ formatPrice(feature.price) }}</p>
<div class="duration-select" v-if="!isFeatureActive(feature.id)">
<select v-model="feature.selectedDuration">
<option value="1">1개월</option>
<option value="3">3개월</option>
<option value="6">6개월</option>
<option value="12">12개월</option>
</select>
</div>
<button
class="feature-btn"
:disabled="isFeatureActive(feature.id)"
@click="purchaseFeature(feature)">
{{ isFeatureActive(feature.id) ? '이용중' : '구매하기' }}
</button>
</div>
</div>
</div>
<!-- 기능 구매 내역 -->
<div class="feature-history">
<h3>기능 구매 내역</h3>
<table v-if="featurePayments.length > 0">
<thead>
<tr>
<th>구매일</th>
<th>기능</th>
<th>기간</th>
<th>금액</th>
<th>만료일</th>
<th>상태</th>
</tr>
</thead>
<tbody>
<tr v-for="payment in featurePayments" :key="payment.id">
<td>{{ formatDate(payment.purchaseDate) }}</td>
<td>{{ payment.featureName }}</td>
<td>{{ payment.duration }}개월</td>
<td>{{ formatPrice(payment.amount) }}</td>
<td>{{ formatDate(payment.expiryDate) }}</td>
<td>{{ payment.status }}</td>
</tr>
</tbody>
</table>
<p v-else>기능 구매 내역이 없습니다.</p>
</div>
<!-- 결제 내역 -->
<div class="payment-history">
<h3>결제 내역</h3>
<table v-if="payments.length > 0">
<thead>
<tr>
<th>결제일</th>
<th>플랜</th>
<th>금액</th>
<th>상태</th>
</tr>
</thead>
<tbody>
<tr v-for="payment in payments" :key="payment.id">
<td>{{ formatDate(payment.paymentDate) }}</td>
<td>{{ payment.planName }}</td>
<td>{{ formatPrice(payment.amount) }}</td>
<td>{{ payment.status }}</td>
</tr>
</tbody>
</table>
<p v-else>결제 내역이 없습니다.</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import apiClient from '@/services/api';
const router = useRouter();
const currentSubscription = ref(null);
const plans = ref([]);
const payments = ref([]);
const availableFeatures = ref([]);
const featurePayments = ref([]);
const activeFeatures = ref([]);
// 날짜 포맷팅
const formatDate = (date) => {
return new Date(date).toLocaleDateString('ko-KR');
};
// 가격 포맷팅
const formatPrice = (price) => {
return price.toLocaleString('ko-KR');
};
// 현재 구독중인 플랜인지 확인
const isCurrentPlan = (planId) => {
return currentSubscription.value?.planId === planId;
};
// 기능이 활성화되어 있는지 확인
const isFeatureActive = (featureId) => {
return activeFeatures.value.some(f =>
f.featureId === featureId && new Date(f.expiryDate) > new Date()
);
};
// 현재 구독 정보 로드
const loadCurrentSubscription = async () => {
try {
const response = await apiClient.get('/api/subscriptions/current');
currentSubscription.value = response.data;
} catch (error) {
console.error('구독 정보 로드 실패:', error);
}
};
// 구독 플랜 목록 로드
const loadPlans = async () => {
try {
const response = await apiClient.get('/api/subscriptions/plans');
plans.value = response.data;
} catch (error) {
console.error('플랜 목록 로드 실패:', error);
}
};
// 결제 내역 로드
const loadPayments = async () => {
try {
const response = await apiClient.get('/api/subscriptions/payments');
payments.value = response.data;
} catch (error) {
console.error('결제 내역 로드 실패:', error);
}
};
// 사용 가능한 기능 목록 로드
const loadAvailableFeatures = async () => {
try {
const response = await apiClient.get('/api/features/available');
availableFeatures.value = response.data.map(feature => ({
...feature,
selectedDuration: 1
}));
} catch (error) {
console.error('기능 목록 로드 실패:', error);
}
};
// 활성화된 기능 목록 로드
const loadActiveFeatures = async () => {
try {
const response = await apiClient.get('/api/features/active');
activeFeatures.value = response.data;
} catch (error) {
console.error('활성화된 기능 로드 실패:', error);
}
};
// 기능 구매 내역 로드
const loadFeaturePayments = async () => {
try {
const response = await apiClient.get('/api/features/payments');
featurePayments.value = response.data;
} catch (error) {
console.error('기능 구매 내역 로드 실패:', error);
}
};
// 플랜 구독
const subscribeToPlan = async (planId) => {
try {
await apiClient.post('/api/subscriptions/subscribe', { planId });
await loadCurrentSubscription();
// TODO: 결제 프로세스 구현
} catch (error) {
console.error('구독 신청 실패:', error);
}
};
// 기능 구매
const purchaseFeature = async (feature) => {
try {
await apiClient.post('/api/features/purchase', {
featureId: feature.id,
duration: feature.selectedDuration
});
await Promise.all([
loadActiveFeatures(),
loadFeaturePayments()
]);
} catch (error) {
console.error('기능 구매 실패:', error);
}
};
onMounted(async () => {
await Promise.all([
loadCurrentSubscription(),
loadPlans(),
loadPayments(),
loadAvailableFeatures(),
loadActiveFeatures(),
loadFeaturePayments()
]);
});
</script>
<style scoped>
.subscription-management {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.current-subscription {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.plans-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin: 20px 0;
}
.plan-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.plan-card .price {
font-size: 1.5em;
color: #0d6efd;
margin: 15px 0;
}
.features {
list-style: none;
padding: 0;
margin: 20px 0;
}
.features li {
margin: 10px 0;
}
.subscribe-btn {
background: #0d6efd;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
.subscribe-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 20px 0;
}
.feature-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
}
.feature-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.feature-status {
font-size: 0.9em;
padding: 4px 8px;
border-radius: 4px;
background: #e9ecef;
}
.feature-status.active {
background: #198754;
color: white;
}
.description {
color: #6c757d;
margin: 10px 0;
}
.duration-select {
margin: 10px 0;
}
.duration-select select {
width: 100%;
padding: 8px;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.feature-btn {
width: 100%;
background: #0d6efd;
color: white;
border: none;
padding: 10px;
border-radius: 4px;
cursor: pointer;
}
.feature-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
th {
background: #f8f9fa;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
server: {
host: '0.0.0.0',
port: 5173,
//strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:3030',
changeOrigin: true
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})