init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.history
|
||||
.env
|
||||
.windsurfrules
|
||||
config.js
|
||||
backend/node_modules/*
|
||||
frontend/node_modules/*
|
||||
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const cors = require('cors');
|
||||
const http = require('http');
|
||||
const socketIo = require('socket.io');
|
||||
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const eventRoutes = require('./routes/eventRoutes');
|
||||
const clubRoutes = require('./routes/clubRoutes');
|
||||
const userRoutes = require('./routes/userRoutes');
|
||||
const notificationRoutes = require('./routes/notificationRoutes');
|
||||
const menuRoutes = require('./routes/menuRoutes');
|
||||
const subscriptionRoutes = require('./routes/subscriptionRoutes');
|
||||
const featureRoutes = require('./routes/featureRoutes');
|
||||
const { sequelize, syncModels } = require('./models');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = socketIo(server, {
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST']
|
||||
}
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// Socket.IO 연결 처리
|
||||
io.on('connection', (socket) => {
|
||||
console.log('새로운 클라이언트 연결:', socket.id);
|
||||
|
||||
// 사용자 인증 및 알림 룸 참여
|
||||
socket.on('authenticate', (userData) => {
|
||||
if (userData && userData.id) {
|
||||
socket.join(`user:${userData.id}`);
|
||||
console.log(`사용자 ${userData.id}가 알림 룸에 참여했습니다.`);
|
||||
}
|
||||
});
|
||||
|
||||
// 연결 해제 처리
|
||||
socket.on('disconnect', () => {
|
||||
console.log('클라이언트 연결 해제:', socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// 전역 Socket.IO 인스턴스 설정
|
||||
app.set('io', io);
|
||||
|
||||
// 데이터베이스 연결 및 모델 동기화 실행
|
||||
sequelize.authenticate()
|
||||
.then(() => {
|
||||
console.log('데이터베이스에 연결되었습니다.');
|
||||
return syncModels();
|
||||
})
|
||||
.then(() => {
|
||||
console.log('데이터베이스 모델이 동기화되었습니다.');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('데이터베이스 연결 또는 모델 동기화 오류:', err);
|
||||
});
|
||||
|
||||
// Routes 설정
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/club', clubRoutes);
|
||||
app.use('/api/club/events', eventRoutes);
|
||||
app.use('/api/features', featureRoutes);
|
||||
app.use('/api/subscriptions', subscriptionRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/menus', menuRoutes);
|
||||
|
||||
// 루트 경로 핸들러
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ message: '볼링 클럽 매니저 API 서버에 오신 것을 환영합니다!' });
|
||||
});
|
||||
|
||||
// 서버 시작
|
||||
const PORT = process.env.PORT || 3030;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,40 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
require('dotenv').config();
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME,
|
||||
process.env.DB_USER,
|
||||
process.env.DB_PASSWORD,
|
||||
{
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
dialect: 'mysql',
|
||||
logging: true, // 개발 중에는 true로 설정하여 SQL 쿼리 로그 확인 가능
|
||||
pool: {
|
||||
max: 5,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
},
|
||||
define: {
|
||||
charset: 'utf8mb4',
|
||||
collate: 'utf8mb4_unicode_ci',
|
||||
timestamps: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 데이터베이스 연결 테스트
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('데이터베이스 연결 성공!');
|
||||
} catch (error) {
|
||||
console.error('데이터베이스 연결 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
testConnection
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 인증 컨트롤러
|
||||
* 사용자 인증 관련 기능을 처리하는 컨트롤러
|
||||
*/
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { User, Member } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
require('dotenv').config();
|
||||
|
||||
// 로그인
|
||||
exports.login = async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// 이메일 필수 확인
|
||||
if (!email) {
|
||||
return res.status(400).json({ message: '이메일은 필수 항목입니다.' });
|
||||
}
|
||||
|
||||
// 사용자 조회
|
||||
const user = await User.findOne({ where: { email } });
|
||||
|
||||
// 사용자가 존재하지 않는 경우
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
// 비밀번호 검증
|
||||
const isPasswordValid = await user.validatePassword(password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({ message: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
// 마지막 로그인 시간 업데이트
|
||||
await user.update({ lastLogin: new Date() });
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
name: user.name
|
||||
},
|
||||
process.env.JWT_SECRET || 'your-secret-key',
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '1d' }
|
||||
);
|
||||
|
||||
// 모임장/운영진으로 참여 중인 클럽 ID 조회
|
||||
const clubMembers = await Member.findAll({
|
||||
where: {
|
||||
userId: user.id,
|
||||
memberType: {
|
||||
[Op.in]: ['모임장', '운영진']
|
||||
}
|
||||
},
|
||||
attributes: ['clubId']
|
||||
});
|
||||
// console.log(`clubMembers: ${JSON.stringify(clubMembers)}`);
|
||||
|
||||
// 모임장/운영진 클럽 ID 배열 생성
|
||||
const clubManagerIds = clubMembers.map(cm => cm.clubId);
|
||||
let clubId = null;
|
||||
if(clubManagerIds.length > 0) {
|
||||
clubId = clubManagerIds[0];
|
||||
}
|
||||
|
||||
// 사용자 정보에서 비밀번호 제외
|
||||
const userData = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
clubId: clubId
|
||||
};
|
||||
// console.log(`userData: ${JSON.stringify(userData)}`);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: userData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('로그인 오류:', error);
|
||||
res.status(500).json({ message: '로그인 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 회원가입
|
||||
exports.register = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, role, status } = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ message: '이름, 이메일, 비밀번호는 필수 항목입니다.' });
|
||||
}
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingUser = await User.findOne({ where: { email } });
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ message: '이미 등록된 이메일입니다.' });
|
||||
}
|
||||
|
||||
// 사용자 생성
|
||||
const newUser = await User.create({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
username: email,
|
||||
role: role || 'user',
|
||||
status: status || 'active'
|
||||
});
|
||||
|
||||
// 민감한 정보 제외
|
||||
const userResponse = {
|
||||
id: newUser.id,
|
||||
name: newUser.name,
|
||||
email: newUser.email,
|
||||
role: newUser.role,
|
||||
status: newUser.status,
|
||||
createdAt: newUser.createdAt
|
||||
};
|
||||
|
||||
res.status(201).json(userResponse);
|
||||
} catch (error) {
|
||||
console.error('회원가입 오류:', error);
|
||||
res.status(500).json({ message: '회원가입 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
exports.getCurrentUser = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: { exclude: ['password'] }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 조회 오류:', error);
|
||||
res.status(500).json({ message: '사용자 정보를 조회하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 프로필 업데이트
|
||||
exports.updateProfile = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { name, email, password, currentPassword } = req.body;
|
||||
|
||||
// 사용자 조회
|
||||
const user = await User.findByPk(userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 비밀번호 변경이 요청된 경우
|
||||
if (password && currentPassword) {
|
||||
// 현재 비밀번호 검증
|
||||
const isPasswordValid = await user.validatePassword(currentPassword);
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({ message: '현재 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 이메일이 변경되는 경우
|
||||
if (email && email !== user.email) {
|
||||
// Users 테이블에서 이메일 중복 확인
|
||||
const existingUser = await User.findOne({ where: { email } });
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ message: '이미 사용 중인 이메일입니다.' });
|
||||
}
|
||||
|
||||
// Members 테이블에서 이메일 중복 확인
|
||||
const existingMember = await Member.findOne({ where: { email } });
|
||||
if (existingMember) {
|
||||
return res.status(400).json({ message: '이미 클럽 회원이 사용 중인 이메일입니다.' });
|
||||
}
|
||||
|
||||
// 해당 사용자의 Members 테이블 레코드도 함께 업데이트
|
||||
await Member.update(
|
||||
{ email },
|
||||
{ where: { userId } }
|
||||
);
|
||||
}
|
||||
|
||||
// 프로필 업데이트
|
||||
const updateData = {
|
||||
name: name || user.name,
|
||||
email: email || user.email,
|
||||
username: email || user.email // username도 이메일과 동일하게 업데이트
|
||||
};
|
||||
|
||||
// 비밀번호가 제공된 경우에만 업데이트
|
||||
if (password && currentPassword) {
|
||||
updateData.password = password;
|
||||
}
|
||||
|
||||
await user.update(updateData);
|
||||
|
||||
// 업데이트된 사용자 정보 반환 (비밀번호 제외)
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: { exclude: ['password'] }
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
console.error('프로필 업데이트 오류:', error);
|
||||
res.status(500).json({ message: '프로필 업데이트 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,925 @@
|
||||
/**
|
||||
* 클럽 컨트롤러
|
||||
* 클럽 관련 기능을 처리하는 컨트롤러
|
||||
*/
|
||||
const Club = require('../models/Club');
|
||||
const User = require('../models/User');
|
||||
const Member = require('../models/Member');
|
||||
const ClubFeature = require('../models/ClubFeature');
|
||||
const Event = require('../models/Event');
|
||||
const EventParticipant = require('../models/EventParticipant');
|
||||
const EventScore = require('../models/EventScore');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// 관리자 권한 체크 함수
|
||||
const isAdmin = (user) => {
|
||||
return user && (user.role === 'admin' || user.role === 'superadmin');
|
||||
};
|
||||
|
||||
// 모든 클럽 조회
|
||||
exports.getAllClubs = async (req, res) => {
|
||||
try {
|
||||
const clubs = await Club.findAll({
|
||||
where: { status: 'active' },
|
||||
include: [
|
||||
{
|
||||
model: Member,
|
||||
where: { memberType: '모임장' },
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'email', 'phone', 'memberType']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
const formattedClubs = clubs.map(club => {
|
||||
const clubData = club.toJSON();
|
||||
// 모임장 정보 추가
|
||||
const ownerMember = clubData.Members && clubData.Members.length > 0 ? clubData.Members[0] : null;
|
||||
|
||||
return {
|
||||
...clubData,
|
||||
ownerName: ownerMember ? ownerMember.name : '알 수 없음',
|
||||
ownerEmail: ownerMember ? ownerMember.email : '',
|
||||
ownerPhone: ownerMember ? ownerMember.phone : '',
|
||||
ownerMemberType: ownerMember ? ownerMember.memberType : ''
|
||||
};
|
||||
});
|
||||
|
||||
res.json(formattedClubs);
|
||||
} catch (error) {
|
||||
console.error('클럽 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '클럽 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 클럽 조회
|
||||
exports.getClubById = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
console.log(`clubId: ${clubId}`);
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: "유효하지 않은 클럽 ID입니다." });
|
||||
}
|
||||
|
||||
const club = await Club.findByPk(clubId);
|
||||
// console.log(`club: ${club}`);
|
||||
if (club) {
|
||||
// 클럽 소유자 정보 조회
|
||||
const owner = await Member.findOne({where: {userId: club.ownerId}}, {
|
||||
attributes: ['name', 'email', 'memberType']
|
||||
});
|
||||
|
||||
const clubData = club.toJSON();
|
||||
const formattedClub = {
|
||||
...clubData,
|
||||
ownerEmail: owner ? owner.email : '',
|
||||
ownerName: owner ? owner.name : '',
|
||||
ownerMemberType: owner ? owner.memberType : ''
|
||||
};
|
||||
|
||||
res.json(formattedClub);
|
||||
} else {
|
||||
res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('클럽 조회 오류:', error);
|
||||
res.status(500).json({ message: '클럽 정보를 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 새 클럽 생성
|
||||
exports.createClub = async (req, res) => {
|
||||
try {
|
||||
const { name, location, description, ownerEmail, contact, features, sendInvite } = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!name || !ownerEmail) {
|
||||
return res.status(400).json({ message: '클럽 이름과 모임장 이메일은 필수 항목입니다.' });
|
||||
}
|
||||
|
||||
// 이메일로 사용자 조회
|
||||
const owner = await User.findOne({ where: { email: ownerEmail } });
|
||||
|
||||
if (!owner) {
|
||||
return res.status(404).json({ message: '해당 이메일로 등록된 사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 클럽 생성
|
||||
const newClub = await Club.create({
|
||||
name,
|
||||
location,
|
||||
description,
|
||||
contact,
|
||||
ownerId: owner.id,
|
||||
memberCount: 1, // 모임장 포함
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 클럽 모임장을 클럽 멤버로 추가
|
||||
await Member.create({
|
||||
name: owner.name,
|
||||
gender: '남성',
|
||||
userId: owner.id,
|
||||
clubId: newClub.id,
|
||||
memberType: '모임장',
|
||||
joinDate: new Date(),
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 선택된 기능 추가
|
||||
if (features && features.length > 0) {
|
||||
const featurePromises = features.map(featureId =>
|
||||
ClubFeature.create({
|
||||
clubId: newClub.id,
|
||||
featureId,
|
||||
enabled: true
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(featurePromises);
|
||||
}
|
||||
|
||||
// 초대 이메일 발송 로직 (sendInvite가 true인 경우)
|
||||
if (sendInvite) {
|
||||
// TODO: 초대 이메일 발송 로직 구현
|
||||
console.log(`${ownerEmail}에게 초대 이메일 발송 예정`);
|
||||
}
|
||||
|
||||
res.status(201).json(newClub);
|
||||
} catch (error) {
|
||||
console.error('클럽 생성 오류:', error);
|
||||
res.status(500).json({ message: '클럽을 생성하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 정보 업데이트
|
||||
exports.updateClub = async (req, res) => {
|
||||
try {
|
||||
const { clubId, name, location, description, contact, ownerEmail, status } = req.body;
|
||||
|
||||
const club = await Club.findByPk(clubId);
|
||||
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 이메일이 변경된 경우 새 모임장 확인
|
||||
let newOwnerId = club.ownerId;
|
||||
let ownerChanged = false;
|
||||
|
||||
if (ownerEmail && ownerEmail !== club.ownerEmail) {
|
||||
const newOwner = await User.findOne({ where: { email: ownerEmail } });
|
||||
if (!newOwner) {
|
||||
return res.status(404).json({ message: '해당 이메일로 등록된 사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
newOwnerId = newOwner.id;
|
||||
ownerChanged = true;
|
||||
}
|
||||
|
||||
// 클럽 정보 업데이트
|
||||
await club.update({
|
||||
name: name || club.name,
|
||||
location: location || club.location,
|
||||
description: description !== undefined ? description : club.description,
|
||||
contact: contact !== undefined ? contact : club.contact,
|
||||
status: status || club.status,
|
||||
ownerId: newOwnerId
|
||||
});
|
||||
|
||||
// 모임장이 변경된 경우 Members 테이블 업데이트
|
||||
if (ownerChanged) {
|
||||
// 기존 모임장을 정회원으로 변경
|
||||
await Member.update(
|
||||
{ memberType: '정회원' },
|
||||
{
|
||||
where: {
|
||||
clubId: clubId,
|
||||
userId: club.ownerId,
|
||||
memberType: '모임장'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 새 모임장 확인 및 업데이트
|
||||
const newOwnerMember = await Member.findOne({
|
||||
where: {
|
||||
clubId: clubId,
|
||||
userId: newOwnerId
|
||||
}
|
||||
});
|
||||
|
||||
if (newOwnerMember) {
|
||||
// 기존 회원인 경우 모임장으로 변경
|
||||
await newOwnerMember.update({ memberType: '모임장' });
|
||||
} else {
|
||||
// 새로운 사용자 정보 가져오기
|
||||
const newOwnerUser = await User.findByPk(newOwnerId);
|
||||
if (!newOwnerUser) {
|
||||
return res.status(404).json({ message: '새 모임장 사용자 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 새 회원으로 모임장 추가
|
||||
await Member.create({
|
||||
userId: newOwnerId,
|
||||
clubId: clubId,
|
||||
name: newOwnerUser.name || ownerEmail.split('@')[0], // 이름이 없는 경우 이메일 아이디 사용
|
||||
gender: '남성', // 성별 정보가 없는 경우 기본값
|
||||
memberType: '모임장',
|
||||
joinDate: new Date(),
|
||||
status: 'active'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(club);
|
||||
} catch (error) {
|
||||
console.error('클럽 정보 업데이트 오류:', error);
|
||||
res.status(500).json({ message: '클럽 정보를 업데이트하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 삭제
|
||||
exports.deleteClub = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
// 클럽 존재 여부 확인
|
||||
const club = await Club.findByPk(clubId);
|
||||
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 소프트 삭제 (상태만 변경)
|
||||
await club.update({ status: 'inactive' });
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('클럽 삭제 오류:', error);
|
||||
res.status(500).json({ message: '클럽을 삭제하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 기능 관리
|
||||
exports.manageClubFeatures = async (req, res) => {
|
||||
try {
|
||||
const { clubId, features } = req.body;
|
||||
|
||||
// 클럽 존재 여부 확인
|
||||
const club = await Club.findByPk(clubId);
|
||||
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 클럽 기능 업데이트
|
||||
await club.update({ features });
|
||||
|
||||
res.json({
|
||||
id: club.id,
|
||||
features: club.features,
|
||||
updatedAt: club.updatedAt
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('클럽 기능 관리 오류:', error);
|
||||
res.status(500).json({ message: '클럽 기능을 업데이트하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 회원 목록 조회
|
||||
exports.getClubMembers = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
// 클럽 존재 여부 확인
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const members = await Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: {
|
||||
[Op.ne]: 'deleted'
|
||||
}
|
||||
},
|
||||
order: [
|
||||
['memberType', 'ASC'],
|
||||
['name', 'ASC']
|
||||
]
|
||||
});
|
||||
|
||||
// 응답 데이터 형식화
|
||||
const formattedMembers = members.map(member => {
|
||||
const memberData = member.toJSON();
|
||||
return {
|
||||
id: memberData.id,
|
||||
name: memberData.name,
|
||||
gender: memberData.gender,
|
||||
memberType: memberData.memberType,
|
||||
handicap: memberData.handicap,
|
||||
average: memberData.average,
|
||||
games: memberData.games,
|
||||
phone: memberData.phone,
|
||||
email: memberData.user ? memberData.user.email : memberData.email,
|
||||
joinDate: memberData.joinDate,
|
||||
status: memberData.status
|
||||
};
|
||||
});
|
||||
|
||||
res.json(formattedMembers);
|
||||
} catch (error) {
|
||||
console.error('클럽 회원 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '클럽 회원 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 회원 추가
|
||||
exports.addClubMember = async (req, res) => {
|
||||
try {
|
||||
const { clubId, userId, name, gender, memberType, email, phone } = req.body;
|
||||
|
||||
if (!clubId || !name || !gender) {
|
||||
return res.status(400).json({ message: '필수 정보가 누락되었습니다.' });
|
||||
}
|
||||
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 회원 생성
|
||||
const newMember = await Member.create({
|
||||
clubId,
|
||||
userId,
|
||||
name,
|
||||
gender,
|
||||
memberType: memberType || '준회원',
|
||||
email,
|
||||
phone
|
||||
});
|
||||
|
||||
// 클럽의 회원 수 업데이트
|
||||
const memberCount = await Member.count({ where: { clubId: clubId, status: 'active' } });
|
||||
await club.update({ memberCount });
|
||||
|
||||
res.status(201).json(newMember);
|
||||
} catch (error) {
|
||||
console.error('클럽 회원 추가 오류:', error);
|
||||
res.status(500).json({ message: '회원을 추가하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 회원 정보 수정
|
||||
exports.updateClubMember = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId, name, gender, memberType, email, phone, status } = req.body;
|
||||
|
||||
if (!clubId || !memberId) {
|
||||
return res.status(400).json({ message: '잘못된 클럽 또는 회원 ID입니다.' });
|
||||
}
|
||||
|
||||
const member = await Member.findOne({
|
||||
where: {
|
||||
id: memberId,
|
||||
clubId: clubId
|
||||
}
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
return res.status(404).json({ message: '회원을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 모임장은 한 명만 존재할 수 있음
|
||||
if (memberType === '모임장') {
|
||||
const existingOwner = await Member.findOne({
|
||||
where: {
|
||||
clubId: clubId,
|
||||
memberType: '모임장',
|
||||
status: 'active',
|
||||
id: { [Op.ne]: memberId }
|
||||
}
|
||||
});
|
||||
|
||||
if (existingOwner) {
|
||||
return res.status(400).json({ message: '이미 다른 모임장이 존재합니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 회원 정보 업데이트
|
||||
await member.update({
|
||||
name: name || member.name,
|
||||
gender: gender || member.gender,
|
||||
memberType: memberType || member.memberType,
|
||||
email: email || member.email,
|
||||
phone: phone || member.phone,
|
||||
status: status || member.status
|
||||
});
|
||||
|
||||
// 클럽의 회원 수 업데이트
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (club) {
|
||||
const memberCount = await Member.count({ where: { clubId: clubId, status: 'active' } });
|
||||
await club.update({ memberCount });
|
||||
}
|
||||
|
||||
res.json(member);
|
||||
} catch (error) {
|
||||
console.error('클럽 회원 정보 수정 오류:', error);
|
||||
res.status(500).json({ message: '회원 정보를 수정하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 회원 삭제
|
||||
exports.deleteClubMember = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.body;
|
||||
|
||||
if (!clubId || !memberId) {
|
||||
return res.status(400).json({ message: '잘못된 클럽 또는 회원 ID입니다.' });
|
||||
}
|
||||
|
||||
const member = await Member.findOne({
|
||||
where: {
|
||||
id: memberId,
|
||||
clubId: clubId
|
||||
}
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
return res.status(404).json({ message: '회원을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 회원 상태를 deleted로 변경
|
||||
await member.update({ status: 'deleted' });
|
||||
|
||||
// 클럽의 회원 수 감소
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (club) {
|
||||
const memberCount = await Member.count({ where: { clubId: clubId, status: 'active' } });
|
||||
await club.update({ memberCount });
|
||||
}
|
||||
|
||||
res.json({ message: '회원이 성공적으로 삭제되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('클럽 회원 삭제 오류:', error);
|
||||
res.status(500).json({ message: '회원을 삭제하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 회원 통계 조회
|
||||
exports.getMemberStats = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const members = await Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalCount: members.length,
|
||||
regularCount: members.filter(m => m.memberType === '정회원').length,
|
||||
associateCount: members.filter(m => m.memberType === '준회원').length
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
console.error('회원 통계 조회 오류:', error);
|
||||
res.status(500).json({ message: '회원 통계를 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 다음 모임 정보 조회
|
||||
exports.getNextMeeting = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const nextMeeting = await Event.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
startDate: { [Op.gt]: new Date() },
|
||||
status: 'active'
|
||||
},
|
||||
include: [{
|
||||
model: EventParticipant,
|
||||
as: 'participants',
|
||||
where: {
|
||||
status: { [Op.ne]: '취소' }
|
||||
},
|
||||
required: false
|
||||
}],
|
||||
order: [['startDate', 'ASC']]
|
||||
});
|
||||
|
||||
if (!nextMeeting) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const meetingData = {
|
||||
date: nextMeeting.startDate,
|
||||
participantsCount: nextMeeting.participants ? nextMeeting.participants.length : 0,
|
||||
title: nextMeeting.title,
|
||||
location: nextMeeting.location
|
||||
};
|
||||
|
||||
res.json(meetingData);
|
||||
} catch (error) {
|
||||
console.error('다음 모임 정보 조회 오류:', error);
|
||||
res.status(500).json({ message: '다음 모임 정보를 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 최근 모임 정보 조회
|
||||
exports.getLastMeeting = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const lastMeeting = await Event.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
startDate: { [Op.lt]: new Date() },
|
||||
status: 'active'
|
||||
},
|
||||
include: [{
|
||||
model: EventParticipant,
|
||||
as: 'participants',
|
||||
where: {
|
||||
status: { [Op.ne]: '취소' }
|
||||
},
|
||||
required: false
|
||||
}],
|
||||
order: [['startDate', 'DESC']]
|
||||
});
|
||||
|
||||
if (!lastMeeting) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const meetingData = {
|
||||
date: lastMeeting.startDate,
|
||||
participantsCount: lastMeeting.participants ? lastMeeting.participants.length : 0,
|
||||
title: lastMeeting.title,
|
||||
games: lastMeeting.gameCount || 0
|
||||
};
|
||||
|
||||
res.json(meetingData);
|
||||
} catch (error) {
|
||||
console.error('최근 모임 정보 조회 오류:', error);
|
||||
res.status(500).json({ message: '최근 모임 정보를 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 점수 통계 조회
|
||||
exports.getScoreStats = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
// 최근 3개월 내의 이벤트 점수만 집계
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const events = await Event.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
startDate: { [Op.gte]: threeMonthsAgo },
|
||||
status: 'active'
|
||||
},
|
||||
include: [{
|
||||
model: EventParticipant,
|
||||
as: 'participants',
|
||||
include: [{
|
||||
model: EventScore,
|
||||
as: 'scores'
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let allScores = [];
|
||||
let totalGames = 0;
|
||||
|
||||
events.forEach(event => {
|
||||
event.participants.forEach(participant => {
|
||||
if (participant.scores && participant.scores.length > 0) {
|
||||
allScores = allScores.concat(participant.scores.map(score => score.score));
|
||||
totalGames += participant.scores.length;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const stats = {
|
||||
topGame: allScores.length > 0 ? Math.max(...allScores) : 0,
|
||||
averageScore: allScores.length > 0 ? Math.round(allScores.reduce((a, b) => a + b, 0) / allScores.length) : 0,
|
||||
totalGames: totalGames
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
console.error('점수 통계 조회 오류:', error);
|
||||
res.status(500).json({ message: '점수 통계를 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 상위 회원 목록 조회
|
||||
exports.getTopMembers = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const topMembers = await Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
games: { [Op.gt]: 0 } // 최소 1게임 이상 플레이한 회원
|
||||
},
|
||||
order: [
|
||||
['average', 'DESC']
|
||||
],
|
||||
limit: 5,
|
||||
attributes: ['id', 'name', 'average', 'games', 'handicap', 'memberType']
|
||||
});
|
||||
|
||||
res.json(topMembers);
|
||||
} catch (error) {
|
||||
console.error('상위 회원 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '상위 회원 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 최근 모임 목록 조회
|
||||
exports.getRecentMeetings = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const recentMeetings = await Event.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
startDate: { [Op.lt]: new Date() }
|
||||
},
|
||||
include: [{
|
||||
model: EventParticipant,
|
||||
as: 'participants',
|
||||
where: {
|
||||
status: { [Op.ne]: '취소' }
|
||||
},
|
||||
required: false
|
||||
}],
|
||||
order: [['startDate', 'DESC']],
|
||||
limit: 5
|
||||
});
|
||||
|
||||
const formattedMeetings = recentMeetings.map(meeting => ({
|
||||
date: meeting.startDate,
|
||||
title: meeting.title,
|
||||
participants: meeting.participants ? meeting.participants.length : 0,
|
||||
games: meeting.gameCount || 0
|
||||
}));
|
||||
|
||||
res.json(formattedMeetings);
|
||||
} catch (error) {
|
||||
console.error('최근 모임 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '최근 모임 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 클럽 목록 조회
|
||||
exports.getUserClubs = async (req, res) => {
|
||||
try {
|
||||
// 사용자가 속한 클럽 목록 조회 (Member 테이블 조인)
|
||||
// admin이면 모든 클럽을, 아니면 자신의 클럽만 조회
|
||||
const hasAdminAccess = isAdmin(req.user);
|
||||
|
||||
const queryOptions = {
|
||||
where: { status: 'active' }
|
||||
};
|
||||
|
||||
// admin이 아닌 경우에만 Member 조건 추가
|
||||
if (!hasAdminAccess) {
|
||||
queryOptions.include = [{
|
||||
model: Member,
|
||||
where: {
|
||||
userId: req.user.id
|
||||
},
|
||||
attributes: ['memberType']
|
||||
}];
|
||||
}
|
||||
|
||||
// order 옵션 추가
|
||||
queryOptions.order = [['createdAt', 'DESC']];
|
||||
const clubs = await Club.findAll(queryOptions);
|
||||
|
||||
// 응답 데이터 가공
|
||||
const formattedClubs = clubs.map(club => {
|
||||
const clubData = club.toJSON();
|
||||
let memberType = '';
|
||||
|
||||
// admin이 아닌 경우에만 Members가 있음
|
||||
if (!hasAdminAccess && clubData.Members && clubData.Members.length > 0) {
|
||||
memberType = clubData.Members[0].memberType;
|
||||
} else if (hasAdminAccess) {
|
||||
memberType = 'admin';
|
||||
}
|
||||
|
||||
return {
|
||||
id: clubData.id,
|
||||
name: clubData.name,
|
||||
location: clubData.location,
|
||||
memberType
|
||||
};
|
||||
});
|
||||
|
||||
res.json(formattedClubs);
|
||||
} catch (error) {
|
||||
console.error('클럽 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '클럽 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 통계 조회
|
||||
exports.getStatistics = async (req, res) => {
|
||||
try {
|
||||
const { clubId, days } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - (days || 90)); // 기본값: 90일
|
||||
|
||||
// 1. 회원 통계
|
||||
const members = await Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 이벤트 및 참가자 통계
|
||||
const events = await Event.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
startDate: { [Op.gte]: startDate },
|
||||
status: 'active'
|
||||
},
|
||||
include: [{
|
||||
model: EventParticipant,
|
||||
as: 'participants',
|
||||
include: [{
|
||||
model: EventScore,
|
||||
as: 'scores'
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
// 3. 점수 데이터 수집
|
||||
let allScores = [];
|
||||
let totalParticipants = 0;
|
||||
let totalGames = 0;
|
||||
|
||||
events.forEach(event => {
|
||||
if (event.participants) {
|
||||
totalParticipants += event.participants.length;
|
||||
event.participants.forEach(participant => {
|
||||
if (participant.scores) {
|
||||
allScores = allScores.concat(participant.scores.map(score => score.score));
|
||||
totalGames += participant.scores.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 점수 분포 계산
|
||||
const scoreDistribution = Array(10).fill(0);
|
||||
allScores.forEach(score => {
|
||||
const index = Math.floor(score / 30);
|
||||
if (index >= 0 && index < 10) {
|
||||
scoreDistribution[index]++;
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 참석률 추이 계산
|
||||
const attendanceTrend = events.map(event => ({
|
||||
date: event.startDate,
|
||||
attendance: Math.round((event.participants ? event.participants.length : 0) / members.length * 100)
|
||||
}));
|
||||
|
||||
// 6. 회원 순위 계산
|
||||
const memberRankings = await Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
games: { [Op.gt]: 0 }
|
||||
},
|
||||
order: [['average', 'DESC']],
|
||||
limit: 10,
|
||||
attributes: ['id', 'name', 'average', 'games', 'handicap', 'memberType']
|
||||
});
|
||||
|
||||
// 각 회원의 참석률 계산
|
||||
const memberAttendance = {};
|
||||
events.forEach(event => {
|
||||
event.participants.forEach(participant => {
|
||||
memberAttendance[participant.memberId] = memberAttendance[participant.memberId] || { attended: 0, total: 0 };
|
||||
memberAttendance[participant.memberId].attended++;
|
||||
memberAttendance[participant.memberId].total = events.length;
|
||||
});
|
||||
});
|
||||
|
||||
const rankingsWithAttendance = memberRankings.map(member => ({
|
||||
...member.toJSON(),
|
||||
attendance: memberAttendance[member.id]
|
||||
? Math.round(memberAttendance[member.id].attended / memberAttendance[member.id].total * 100)
|
||||
: 0
|
||||
}));
|
||||
|
||||
// 7. 모임별 통계
|
||||
const meetingStats = events.map(event => ({
|
||||
date: event.startDate,
|
||||
title: event.title,
|
||||
participants: event.participants ? event.participants.length : 0,
|
||||
games: event.gameCount || 0,
|
||||
averageScore: event.participants && event.participants.length > 0
|
||||
? Math.round(event.participants.reduce((sum, p) =>
|
||||
sum + (p.scores ? p.scores.reduce((s, score) => s + score.score, 0) / p.scores.length : 0),
|
||||
0) / event.participants.length)
|
||||
: 0
|
||||
}));
|
||||
|
||||
// 8. 응답 데이터 구성
|
||||
const response = {
|
||||
summary: {
|
||||
totalMembers: members.length,
|
||||
activeMembers: members.filter(m => m.games > 0).length,
|
||||
averageAttendance: events.length > 0
|
||||
? Math.round(totalParticipants / (events.length * members.length) * 100)
|
||||
: 0,
|
||||
highestGame: allScores.length > 0 ? Math.max(...allScores) : 0,
|
||||
averageScore: allScores.length > 0
|
||||
? Math.round(allScores.reduce((a, b) => a + b, 0) / allScores.length)
|
||||
: 0,
|
||||
totalGames: totalGames,
|
||||
totalMeetings: events.length,
|
||||
averageParticipants: events.length > 0
|
||||
? Math.round(totalParticipants / events.length)
|
||||
: 0,
|
||||
averageGamesPerMeeting: events.length > 0
|
||||
? Math.round(totalGames / events.length)
|
||||
: 0,
|
||||
averageHandicap: members.length > 0
|
||||
? Math.round(members.reduce((sum, m) => sum + m.handicap, 0) / members.length)
|
||||
: 0,
|
||||
highestHandicap: members.length > 0
|
||||
? Math.max(...members.map(m => m.handicap))
|
||||
: 0,
|
||||
lowestHandicap: members.length > 0
|
||||
? Math.min(...members.map(m => m.handicap))
|
||||
: 0
|
||||
},
|
||||
scoreDistribution: scoreDistribution.map((count, index) => ({
|
||||
range: `${index * 30}-${(index + 1) * 30 - 1}`,
|
||||
count
|
||||
})),
|
||||
attendanceTrend,
|
||||
memberRankings: rankingsWithAttendance,
|
||||
meetingStats
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('통계 데이터 조회 오류:', error);
|
||||
res.status(500).json({ message: '통계 데이터를 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
const { Feature, ClubFeature, Club } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// 전체 기능 목록 조회
|
||||
exports.getFeatures = async (req, res) => {
|
||||
try {
|
||||
const features = await Feature.findAll({
|
||||
where: {
|
||||
status: 'active'
|
||||
},
|
||||
attributes: ['id', 'name', 'description', 'price']
|
||||
});
|
||||
res.json(features);
|
||||
} catch (error) {
|
||||
console.error('기능 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '기능 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 사용 가능한 기능 목록 조회
|
||||
exports.getAvailableFeatures = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.query;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
// 모든 기능 목록 조회
|
||||
const features = await Feature.findAll({
|
||||
where: {
|
||||
status: 'active'
|
||||
},
|
||||
attributes: ['id', 'name', 'description', 'price']
|
||||
});
|
||||
|
||||
// 클럽의 활성화된 기능 조회
|
||||
const activeFeatures = await ClubFeature.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
expiryDate: {
|
||||
[Op.gt]: new Date()
|
||||
}
|
||||
},
|
||||
attributes: ['featureId']
|
||||
});
|
||||
|
||||
const activeFeatureIds = activeFeatures.map(af => af.featureId);
|
||||
|
||||
// 기능 목록에 활성화 상태 추가
|
||||
const formattedFeatures = features.map(feature => ({
|
||||
...feature.toJSON(),
|
||||
isActive: activeFeatureIds.includes(feature.id)
|
||||
}));
|
||||
|
||||
res.json(formattedFeatures);
|
||||
} catch (error) {
|
||||
console.error('사용 가능한 기능 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '기능 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 활성화된 기능 목록 조회
|
||||
exports.getActiveFeatures = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.query;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const activeFeatures = await ClubFeature.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
expiryDate: {
|
||||
[Op.gt]: new Date()
|
||||
}
|
||||
},
|
||||
include: [{
|
||||
model: Feature,
|
||||
as: 'feature',
|
||||
attributes: ['name', 'description']
|
||||
}],
|
||||
attributes: ['id', 'featureId', 'purchaseDate', 'expiryDate']
|
||||
});
|
||||
|
||||
res.json(activeFeatures);
|
||||
} catch (error) {
|
||||
console.error('활성화된 기능 목록 조회 오류:', error);
|
||||
res.status(500).json({ message: '활성화된 기능 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 기능 구매 내역 조회
|
||||
exports.getFeaturePayments = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.query;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
const payments = await ClubFeature.findAll({
|
||||
where: {
|
||||
clubId
|
||||
},
|
||||
include: [{
|
||||
model: Feature,
|
||||
as: 'feature',
|
||||
attributes: ['name']
|
||||
}],
|
||||
attributes: [
|
||||
'id',
|
||||
'featureId',
|
||||
'purchaseDate',
|
||||
'expiryDate',
|
||||
'amount',
|
||||
'duration',
|
||||
'status'
|
||||
],
|
||||
order: [['purchaseDate', 'DESC']]
|
||||
});
|
||||
|
||||
const formattedPayments = payments.map(payment => ({
|
||||
id: payment.id,
|
||||
featureName: payment.feature.name,
|
||||
purchaseDate: payment.purchaseDate,
|
||||
expiryDate: payment.expiryDate,
|
||||
amount: payment.amount,
|
||||
duration: payment.duration,
|
||||
status: payment.status
|
||||
}));
|
||||
|
||||
res.json(formattedPayments);
|
||||
} catch (error) {
|
||||
console.error('기능 구매 내역 조회 오류:', error);
|
||||
res.status(500).json({ message: '구매 내역을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 기능 구매
|
||||
exports.purchaseFeature = async (req, res) => {
|
||||
try {
|
||||
const { clubId, featureId, duration } = req.body;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ message: '클럽 ID가 필요합니다.' });
|
||||
}
|
||||
|
||||
// 기능 정보 조회
|
||||
const feature = await Feature.findByPk(featureId);
|
||||
if (!feature) {
|
||||
return res.status(404).json({ message: '존재하지 않는 기능입니다.' });
|
||||
}
|
||||
|
||||
// 클럽 정보 조회
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: '존재하지 않는 클럽입니다.' });
|
||||
}
|
||||
|
||||
// 이미 활성화된 기능인지 확인
|
||||
const existingFeature = await ClubFeature.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
featureId,
|
||||
expiryDate: {
|
||||
[Op.gt]: new Date()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingFeature) {
|
||||
return res.status(400).json({ message: '이미 활성화된 기능입니다.' });
|
||||
}
|
||||
|
||||
// 구매 금액 계산 (duration에 따른 할인 적용 가능)
|
||||
const amount = feature.price * duration;
|
||||
|
||||
// 기능 구매 기록 생성
|
||||
const purchaseDate = new Date();
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setMonth(expiryDate.getMonth() + duration);
|
||||
|
||||
const clubFeature = await ClubFeature.create({
|
||||
clubId,
|
||||
featureId,
|
||||
purchaseDate,
|
||||
expiryDate,
|
||||
amount,
|
||||
duration,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: '기능 구매가 완료되었습니다.',
|
||||
purchaseInfo: clubFeature
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('기능 구매 오류:', error);
|
||||
res.status(500).json({ message: '기능 구매 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
const { Menu, Feature, ClubFeature, ClubSubscription, SubscriptionPlan, Member } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* 사용자의 접근 가능한 메뉴 목록을 반환합니다.
|
||||
*/
|
||||
exports.getUserMenus = async (req, res) => {
|
||||
try {
|
||||
const { clubId, role = 'user' } = req.body;
|
||||
const userId = req.user.id; // JWT 토큰에서 사용자 ID 가져오기
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '클럽 ID가 필요합니다.'
|
||||
});
|
||||
}
|
||||
|
||||
// 멤버 정보 조회
|
||||
const member = await Member.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
const menus = await getAccessibleMenus(clubId, role, member);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: menus
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('메뉴 조회 중 오류 발생:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '메뉴 조회 중 오류가 발생했습니다.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 권한과 클럽의 구독/기능 상태에 따라 접근 가능한 메뉴 목록을 조회합니다.
|
||||
*/
|
||||
async function getAccessibleMenus(clubId, userRole, member) {
|
||||
try {
|
||||
// 1. 클럽의 현재 구독 정보 조회
|
||||
const clubSubscription = await ClubSubscription.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
endDate: {
|
||||
[Op.gt]: new Date()
|
||||
}
|
||||
},
|
||||
include: [{
|
||||
model: SubscriptionPlan,
|
||||
attributes: ['name']
|
||||
}]
|
||||
});
|
||||
|
||||
// 2. 클럽의 활성화된 기능 목록 조회
|
||||
const activeFeatures = await ClubFeature.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
enabled: true,
|
||||
[Op.or]: [
|
||||
{ expiryDate: null },
|
||||
{ expiryDate: { [Op.gt]: new Date() } }
|
||||
]
|
||||
},
|
||||
include: [{
|
||||
model: Feature,
|
||||
as: 'feature',
|
||||
attributes: ['code']
|
||||
}]
|
||||
});
|
||||
|
||||
const activeFeatureCodes = activeFeatures.map(f => f.feature.code);
|
||||
const subscriptionTier = clubSubscription ? clubSubscription.SubscriptionPlan.name.toLowerCase() : 'basic';
|
||||
|
||||
// 3. 메뉴 조회 및 필터링
|
||||
const allMenus = await Menu.findAll({
|
||||
where: {
|
||||
visible: true,
|
||||
[Op.and]: [
|
||||
// 역할 기반 필터링
|
||||
{
|
||||
[Op.or]: [
|
||||
{ role: userRole },
|
||||
{ role: 'user' }
|
||||
]
|
||||
},
|
||||
// 구독 등급 기반 필터링
|
||||
{
|
||||
[Op.or]: [
|
||||
{ requiredSubscriptionTier: null },
|
||||
{ requiredSubscriptionTier: subscriptionTier }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
include: [{
|
||||
model: Menu,
|
||||
as: 'children',
|
||||
required: false
|
||||
}],
|
||||
order: [
|
||||
['order', 'ASC'],
|
||||
[{ model: Menu, as: 'children' }, 'order', 'ASC']
|
||||
]
|
||||
});
|
||||
|
||||
// 4. 기능 코드 기반 필터링 및 메뉴 구조화
|
||||
const processMenu = (menu) => {
|
||||
// 기능 코드가 있는 경우 접근 권한 확인
|
||||
if (menu.featureCode && !activeFeatureCodes.includes(menu.featureCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const processedMenu = {
|
||||
id: menu.id,
|
||||
title: menu.title,
|
||||
icon: menu.icon,
|
||||
to: menu.to,
|
||||
order: menu.order
|
||||
};
|
||||
|
||||
// 하위 메뉴 처리
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const children = menu.children
|
||||
.map(processMenu)
|
||||
.filter(child => child !== null);
|
||||
|
||||
if (children.length > 0) {
|
||||
processedMenu.children = children;
|
||||
}
|
||||
}
|
||||
|
||||
return processedMenu;
|
||||
};
|
||||
|
||||
// 최상위 메뉴만 필터링 (parentId가 null인 항목)
|
||||
const rootMenus = allMenus
|
||||
.filter(menu => !menu.parentId)
|
||||
.map(processMenu)
|
||||
.filter(menu => menu !== null);
|
||||
|
||||
// 구독 관리 메뉴 추가 (모임장, 운영진만 접근 가능)
|
||||
if (member && (member.memberType === '모임장' || member.memberType === '운영진')) {
|
||||
rootMenus.push({
|
||||
id: 'subscription',
|
||||
title: '구독/결제 관리',
|
||||
icon: 'pi pi-credit-card',
|
||||
to: '/club/subscription',
|
||||
order: 90,
|
||||
children: []
|
||||
});
|
||||
}
|
||||
|
||||
return rootMenus;
|
||||
} catch (error) {
|
||||
console.error('메뉴 조회 중 오류 발생:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 알림 컨트롤러
|
||||
*
|
||||
* 볼링 클럽 매니저에서 지원하는 알림 유형:
|
||||
* 1. 시스템 알림
|
||||
* - 시스템 업데이트, 유지보수 공지 등
|
||||
* - 아이콘: pi pi-info-circle
|
||||
* - 타입: info
|
||||
*
|
||||
* 2. 클럽 관련 알림
|
||||
* - 새 회원 가입, 클럽 정보 변경 등
|
||||
* - 아이콘: pi pi-users
|
||||
* - 타입: info 또는 success
|
||||
*
|
||||
* 3. 모임/대회 알림
|
||||
* - 모임 생성, 대회 일정, 참가 신청 등
|
||||
* - 아이콘: pi pi-calendar 또는 pi pi-flag
|
||||
* - 타입: info 또는 warning
|
||||
*
|
||||
* 4. 점수 관련 알림
|
||||
* - 새 점수 등록, 평균 점수 갱신 등
|
||||
* - 아이콘: pi pi-chart-bar
|
||||
* - 타입: success
|
||||
*
|
||||
* 5. 결제/구독 알림
|
||||
* - 결제 완료, 구독 만료 예정 등
|
||||
* - 아이콘: pi pi-credit-card 또는 pi pi-clock
|
||||
* - 타입: success 또는 warning
|
||||
*
|
||||
* 6. 오류/경고 알림
|
||||
* - 시스템 오류, 보안 경고 등
|
||||
* - 아이콘: pi pi-exclamation-triangle
|
||||
* - 타입: error 또는 warning
|
||||
*
|
||||
* 알림 생성 방법:
|
||||
* 1. API 엔드포인트를 통한 생성: POST /api/notifications
|
||||
* 2. 내부 함수를 통한 생성: createSystemNotification()
|
||||
*
|
||||
* 알림 필드:
|
||||
* - user: 알림을 받을 사용자 ID (필수)
|
||||
* - title: 알림 제목 (필수)
|
||||
* - message: 알림 내용 (필수)
|
||||
* - icon: 알림 아이콘 (PrimeIcons 사용)
|
||||
* - type: 알림 타입 (info, success, warning, error)
|
||||
* - link: 알림 클릭 시 이동할 링크 (옵션)
|
||||
* - read: 읽음 상태 (기본값: false)
|
||||
*/
|
||||
|
||||
const User = require('../models/User');
|
||||
const Notification = require('../models/Notification');
|
||||
const { validationResult } = require('express-validator');
|
||||
|
||||
// 사용자의 알림 목록 조회
|
||||
exports.getUserNotifications = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// 사용자의 알림 목록 조회 (최신순으로 정렬)
|
||||
const notifications = await Notification.findAll({
|
||||
where: { userId },
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 10
|
||||
});
|
||||
|
||||
res.status(200).json(notifications);
|
||||
} catch (error) {
|
||||
console.error('알림 조회 오류:', error);
|
||||
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 읽음 표시
|
||||
exports.markAsRead = async (req, res) => {
|
||||
try {
|
||||
const { notificationId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
const notification = await Notification.findOne({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return res.status(404).json({ message: '알림을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
notification.read = true;
|
||||
await notification.save();
|
||||
|
||||
res.status(200).json({ message: '알림이 읽음 처리되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('알림 읽음 표시 오류:', error);
|
||||
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 모든 알림 읽음 표시
|
||||
exports.markAllAsRead = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
await Notification.update(
|
||||
{ read: true },
|
||||
{ where: { userId, read: false } }
|
||||
);
|
||||
|
||||
res.status(200).json({ message: '모든 알림이 읽음 처리되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('모든 알림 읽음 표시 오류:', error);
|
||||
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 생성 (관리자용)
|
||||
exports.createNotification = async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { userId, title, message, icon, type, link } = req.body;
|
||||
|
||||
const newNotification = await Notification.create({
|
||||
userId,
|
||||
title,
|
||||
message,
|
||||
icon: icon || 'pi pi-info-circle',
|
||||
type: type || 'info',
|
||||
link,
|
||||
read: false
|
||||
});
|
||||
|
||||
// Socket.IO를 통해 실시간 알림 전송
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`user:${userId}`).emit('notification', {
|
||||
id: newNotification.id,
|
||||
title: newNotification.title,
|
||||
message: newNotification.message,
|
||||
icon: newNotification.icon,
|
||||
type: newNotification.type,
|
||||
link: newNotification.link,
|
||||
createdAt: newNotification.createdAt
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(newNotification);
|
||||
} catch (error) {
|
||||
console.error('알림 생성 오류:', error);
|
||||
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 삭제
|
||||
exports.deleteNotification = async (req, res) => {
|
||||
try {
|
||||
const { notificationId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
const notification = await Notification.findOne({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return res.status(404).json({ message: '알림을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
await notification.destroy();
|
||||
|
||||
res.status(200).json({ message: '알림이 삭제되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('알림 삭제 오류:', error);
|
||||
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 시스템 알림 생성 (내부 함수)
|
||||
exports.createSystemNotification = async (userId, title, message, options = {}) => {
|
||||
try {
|
||||
const { icon, type, link } = options;
|
||||
|
||||
const newNotification = await Notification.create({
|
||||
userId,
|
||||
title,
|
||||
message,
|
||||
icon: icon || 'pi pi-info-circle',
|
||||
type: type || 'info',
|
||||
link,
|
||||
read: false
|
||||
});
|
||||
|
||||
// Socket.IO를 통해 실시간 알림 전송
|
||||
const io = global.io;
|
||||
if (io) {
|
||||
io.to(`user:${userId}`).emit('notification', {
|
||||
id: newNotification.id,
|
||||
title: newNotification.title,
|
||||
message: newNotification.message,
|
||||
icon: newNotification.icon,
|
||||
type: newNotification.type,
|
||||
link: newNotification.link,
|
||||
createdAt: newNotification.createdAt
|
||||
});
|
||||
}
|
||||
|
||||
return newNotification;
|
||||
} catch (error) {
|
||||
console.error('시스템 알림 생성 오류:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
const { SubscriptionPlan, SubscriptionPlanFeature, ClubSubscription, Feature, Club, User } = require('../models');
|
||||
|
||||
// 전체 구독 목록 조회
|
||||
exports.getClubSubscriptions = async (req, res) => {
|
||||
try {
|
||||
const subscriptions = await ClubSubscription.findAll({
|
||||
include: [
|
||||
{
|
||||
model: SubscriptionPlan,
|
||||
include: [{
|
||||
model: Feature,
|
||||
through: { attributes: ['status'] }
|
||||
}]
|
||||
},
|
||||
{
|
||||
model: Club,
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'owner',
|
||||
attributes: ['id', 'name', 'email']
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
res.json(subscriptions);
|
||||
} catch (error) {
|
||||
console.error('Error in getClubSubscriptions:', error);
|
||||
res.status(500).json({ message: '구독 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 구독 플랜 목록 조회
|
||||
exports.getSubscriptionPlans = async (req, res) => {
|
||||
try {
|
||||
const plans = await SubscriptionPlan.findAll({
|
||||
include: [{
|
||||
model: Feature,
|
||||
through: { attributes: ['status'] }
|
||||
}]
|
||||
});
|
||||
res.json(plans);
|
||||
} catch (error) {
|
||||
console.error('Error in getSubscriptionPlans:', error);
|
||||
res.status(500).json({ message: '구독 플랜 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 구독 플랜 생성
|
||||
exports.createSubscriptionPlan = async (req, res) => {
|
||||
try {
|
||||
const { name, description, maxMembers, price, features } = req.body;
|
||||
|
||||
const plan = await SubscriptionPlan.create({
|
||||
name,
|
||||
description,
|
||||
maxMembers,
|
||||
price
|
||||
});
|
||||
|
||||
if (features && features.length > 0) {
|
||||
const featureAssociations = features.map(featureId => ({
|
||||
planId: plan.id,
|
||||
featureId,
|
||||
status: 'active'
|
||||
}));
|
||||
await SubscriptionPlanFeature.bulkCreate(featureAssociations);
|
||||
}
|
||||
|
||||
const createdPlan = await SubscriptionPlan.findByPk(plan.id, {
|
||||
include: [{
|
||||
model: Feature,
|
||||
through: { attributes: ['status'] }
|
||||
}]
|
||||
});
|
||||
|
||||
res.status(201).json(createdPlan);
|
||||
} catch (error) {
|
||||
console.error('Error in createSubscriptionPlan:', error);
|
||||
res.status(500).json({ message: '구독 플랜 생성 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 구독 플랜 수정
|
||||
exports.updateSubscriptionPlan = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, maxMembers, price, features } = req.body;
|
||||
|
||||
const plan = await SubscriptionPlan.findByPk(id);
|
||||
if (!plan) {
|
||||
return res.status(404).json({ message: '구독 플랜을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
await plan.update({
|
||||
name,
|
||||
description,
|
||||
maxMembers,
|
||||
price
|
||||
});
|
||||
|
||||
if (features) {
|
||||
// 기존 기능 연결 삭제
|
||||
await SubscriptionPlanFeature.destroy({
|
||||
where: { planId: id }
|
||||
});
|
||||
|
||||
// 새로운 기능 연결 생성
|
||||
if (features.length > 0) {
|
||||
const featureAssociations = features.map(featureId => ({
|
||||
planId: id,
|
||||
featureId,
|
||||
status: 'active'
|
||||
}));
|
||||
await SubscriptionPlanFeature.bulkCreate(featureAssociations);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPlan = await SubscriptionPlan.findByPk(id, {
|
||||
include: [{
|
||||
model: Feature,
|
||||
through: { attributes: ['status'] }
|
||||
}]
|
||||
});
|
||||
|
||||
res.json(updatedPlan);
|
||||
} catch (error) {
|
||||
console.error('Error in updateSubscriptionPlan:', error);
|
||||
res.status(500).json({ message: '구독 플랜 수정 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 구독 플랜 삭제
|
||||
exports.deleteSubscriptionPlan = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 플랜이 존재하는지 확인
|
||||
const plan = await SubscriptionPlan.findByPk(id);
|
||||
if (!plan) {
|
||||
return res.status(404).json({ message: '구독 플랜을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 플랜과 연결된 기능 관계 삭제
|
||||
await SubscriptionPlanFeature.destroy({
|
||||
where: { planId: id }
|
||||
});
|
||||
|
||||
// 플랜 삭제
|
||||
await plan.destroy();
|
||||
|
||||
res.json({ message: '구독 플랜이 삭제되었습니다.' });
|
||||
} catch (error) {
|
||||
console.error('Error in deleteSubscriptionPlan:', error);
|
||||
res.status(500).json({ message: '구독 플랜 삭제 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽 구독 생성/수정
|
||||
exports.subscribeClub = async (req, res) => {
|
||||
try {
|
||||
const { clubId, planId, startDate, expiryDate } = req.body;
|
||||
|
||||
// 기존 구독 확인
|
||||
let subscription = await ClubSubscription.findOne({
|
||||
where: { clubId, status: 'active' }
|
||||
});
|
||||
|
||||
if (subscription) {
|
||||
// 기존 구독 만료 처리
|
||||
await subscription.update({
|
||||
status: 'expired',
|
||||
updatedAt: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// 새로운 구독 생성
|
||||
subscription = await ClubSubscription.create({
|
||||
clubId,
|
||||
planId,
|
||||
startDate: new Date(startDate),
|
||||
expiryDate: new Date(expiryDate),
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
res.status(201).json(subscription);
|
||||
} catch (error) {
|
||||
console.error('Error in subscribeClub:', error);
|
||||
res.status(500).json({ message: '클럽 구독 처리 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 클럽의 현재 구독 정보 조회
|
||||
exports.getClubSubscription = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
|
||||
const subscription = await ClubSubscription.findOne({
|
||||
where: { clubId, status: 'active' },
|
||||
include: [{
|
||||
model: SubscriptionPlan,
|
||||
include: [{
|
||||
model: Feature,
|
||||
through: { attributes: ['status'] }
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return res.status(404).json({ message: '활성화된 구독 정보가 없습니다.' });
|
||||
}
|
||||
|
||||
res.json(subscription);
|
||||
} catch (error) {
|
||||
console.error('Error in getClubSubscription:', error);
|
||||
res.status(500).json({ message: '구독 정보 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// 플랜별 구독 목록 조회
|
||||
exports.getPlanSubscriptions = async (req, res) => {
|
||||
try {
|
||||
const { planId } = req.params;
|
||||
|
||||
const subscriptions = await ClubSubscription.findAll({
|
||||
where: { planId },
|
||||
include: [{
|
||||
model: Club,
|
||||
attributes: ['id', 'name']
|
||||
}]
|
||||
});
|
||||
|
||||
res.json(subscriptions);
|
||||
} catch (error) {
|
||||
console.error('Error in getPlanSubscriptions:', error);
|
||||
res.status(500).json({ message: '플랜별 구독 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
const User = require('../models/User');
|
||||
|
||||
/**
|
||||
* 전체 사용자 목록 조회
|
||||
*/
|
||||
const getAllUsers = async (req, res) => {
|
||||
try {
|
||||
const users = await User.findAll({
|
||||
attributes: {
|
||||
exclude: ['password']
|
||||
},
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 조회 중 오류 발생:', error);
|
||||
res.status(500).json({ message: '사용자 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 단일 사용자 정보 조회
|
||||
*/
|
||||
const getUser = async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: { exclude: ['password'] }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 조회 중 오류 발생:', error);
|
||||
res.status(500).json({ message: '사용자 정보를 가져오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자 정보 수정
|
||||
*/
|
||||
const updateUser = async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
const { name, email, role, status } = req.body;
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 이메일 중복 확인 (이메일이 변경된 경우에만)
|
||||
if (email !== user.email) {
|
||||
const existingUser = await User.findOne({ where: { email } });
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ message: '이미 사용 중인 이메일입니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 필드 확인
|
||||
if (!name || !email || !role || !status) {
|
||||
return res.status(400).json({ message: '필수 정보가 누락되었습니다.' });
|
||||
}
|
||||
|
||||
await user.update({
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
status,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: { exclude: ['password'] }
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 수정 중 오류 발생:', error);
|
||||
res.status(500).json({ message: '사용자 정보를 수정하는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllUsers,
|
||||
getUser,
|
||||
updateUser
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
-- 볼링 매니저 데이터베이스 스키마
|
||||
-- 데이터베이스 생성
|
||||
CREATE DATABASE IF NOT EXISTS bowlingManager;
|
||||
USE bowlingManager;
|
||||
|
||||
-- 사용자 테이블
|
||||
CREATE TABLE IF NOT EXISTS Users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
role ENUM('admin', 'clubadmin', 'user') NOT NULL DEFAULT 'user',
|
||||
status ENUM('active', 'inactive', 'suspended') NOT NULL DEFAULT 'active',
|
||||
lastLoginAt DATETIME,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- 클럽 테이블
|
||||
CREATE TABLE IF NOT EXISTS Clubs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
ownerId INT NOT NULL,
|
||||
location VARCHAR(255),
|
||||
contact VARCHAR(255),
|
||||
status ENUM('active', 'inactive', 'suspended') NOT NULL DEFAULT 'active',
|
||||
memberCount INT NOT NULL DEFAULT 0,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (ownerId) REFERENCES Users(id)
|
||||
);
|
||||
|
||||
-- 기능 테이블
|
||||
CREATE TABLE IF NOT EXISTS Features (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
code VARCHAR(255) NOT NULL UNIQUE,
|
||||
price INT NOT NULL DEFAULT 0,
|
||||
status ENUM('active', 'inactive', 'suspended') NOT NULL DEFAULT 'active',
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- 구독 플랜 테이블
|
||||
CREATE TABLE IF NOT EXISTS SubscriptionPlans (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
maxMembers INT NOT NULL,
|
||||
price INT NOT NULL DEFAULT 0,
|
||||
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- 클럽 구독 테이블
|
||||
CREATE TABLE IF NOT EXISTS ClubSubscriptions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clubId INT NOT NULL,
|
||||
planId INT NOT NULL,
|
||||
status ENUM('active', 'expired', 'suspended') NOT NULL DEFAULT 'active',
|
||||
startDate DATETIME NOT NULL,
|
||||
endDate DATETIME NOT NULL,
|
||||
price INT NOT NULL DEFAULT 0,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (clubId) REFERENCES Clubs(id),
|
||||
FOREIGN KEY (planId) REFERENCES SubscriptionPlans(id)
|
||||
);
|
||||
|
||||
-- 구독 플랜 기능 테이블
|
||||
CREATE TABLE IF NOT EXISTS SubscriptionPlanFeatures (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
planId INT NOT NULL,
|
||||
featureId INT NOT NULL,
|
||||
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (planId) REFERENCES SubscriptionPlans(id),
|
||||
FOREIGN KEY (featureId) REFERENCES Features(id)
|
||||
);
|
||||
|
||||
-- 클럽 기능 테이블 (N:M 관계)
|
||||
CREATE TABLE IF NOT EXISTS ClubFeatures (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clubId INT NOT NULL,
|
||||
featureId INT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
expiryDate DATETIME,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (clubId) REFERENCES Clubs(id),
|
||||
FOREIGN KEY (featureId) REFERENCES Features(id)
|
||||
);
|
||||
|
||||
-- 회원 테이블
|
||||
CREATE TABLE IF NOT EXISTS Members (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
clubId INT NOT NULL,
|
||||
userId INT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
gender ENUM('남성', '여성') NOT NULL,
|
||||
memberType ENUM('정회원', '준회원', '운영진', '모임장') NOT NULL DEFAULT '준회원',
|
||||
handicap INT NOT NULL DEFAULT 0,
|
||||
average FLOAT NOT NULL DEFAULT 0,
|
||||
games INT NOT NULL DEFAULT 0,
|
||||
phone VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
joinDate DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
status ENUM('active', 'inactive', 'suspended') NOT NULL DEFAULT 'active',
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (clubId) REFERENCES Clubs(id),
|
||||
FOREIGN KEY (userId) REFERENCES Users(id)
|
||||
);
|
||||
|
||||
-- 이벤트 테이블
|
||||
CREATE TABLE IF NOT EXISTS `Events` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`clubId` int NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`eventType` enum('정기전', '번개', '연습', '교류전', '이벤트', '기타') NOT NULL DEFAULT '정기전',
|
||||
`startDate` datetime NOT NULL,
|
||||
`endDate` datetime,
|
||||
`location` varchar(255),
|
||||
`gameCount` int NOT NULL DEFAULT 3,
|
||||
`entryFee` int NOT NULL DEFAULT 0,
|
||||
`maxParticipants` int NOT NULL DEFAULT 0,
|
||||
`registrationDeadline` datetime,
|
||||
`status` enum('active', 'inactive', 'deleted') NOT NULL DEFAULT 'active',
|
||||
`createdAt` datetime NOT NULL,
|
||||
`updatedAt` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `clubId` (`clubId`),
|
||||
CONSTRAINT `events_ibfk_1` FOREIGN KEY (`clubId`) REFERENCES `Clubs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- 이벤트 참가자 테이블
|
||||
CREATE TABLE IF NOT EXISTS EventParticipants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
eventId INT NOT NULL,
|
||||
memberId INT NOT NULL,
|
||||
status ENUM('참가예정', '참가완료', '취소') NOT NULL DEFAULT '참가예정',
|
||||
paymentStatus ENUM('미납', '납부완료') NOT NULL DEFAULT '미납',
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (eventId) REFERENCES Events(id),
|
||||
FOREIGN KEY (memberId) REFERENCES Members(id)
|
||||
);
|
||||
|
||||
-- 이벤트 점수 테이블
|
||||
CREATE TABLE IF NOT EXISTS EventScores (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
eventId INT NOT NULL,
|
||||
participantId INT NOT NULL,
|
||||
gameNumber INT NOT NULL,
|
||||
score INT NOT NULL,
|
||||
handicap INT NOT NULL DEFAULT 0,
|
||||
totalScore INT NOT NULL,
|
||||
strikes INT NOT NULL DEFAULT 0,
|
||||
spares INT NOT NULL DEFAULT 0,
|
||||
openFrames INT NOT NULL DEFAULT 0,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (eventId) REFERENCES Events(id),
|
||||
FOREIGN KEY (participantId) REFERENCES EventParticipants(id)
|
||||
);
|
||||
|
||||
-- 메뉴 테이블
|
||||
CREATE TABLE IF NOT EXISTS menus (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
icon VARCHAR(255),
|
||||
`to` VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(255) NOT NULL DEFAULT 'user',
|
||||
`order` INT NOT NULL DEFAULT 0,
|
||||
visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- 알림 테이블
|
||||
CREATE TABLE IF NOT EXISTS Notifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
userId INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
icon VARCHAR(255) DEFAULT 'pi pi-info-circle',
|
||||
type ENUM('info', 'success', 'warning', 'error') NOT NULL DEFAULT 'info',
|
||||
read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
link VARCHAR(255) DEFAULT NULL,
|
||||
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (userId) REFERENCES Users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 관리자 활동 로그 테이블
|
||||
CREATE TABLE IF NOT EXISTS ActivityLogs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
userId INT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (userId) REFERENCES Users(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = (req, res, next) => {
|
||||
try {
|
||||
// 헤더에서 토큰 가져오기
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
// 토큰 검증
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
|
||||
// 요청 객체에 사용자 정보 추가
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('인증 오류:', error);
|
||||
res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/**
|
||||
* JWT 토큰 인증 미들웨어
|
||||
* 요청 헤더에서 토큰을 추출하고 검증
|
||||
*/
|
||||
exports.authenticateJWT = (req, res, next) => {
|
||||
try {
|
||||
// 헤더에서 토큰 가져오기
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
// 토큰 검증
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// 요청 객체에 사용자 정보 추가
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('인증 오류:', error);
|
||||
res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 역할 기반 권한 부여 미들웨어
|
||||
* 특정 역할을 가진 사용자만 접근 허용
|
||||
* @param {string[]} roles - 허용된 역할 목록
|
||||
*/
|
||||
exports.authorizeRoles = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: '인증이 필요합니다.' });
|
||||
}
|
||||
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ message: '이 작업을 수행할 권한이 없습니다.' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const Club = sequelize.define('Club', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
ownerId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
location: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
contact: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
memberCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'Clubs',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = Club;
|
||||
@@ -0,0 +1,48 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const ClubFeature = sequelize.define('ClubFeature', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Clubs',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
featureId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Features',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
expiryDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'ClubFeatures',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = ClubFeature;
|
||||
@@ -0,0 +1,49 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const ClubSubscription = sequelize.define('ClubSubscription', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Clubs',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
planId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'SubscriptionPlans',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'expired', 'suspended'),
|
||||
defaultValue: 'active',
|
||||
allowNull: false
|
||||
},
|
||||
startDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
endDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
price: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
}, {
|
||||
tableName: 'ClubSubscriptions',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = ClubSubscription;
|
||||
@@ -0,0 +1,103 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const Event = sequelize.define('Event', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Clubs',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
eventType: {
|
||||
type: DataTypes.ENUM('정기전', '번개', '연습', '교류전', '이벤트', '기타'),
|
||||
allowNull: false,
|
||||
defaultValue: '정기전'
|
||||
},
|
||||
startDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
endDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
location: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
gameCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 3
|
||||
},
|
||||
participantFee: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
maxParticipants: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
registrationDeadline: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
participantCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('준비', '활성', '완료', '취소', '삭제'),
|
||||
allowNull: false,
|
||||
defaultValue: '활성'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'Events',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// 관계 설정
|
||||
Event.associate = (models) => {
|
||||
Event.belongsTo(models.Club, {
|
||||
foreignKey: 'clubId',
|
||||
as: 'club'
|
||||
});
|
||||
|
||||
Event.hasMany(models.EventParticipant, {
|
||||
foreignKey: 'eventId',
|
||||
as: 'participants'
|
||||
});
|
||||
|
||||
Event.hasMany(models.EventScore, {
|
||||
foreignKey: 'eventId',
|
||||
as: 'scores'
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = Event;
|
||||
@@ -0,0 +1,59 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const EventParticipant = sequelize.define('EventParticipant', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
eventId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Events',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Members',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('참가예정', '참가확정', '취소'),
|
||||
allowNull: false,
|
||||
defaultValue: '참가예정'
|
||||
},
|
||||
paymentStatus: {
|
||||
type: DataTypes.ENUM('미납', '납부완료'),
|
||||
allowNull: false,
|
||||
defaultValue: '미납'
|
||||
}
|
||||
}, {
|
||||
tableName: 'EventParticipants',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// 관계 설정
|
||||
EventParticipant.associate = (models) => {
|
||||
EventParticipant.belongsTo(models.Event, {
|
||||
foreignKey: 'eventId',
|
||||
as: 'event'
|
||||
});
|
||||
|
||||
EventParticipant.belongsTo(models.Member, {
|
||||
foreignKey: 'memberId',
|
||||
as: 'member'
|
||||
});
|
||||
|
||||
EventParticipant.hasMany(models.EventScore, {
|
||||
foreignKey: 'participantId',
|
||||
as: 'scores'
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = EventParticipant;
|
||||
@@ -0,0 +1,52 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const EventScore = sequelize.define('EventScore', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
eventId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Events',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
participantId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'EventParticipants',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
teamNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
gameNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
score: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
handicap: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
totalScore: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'EventScores',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = EventScore;
|
||||
@@ -0,0 +1,59 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const Feature = sequelize.define('Feature', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
purchaseType: {
|
||||
type: DataTypes.ENUM('subscription_only', 'separate_only', 'both'),
|
||||
allowNull: false,
|
||||
defaultValue: 'both'
|
||||
},
|
||||
price: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
oneTimePurchasePrice: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
monthlySubscriptionPrice: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'Features',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = Feature;
|
||||
@@ -0,0 +1,91 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
const User = require('./User');
|
||||
const Club = require('./Club');
|
||||
|
||||
const Member = sequelize.define('Member', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Clubs',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
gender: {
|
||||
type: DataTypes.ENUM('남성', '여성'),
|
||||
allowNull: false
|
||||
},
|
||||
memberType: {
|
||||
type: DataTypes.ENUM('정회원', '준회원', '운영진', '모임장'),
|
||||
allowNull: false,
|
||||
defaultValue: '준회원'
|
||||
},
|
||||
handicap: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
average: {
|
||||
type: DataTypes.FLOAT,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
games: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
phone: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
joinDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'deleted'),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
}
|
||||
}, {
|
||||
tableName: 'Members',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// User와의 관계 설정
|
||||
Member.belongsTo(User, {
|
||||
foreignKey: 'userId',
|
||||
as: 'user'
|
||||
});
|
||||
|
||||
// Club과의 관계 설정
|
||||
Member.belongsTo(Club, {
|
||||
foreignKey: 'clubId',
|
||||
as: 'club'
|
||||
});
|
||||
|
||||
module.exports = Member;
|
||||
@@ -0,0 +1,70 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const Menu = sequelize.define('Menu', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
parentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Menus', // 변경
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
icon: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
to: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
featureCode: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Features',
|
||||
key: 'code'
|
||||
}
|
||||
},
|
||||
requiredSubscriptionTier: {
|
||||
type: DataTypes.ENUM('basic', 'standard', 'premium'),
|
||||
allowNull: true
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'user'
|
||||
},
|
||||
order: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
visible: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'Menus',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = Menu;
|
||||
@@ -0,0 +1,59 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const Notification = sequelize.define('Notification', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
icon: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'pi pi-info-circle'
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.ENUM('info', 'success', 'warning', 'error'),
|
||||
defaultValue: 'info'
|
||||
},
|
||||
read: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
link: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
tableName: 'Notifications'
|
||||
});
|
||||
|
||||
module.exports = Notification;
|
||||
@@ -0,0 +1,63 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const Participant = sequelize.define('Participant', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
meetingId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Meetings',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Members',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
teamNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('참가예정', '참가완료', '취소'),
|
||||
allowNull: false,
|
||||
defaultValue: '참가예정'
|
||||
},
|
||||
attendance: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
paymentStatus: {
|
||||
type: DataTypes.ENUM('미납', '납부완료'),
|
||||
allowNull: false,
|
||||
defaultValue: '미납'
|
||||
},
|
||||
paymentAmount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'EventParticipants',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = Participant;
|
||||
@@ -0,0 +1,36 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const SubscriptionPlan = sequelize.define('SubscriptionPlan', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT
|
||||
},
|
||||
maxMembers: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
price: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive'),
|
||||
defaultValue: 'active',
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'SubscriptionPlan',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = SubscriptionPlan;
|
||||
@@ -0,0 +1,36 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
const SubscriptionPlanFeature = sequelize.define('SubscriptionPlanFeature', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
planId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'SubscriptionPlans',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
featureId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Features',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive'),
|
||||
defaultValue: 'active',
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'SubscriptionPlanFeatures',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = SubscriptionPlanFeature;
|
||||
@@ -0,0 +1,78 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const User = sequelize.define('User', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true
|
||||
}
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.ENUM('admin', 'clubadmin', 'user'),
|
||||
allowNull: false,
|
||||
defaultValue: 'user'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
lastLoginAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'Users',
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeCreate: async (user) => {
|
||||
if (user.password) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
},
|
||||
beforeUpdate: async (user) => {
|
||||
if (user.changed('password')) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 비밀번호 검증 메소드
|
||||
User.prototype.validatePassword = async function(password) {
|
||||
return await bcrypt.compare(password, this.password);
|
||||
};
|
||||
|
||||
module.exports = User;
|
||||
@@ -0,0 +1,40 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
class ActivityLog extends Model {}
|
||||
|
||||
ActivityLog.init({
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'ActivityLog',
|
||||
tableName: 'ActivityLogs',
|
||||
timestamps: true,
|
||||
updatedAt: false
|
||||
});
|
||||
|
||||
module.exports = ActivityLog;
|
||||
@@ -0,0 +1,855 @@
|
||||
const { sequelize } = require('../config/database');
|
||||
const User = require('./User');
|
||||
const Club = require('./Club');
|
||||
const Event = require('./Event');
|
||||
const EventParticipant = require('./EventParticipant');
|
||||
const EventScore = require('./EventScore');
|
||||
const Member = require('./Member');
|
||||
const Notification = require('./Notification');
|
||||
const Feature = require('./Feature');
|
||||
const ClubFeature = require('./ClubFeature');
|
||||
const Menu = require('./Menu');
|
||||
const SubscriptionPlan = require('./SubscriptionPlan');
|
||||
const SubscriptionPlanFeature = require('./SubscriptionPlanFeature');
|
||||
const ClubSubscription = require('./ClubSubscription');
|
||||
const ActivityLog = require('./activityLog');
|
||||
|
||||
// 관계 설정
|
||||
// User와 Member 관계
|
||||
User.hasMany(Member, { foreignKey: 'userId' });
|
||||
Member.belongsTo(User, { foreignKey: 'userId' });
|
||||
|
||||
// Club과 Member 관계
|
||||
Club.hasMany(Member, { foreignKey: 'clubId' });
|
||||
Member.belongsTo(Club, { foreignKey: 'clubId' });
|
||||
|
||||
// Club과 User 사이의 다대다 관계 추가
|
||||
User.belongsToMany(Club, { through: Member, foreignKey: 'userId' });
|
||||
Club.belongsToMany(User, { through: Member, foreignKey: 'clubId' });
|
||||
|
||||
// Club과 User 사이의 소유자 관계 추가
|
||||
Club.belongsTo(User, { as: 'owner', foreignKey: 'ownerId' });
|
||||
User.hasMany(Club, { as: 'ownedClubs', foreignKey: 'ownerId' });
|
||||
|
||||
// Club과 Event 관계
|
||||
Club.hasMany(Event, { foreignKey: 'clubId' });
|
||||
Event.belongsTo(Club, { foreignKey: 'clubId' });
|
||||
|
||||
// Event와 EventParticipant 관계
|
||||
Event.hasMany(EventParticipant, { foreignKey: 'eventId', as: 'participants' });
|
||||
EventParticipant.belongsTo(Event, { foreignKey: 'eventId' });
|
||||
|
||||
// Member와 EventParticipant 관계
|
||||
Member.hasMany(EventParticipant, { foreignKey: 'memberId' });
|
||||
EventParticipant.belongsTo(Member, { foreignKey: 'memberId' });
|
||||
|
||||
// Event와 EventScore 관계
|
||||
Event.hasMany(EventScore, { foreignKey: 'eventId', as: 'scores' });
|
||||
EventScore.belongsTo(Event, { foreignKey: 'eventId', as: 'event' });
|
||||
|
||||
// EventParticipant와 EventScore 관계
|
||||
EventParticipant.hasMany(EventScore, { foreignKey: 'participantId', as: 'scores' });
|
||||
EventScore.belongsTo(EventParticipant, { foreignKey: 'participantId', as: 'participant' });
|
||||
|
||||
// Feature와 Menu 관계
|
||||
Feature.hasMany(Menu, { foreignKey: 'featureCode', sourceKey: 'code' });
|
||||
Menu.belongsTo(Feature, { foreignKey: 'featureCode', targetKey: 'code' });
|
||||
|
||||
// Menu 자기 참조 관계
|
||||
Menu.belongsTo(Menu, { as: 'parent', foreignKey: 'parentId' });
|
||||
Menu.hasMany(Menu, { as: 'children', foreignKey: 'parentId' });
|
||||
|
||||
// Club과 Feature의 관계
|
||||
Club.belongsToMany(Feature, { through: ClubFeature, foreignKey: 'clubId' });
|
||||
Feature.belongsToMany(Club, { through: ClubFeature, foreignKey: 'featureId' });
|
||||
|
||||
// Feature와 ClubFeature 관계
|
||||
Feature.hasMany(ClubFeature, { foreignKey: 'featureId', as: 'clubFeatures' });
|
||||
ClubFeature.belongsTo(Feature, { foreignKey: 'featureId', as: 'feature' });
|
||||
|
||||
// Club과 ClubFeature 관계
|
||||
Club.hasMany(ClubFeature, { foreignKey: 'clubId', as: 'features' });
|
||||
ClubFeature.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
|
||||
// User와 Notification 관계
|
||||
User.hasMany(Notification, { foreignKey: 'userId' });
|
||||
Notification.belongsTo(User, { foreignKey: 'userId' });
|
||||
|
||||
// Club과 SubscriptionPlan의 관계 (through ClubSubscription)
|
||||
Club.belongsToMany(SubscriptionPlan, {
|
||||
through: ClubSubscription,
|
||||
foreignKey: 'clubId',
|
||||
otherKey: 'planId'
|
||||
});
|
||||
SubscriptionPlan.belongsToMany(Club, {
|
||||
through: ClubSubscription,
|
||||
foreignKey: 'planId',
|
||||
otherKey: 'clubId'
|
||||
});
|
||||
|
||||
// SubscriptionPlan과 Feature의 관계 (through SubscriptionPlanFeature)
|
||||
SubscriptionPlan.belongsToMany(Feature, {
|
||||
through: SubscriptionPlanFeature,
|
||||
foreignKey: 'planId',
|
||||
otherKey: 'featureId'
|
||||
});
|
||||
Feature.belongsToMany(SubscriptionPlan, {
|
||||
through: SubscriptionPlanFeature,
|
||||
foreignKey: 'featureId',
|
||||
otherKey: 'planId'
|
||||
});
|
||||
|
||||
// ClubSubscription과 SubscriptionPlan의 관계
|
||||
ClubSubscription.belongsTo(SubscriptionPlan, { foreignKey: 'planId' });
|
||||
SubscriptionPlan.hasMany(ClubSubscription, { foreignKey: 'planId' });
|
||||
|
||||
// ClubSubscription과 Club의 관계
|
||||
ClubSubscription.belongsTo(Club, { foreignKey: 'clubId' });
|
||||
Club.hasMany(ClubSubscription, { foreignKey: 'clubId' });
|
||||
|
||||
// 모델 동기화 함수
|
||||
const syncModels = async () => {
|
||||
try {
|
||||
// 외래 키 제약 조건을 비활성화하고 테이블 재생성
|
||||
// 개발
|
||||
// await sequelize.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
// await sequelize.sync({ force: true });
|
||||
// await sequelize.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
// await createAdminUser();
|
||||
// await createSampleData();
|
||||
|
||||
// 운영 alter: true - 구조변경만 alter처리
|
||||
await sequelize.sync({ alter: true });
|
||||
} catch (error) {
|
||||
console.error('모델 동기화 중 오류 발생:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 관리자 계정 생성 함수
|
||||
const createAdminUser = async () => {
|
||||
try {
|
||||
const adminExists = await User.findOne({
|
||||
where: { email: process.env.ADMIN_EMAIL }
|
||||
});
|
||||
|
||||
if (!adminExists) {
|
||||
await User.create({
|
||||
username: 'master',
|
||||
name: '관리자',
|
||||
email: process.env.ADMIN_EMAIL,
|
||||
password: process.env.ADMIN_PASSWORD,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
console.log('관리자 계정이 생성되었습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('관리자 계정 생성 중 오류 발생:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 샘플 데이터 생성 함수
|
||||
const createSampleData = async () => {
|
||||
try {
|
||||
// 1. 기존 데이터 삭제 (순서 중요)
|
||||
await ClubFeature.destroy({ where: {} });
|
||||
await ClubSubscription.destroy({ where: {} });
|
||||
await Member.destroy({ where: {} });
|
||||
await Club.destroy({ where: {} });
|
||||
await Feature.destroy({ where: {} });
|
||||
await SubscriptionPlan.destroy({ where: {} });
|
||||
await User.destroy({ where: {} });
|
||||
console.log('기존 데이터가 모두 삭제되었습니다.');
|
||||
|
||||
// 2. 샘플 사용자 데이터 생성
|
||||
const sampleUsers = await User.bulkCreate([
|
||||
{
|
||||
username: 'hong',
|
||||
name: '홍길동',
|
||||
email: 'hong@test.com',
|
||||
password: '1234',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
username: 'kim',
|
||||
name: '김철수',
|
||||
email: 'kim@test.com',
|
||||
password: '1234',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
username: 'lee',
|
||||
name: '이영희',
|
||||
email: 'lee@test.com',
|
||||
password: '1234',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]);
|
||||
console.log('샘플 사용자 데이터가 생성되었습니다:', sampleUsers.map(user => user.id));
|
||||
|
||||
// 3. 샘플 클럽 데이터 생성
|
||||
const clubs = await Club.bulkCreate([
|
||||
{
|
||||
name: '서울 볼링 클럽',
|
||||
description: '서울 지역 볼링 동호회입니다.',
|
||||
location: '서울시 강남구',
|
||||
ownerId: sampleUsers[0].id,
|
||||
status: 'active',
|
||||
memberCount: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
name: '부산 볼링 클럽',
|
||||
description: '부산 지역 볼링 동호회입니다.',
|
||||
location: '부산시 해운대구',
|
||||
ownerId: sampleUsers[1].id,
|
||||
status: 'active',
|
||||
memberCount: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
name: '대전 볼링 클럽',
|
||||
description: '대전 지역 볼링 동호회입니다.',
|
||||
location: '대전시 유성구',
|
||||
ownerId: sampleUsers[2].id,
|
||||
status: 'active',
|
||||
memberCount: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]);
|
||||
console.log('샘플 클럽 데이터가 생성되었습니다:', clubs.map(club => club.id));
|
||||
|
||||
// 샘플 구독 플랜 데이터 생성
|
||||
const subscriptionPlans = [
|
||||
{
|
||||
name: 'Basic',
|
||||
description: '기본적인 클럽 관리 기능을 제공합니다.',
|
||||
price: 30000,
|
||||
duration: 1,
|
||||
maxMembers: 30,
|
||||
features: ['member_management', 'event_basic'],
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Premium',
|
||||
description: '고급 분석 기능과 함께 더 많은 회원을 관리할 수 있습니다.',
|
||||
price: 50000,
|
||||
duration: 1,
|
||||
maxMembers: 100,
|
||||
features: ['member_management', 'event_basic', 'analytics_basic', 'lane_booking'],
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
description: '무제한 회원 관리와 모든 프리미엄 기능을 제공합니다.',
|
||||
price: 100000,
|
||||
duration: 1,
|
||||
maxMembers: 999999,
|
||||
features: ['member_management', 'event_basic', 'analytics_basic', 'lane_booking', 'tournament', 'analytics_advanced'],
|
||||
status: 'active'
|
||||
}
|
||||
];
|
||||
|
||||
await SubscriptionPlan.bulkCreate(subscriptionPlans);
|
||||
console.log('샘플 구독 플랜 데이터가 생성되었습니다.');
|
||||
|
||||
// 샘플 클럽 구독 데이터 생성
|
||||
const clubSubscriptions = [
|
||||
{
|
||||
clubId: 1,
|
||||
planId: 3, // Enterprise 플랜
|
||||
startDate: new Date(),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 12)),
|
||||
amount: 1200000, // 연간 구독
|
||||
status: 'active',
|
||||
paymentStatus: 'paid'
|
||||
},
|
||||
{
|
||||
clubId: 2,
|
||||
planId: 2, // Premium 플랜
|
||||
startDate: new Date(),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 6)),
|
||||
amount: 300000, // 6개월 구독
|
||||
status: 'active',
|
||||
paymentStatus: 'paid'
|
||||
},
|
||||
{
|
||||
clubId: 3,
|
||||
planId: 1, // Basic 플랜
|
||||
startDate: new Date(),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 3)),
|
||||
amount: 90000, // 3개월 구독
|
||||
status: 'active',
|
||||
paymentStatus: 'paid'
|
||||
}
|
||||
];
|
||||
|
||||
await ClubSubscription.bulkCreate(clubSubscriptions);
|
||||
console.log('샘플 클럽 구독 데이터가 생성되었습니다.');
|
||||
|
||||
// 샘플 기능 데이터 생성
|
||||
const features = [
|
||||
{
|
||||
code: 'member_management',
|
||||
name: '회원 관리',
|
||||
description: '클럽 회원 관리 기능',
|
||||
purchaseType: 'subscription_only',
|
||||
monthlySubscriptionPrice: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
code: 'event_management',
|
||||
name: '이벤트 관리',
|
||||
description: '클럽 이벤트 관리 기능',
|
||||
purchaseType: 'subscription_only',
|
||||
monthlySubscriptionPrice: 5000,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
code: 'event_calendar',
|
||||
name: '이벤트 캘린더',
|
||||
description: '클럽 이벤트 캘린더 기능',
|
||||
purchaseType: 'subscription_only',
|
||||
monthlySubscriptionPrice: 5000,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
code: 'statistics',
|
||||
name: '통계',
|
||||
description: '클럽 통계 기능',
|
||||
purchaseType: 'subscription_only',
|
||||
monthlySubscriptionPrice: 10000,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
// 기능 데이터 생성
|
||||
await Feature.bulkCreate(features);
|
||||
console.log('샘플 기능 데이터가 생성되었습니다.');
|
||||
|
||||
// 샘플 클럽 회원 데이터 생성
|
||||
const members = [
|
||||
{
|
||||
clubId: 1,
|
||||
userId: 2,
|
||||
name: '홍길동',
|
||||
gender: '남성',
|
||||
memberType: '모임장',
|
||||
status: 'active',
|
||||
joinDate: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: 1,
|
||||
userId: 3,
|
||||
name: '김철수',
|
||||
gender: '남성',
|
||||
memberType: '정회원',
|
||||
status: 'active',
|
||||
joinDate: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: 2,
|
||||
userId: 3,
|
||||
name: '김철수',
|
||||
gender: '남성',
|
||||
memberType: '모임장',
|
||||
status: 'active',
|
||||
joinDate: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: 2,
|
||||
userId: 4,
|
||||
name: '이영희',
|
||||
gender: '여성',
|
||||
memberType: '정회원',
|
||||
status: 'active',
|
||||
joinDate: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: 3,
|
||||
userId: 4,
|
||||
name: '이영희',
|
||||
gender: '여성',
|
||||
memberType: '모임장',
|
||||
status: 'active',
|
||||
joinDate: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: 3,
|
||||
userId: 2,
|
||||
name: '홍길동',
|
||||
gender: '남성',
|
||||
memberType: '정회원',
|
||||
status: 'active',
|
||||
joinDate: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: 1,
|
||||
userId: null,
|
||||
name: '정회원A',
|
||||
gender: '여성',
|
||||
memberType: '정회원',
|
||||
status: 'active',
|
||||
joinDate: new Date('2023-01-15'),
|
||||
createdAt: new Date('2023-01-16'),
|
||||
updatedAt: new Date('2023-01-17')
|
||||
},
|
||||
{
|
||||
clubId: 2,
|
||||
userId: null,
|
||||
name: '준회원B',
|
||||
gender: '남성',
|
||||
memberType: '준회원',
|
||||
status: 'inactive',
|
||||
joinDate: new Date('2023-02-20'),
|
||||
createdAt: new Date('2023-02-21'),
|
||||
updatedAt: new Date('2023-02-22')
|
||||
},
|
||||
{
|
||||
clubId: 3,
|
||||
userId: null,
|
||||
name: '정회원C',
|
||||
gender: '여성',
|
||||
memberType: '정회원',
|
||||
status: 'suspended',
|
||||
joinDate: new Date('2023-03-10'),
|
||||
createdAt: new Date('2023-03-11'),
|
||||
updatedAt: new Date('2023-03-12')
|
||||
},
|
||||
{
|
||||
clubId: 1,
|
||||
userId: null,
|
||||
name: '준회원D',
|
||||
gender: '남성',
|
||||
memberType: '준회원',
|
||||
status: 'active',
|
||||
joinDate: new Date('2023-04-05'),
|
||||
createdAt: new Date('2023-04-06'),
|
||||
updatedAt: new Date('2023-04-07')
|
||||
},
|
||||
{
|
||||
clubId: 2,
|
||||
userId: null,
|
||||
name: '정회원E',
|
||||
gender: '여성',
|
||||
memberType: '정회원',
|
||||
status: 'suspended',
|
||||
joinDate: new Date('2023-05-15'),
|
||||
createdAt: new Date('2023-05-16'),
|
||||
updatedAt: new Date('2023-05-17')
|
||||
},
|
||||
{
|
||||
clubId: 3,
|
||||
userId: null,
|
||||
name: '준회원F',
|
||||
gender: '남성',
|
||||
memberType: '준회원',
|
||||
status: 'inactive',
|
||||
joinDate: new Date('2023-06-25'),
|
||||
createdAt: new Date('2023-06-26'),
|
||||
updatedAt: new Date('2023-06-27')
|
||||
},
|
||||
{
|
||||
clubId: 1,
|
||||
userId: null,
|
||||
name: '정회원G',
|
||||
gender: '여성',
|
||||
memberType: '정회원',
|
||||
status: 'active',
|
||||
joinDate: new Date('2023-07-30'),
|
||||
createdAt: new Date('2023-07-31'),
|
||||
updatedAt: new Date('2023-08-01')
|
||||
},
|
||||
{
|
||||
clubId: 2,
|
||||
userId: null,
|
||||
name: '준회원H',
|
||||
gender: '남성',
|
||||
memberType: '준회원',
|
||||
status: 'suspended',
|
||||
joinDate: new Date('2023-08-15'),
|
||||
createdAt: new Date('2023-08-16'),
|
||||
updatedAt: new Date('2023-08-17')
|
||||
}
|
||||
];
|
||||
|
||||
await Member.bulkCreate(members);
|
||||
console.log('샘플 클럽 회원 데이터가 생성되었습니다.');
|
||||
|
||||
// 샘플 클럽 기능 데이터 생성
|
||||
// 각 클럽의 기능을 개별적으로 생성
|
||||
// 클럽 1 (Enterprise)
|
||||
await ClubFeature.create({
|
||||
clubId: 1,
|
||||
featureId: 1,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 12)),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
await ClubFeature.create({
|
||||
clubId: 1,
|
||||
featureId: 2,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 12)),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
// 클럽 2 (Premium)
|
||||
await ClubFeature.create({
|
||||
clubId: 2,
|
||||
featureId: 1,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 6)),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
await ClubFeature.create({
|
||||
clubId: 2,
|
||||
featureId: 2,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 6)),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
// 클럽 3 (Basic)
|
||||
await ClubFeature.create({
|
||||
clubId: 3,
|
||||
featureId: 1,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 3)),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
console.log('샘플 클럽 기능 데이터가 생성되었습니다.');
|
||||
|
||||
// 샘플 메뉴 데이터 생성
|
||||
const menus = [
|
||||
// 클럽 관리 메뉴
|
||||
{
|
||||
title: '클럽 관리',
|
||||
icon: 'pi pi-building',
|
||||
to: '/club',
|
||||
role: 'user',
|
||||
order: 1,
|
||||
visible: true,
|
||||
children: [
|
||||
{
|
||||
title: '대시보드',
|
||||
icon: 'pi pi-home',
|
||||
to: '/club',
|
||||
role: 'user',
|
||||
order: 1,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '회원 관리',
|
||||
icon: 'pi pi-users',
|
||||
to: '/club/members',
|
||||
role: 'user',
|
||||
order: 2,
|
||||
visible: true,
|
||||
featureCode: 'member_management',
|
||||
requiredSubscriptionTier: 'basic'
|
||||
},
|
||||
{
|
||||
title: '이벤트 관리',
|
||||
icon: 'pi pi-calendar',
|
||||
to: '/club/events',
|
||||
role: 'user',
|
||||
order: 3,
|
||||
visible: true,
|
||||
featureCode: 'event_management',
|
||||
requiredSubscriptionTier: 'standard'
|
||||
},
|
||||
{
|
||||
title: '이벤트 캘린더',
|
||||
icon: 'pi pi-calendar-plus',
|
||||
to: '/club/events/calendar',
|
||||
role: 'user',
|
||||
order: 4,
|
||||
visible: true,
|
||||
featureCode: 'event_calendar',
|
||||
requiredSubscriptionTier: 'standard'
|
||||
},
|
||||
{
|
||||
title: '통계',
|
||||
icon: 'pi pi-chart-line',
|
||||
to: '/club/statistics',
|
||||
role: 'user',
|
||||
order: 5,
|
||||
visible: true,
|
||||
featureCode: 'statistics',
|
||||
requiredSubscriptionTier: 'premium'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 부모 메뉴 먼저 생성
|
||||
for (const menuData of menus) {
|
||||
const { children, ...parentData } = menuData;
|
||||
const parentMenu = await Menu.create(parentData);
|
||||
|
||||
// 자식 메뉴 생성 및 부모와 연결
|
||||
if (children) {
|
||||
for (const childData of children) {
|
||||
await Menu.create({
|
||||
...childData,
|
||||
parentId: parentMenu.id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('샘플 메뉴 데이터가 생성되었습니다.');
|
||||
|
||||
// 샘플 클럽 생성
|
||||
const clubsCount = await Club.count();
|
||||
if (clubsCount === 0) {
|
||||
// 3. 샘플 클럽 데이터 생성
|
||||
const clubs = await Club.bulkCreate([
|
||||
{
|
||||
name: '서울 볼링 클럽',
|
||||
description: '서울 지역 볼링 동호회입니다.',
|
||||
ownerId: sampleUsers[0].id,
|
||||
location: '서울시 강남구',
|
||||
status: 'active',
|
||||
memberCount: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
name: '부산 볼링 클럽',
|
||||
description: '부산 지역 볼링 동호회입니다.',
|
||||
ownerId: sampleUsers[1].id,
|
||||
location: '부산시 해운대구',
|
||||
status: 'active',
|
||||
memberCount: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
name: '대전 볼링 클럽',
|
||||
description: '대전 지역 볼링 동호회입니다.',
|
||||
ownerId: sampleUsers[2].id,
|
||||
location: '대전시 유성구',
|
||||
status: 'active',
|
||||
memberCount: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]);
|
||||
console.log('샘플 클럽 데이터가 생성되었습니다:', clubs.map(club => club.id));
|
||||
}
|
||||
|
||||
// 샘플 구독 플랜 생성
|
||||
const subscriptionPlansCount = await SubscriptionPlan.count();
|
||||
if (subscriptionPlansCount === 0) {
|
||||
const subscriptionPlans = [
|
||||
{
|
||||
name: 'Basic',
|
||||
description: '기본적인 클럽 관리 기능을 제공합니다.',
|
||||
price: 30000,
|
||||
duration: 1,
|
||||
maxMembers: 30,
|
||||
features: ['member_management', 'event_basic'],
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Premium',
|
||||
description: '고급 분석 기능과 함께 더 많은 회원을 관리할 수 있습니다.',
|
||||
price: 50000,
|
||||
duration: 1,
|
||||
maxMembers: 100,
|
||||
features: ['member_management', 'event_basic', 'analytics_basic', 'lane_booking'],
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
description: '무제한 회원 관리와 모든 프리미엄 기능을 제공합니다.',
|
||||
price: 100000,
|
||||
duration: 1,
|
||||
maxMembers: 999999, // 무제한을 표현하기 위해 큰 숫자 사용
|
||||
features: ['member_management', 'event_basic', 'analytics_basic', 'lane_booking', 'tournament', 'analytics_advanced'],
|
||||
status: 'active'
|
||||
}
|
||||
];
|
||||
|
||||
await SubscriptionPlan.bulkCreate(subscriptionPlans);
|
||||
console.log('샘플 구독 플랜 데이터가 생성되었습니다.');
|
||||
}
|
||||
|
||||
// 샘플 클럽 구독 데이터 생성
|
||||
const clubSubscriptionsCount = await ClubSubscription.count();
|
||||
if (clubSubscriptionsCount === 0) {
|
||||
const clubSubscriptions = [
|
||||
{
|
||||
clubId: 1,
|
||||
planId: 3, // Enterprise 플랜
|
||||
startDate: new Date(),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 12)),
|
||||
amount: 1200000, // 연간 구독
|
||||
status: 'active',
|
||||
paymentStatus: 'paid'
|
||||
},
|
||||
{
|
||||
clubId: 2,
|
||||
planId: 2, // Premium 플랜
|
||||
startDate: new Date(),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 6)),
|
||||
amount: 300000, // 6개월 구독
|
||||
status: 'active',
|
||||
paymentStatus: 'paid'
|
||||
},
|
||||
{
|
||||
clubId: 3,
|
||||
planId: 1, // Basic 플랜
|
||||
startDate: new Date(),
|
||||
endDate: new Date(new Date().setMonth(new Date().getMonth() + 3)),
|
||||
amount: 90000, // 3개월 구독
|
||||
status: 'active',
|
||||
paymentStatus: 'paid'
|
||||
}
|
||||
];
|
||||
|
||||
await ClubSubscription.bulkCreate(clubSubscriptions);
|
||||
console.log('샘플 클럽 구독 데이터가 생성되었습니다.');
|
||||
}
|
||||
|
||||
// 5. 샘플 기능 데이터 생성
|
||||
const createdFeatures = await Feature.bulkCreate([
|
||||
{
|
||||
name: '토너먼트 관리',
|
||||
description: '클럽 내 토너먼트 대회를 개최하고 관리할 수 있습니다.',
|
||||
code: 'tournament',
|
||||
purchaseType: 'both',
|
||||
price: 50000,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
name: '레인 예약',
|
||||
description: '볼링장 레인을 미리 예약하고 관리할 수 있습니다.',
|
||||
code: 'lane_booking',
|
||||
purchaseType: 'subscription_only',
|
||||
price: 30000,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
name: '점수 분석',
|
||||
description: '회원들의 경기 점수를 분석하고 통계를 제공합니다.',
|
||||
code: 'score_analysis',
|
||||
purchaseType: 'separate_only',
|
||||
price: 40000,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]);
|
||||
console.log('샘플 기능 데이터가 생성되었습니다:', createdFeatures.map(f => f.id));
|
||||
|
||||
// 6. 샘플 클럽 기능 데이터 생성
|
||||
const sampleClubs = await Club.findAll();
|
||||
|
||||
if (createdFeatures.length > 0 && sampleClubs.length > 0) {
|
||||
const clubFeatures = await ClubFeature.bulkCreate([
|
||||
{
|
||||
clubId: sampleClubs[0].id,
|
||||
featureId: createdFeatures[0].id,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 6)),
|
||||
amount: 50000,
|
||||
duration: 6,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: sampleClubs[1].id,
|
||||
featureId: createdFeatures[1].id,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 3)),
|
||||
amount: 30000,
|
||||
duration: 3,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
clubId: sampleClubs[2].id,
|
||||
featureId: createdFeatures[2].id,
|
||||
enabled: true,
|
||||
expiryDate: new Date(new Date().setMonth(new Date().getMonth() + 12)),
|
||||
amount: 40000,
|
||||
duration: 12,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]);
|
||||
console.log('샘플 클럽 기능 데이터가 생성되었습니다:', clubFeatures.map(cf => `${cf.clubId}-${cf.featureId}`));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('샘플 데이터 생성 중 오류 발생:', error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
User,
|
||||
Club,
|
||||
Event,
|
||||
EventParticipant,
|
||||
EventScore,
|
||||
Member,
|
||||
Notification,
|
||||
Feature,
|
||||
ClubFeature,
|
||||
Menu,
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanFeature,
|
||||
ClubSubscription,
|
||||
ActivityLog,
|
||||
syncModels,
|
||||
createAdminUser,
|
||||
createSampleData
|
||||
};
|
||||
Generated
+3753
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"body-parser": "^1.20.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mariadb": "^3.4.0",
|
||||
"mongoose": "^8.12.1",
|
||||
"morgan": "^1.10.0",
|
||||
"mysql2": "^3.13.0",
|
||||
"sequelize": "^6.37.6",
|
||||
"socket.io": "^4.8.1",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sequelize-cli": "^6.6.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/authController');
|
||||
|
||||
// 인증 미들웨어
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
|
||||
// 로그인
|
||||
router.post('/login', authController.login);
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
router.get('/me', authMiddleware, authController.getCurrentUser);
|
||||
|
||||
// 회원가입
|
||||
router.post('/register', authController.register);
|
||||
|
||||
// 프로필 업데이트
|
||||
router.put('/profile', authMiddleware, authController.updateProfile);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,61 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const jwt = require('jsonwebtoken');
|
||||
const clubController = require('../controllers/clubController');
|
||||
const { authenticateJWT } = require('../middleware/authMiddleware');
|
||||
|
||||
// 모든 클럽 조회
|
||||
router.get('/', clubController.getAllClubs);
|
||||
|
||||
// 특정 클럽 조회
|
||||
router.post('/', authenticateJWT, clubController.getClubById);
|
||||
|
||||
// 클럽 정보 업데이트
|
||||
router.put('/', authenticateJWT, clubController.updateClub);
|
||||
|
||||
// 클럽 삭제
|
||||
router.delete('/', authenticateJWT, clubController.deleteClub);
|
||||
|
||||
// 사용자 클럽 목록 조회
|
||||
router.post('/user', authenticateJWT, clubController.getUserClubs);
|
||||
|
||||
// 클럽 회원 통계 조회
|
||||
router.post('/members/stats', authenticateJWT, clubController.getMemberStats);
|
||||
|
||||
// 클럽 회원 목록 조회
|
||||
router.post('/members', authenticateJWT, clubController.getClubMembers);
|
||||
|
||||
// 클럽 회원 추가
|
||||
router.post('/members/add', authenticateJWT, clubController.addClubMember);
|
||||
|
||||
// 클럽 회원 수정
|
||||
router.post('/members/update', authenticateJWT, clubController.updateClubMember);
|
||||
|
||||
// 클럽 회원 삭제
|
||||
router.post('/members/delete', authenticateJWT, clubController.deleteClubMember);
|
||||
|
||||
// 다음 모임 정보 조회
|
||||
router.post('/meetings/next', authenticateJWT, clubController.getNextMeeting);
|
||||
|
||||
// 최근 모임 정보 조회
|
||||
router.post('/meetings/last', authenticateJWT, clubController.getLastMeeting);
|
||||
|
||||
// 최근 정기 모임 목록 조회
|
||||
router.post('/meetings/recent', authenticateJWT, clubController.getRecentMeetings);
|
||||
|
||||
// 점수 통계 조회
|
||||
router.post('/scores/stats', authenticateJWT, clubController.getScoreStats);
|
||||
|
||||
// 상위 회원 목록 조회
|
||||
router.post('/members/top', authenticateJWT, clubController.getTopMembers);
|
||||
|
||||
// 새 클럽 생성
|
||||
router.post('/create', authenticateJWT, clubController.createClub);
|
||||
|
||||
// 클럽 통계 조회
|
||||
router.post('/statistics', authenticateJWT, clubController.getStatistics);
|
||||
|
||||
// 클럽 기능 관리
|
||||
router.put('/features', authenticateJWT, clubController.manageClubFeatures);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,29 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const eventController = require('../controllers/eventController');
|
||||
const { authenticateJWT } = require('../middleware/authMiddleware');
|
||||
|
||||
// 이벤트 기본 CRUD
|
||||
router.post('/', authenticateJWT, eventController.getAllEvents);
|
||||
router.get('/:id', authenticateJWT, eventController.getEventById);
|
||||
router.post('/create', authenticateJWT, eventController.createEvent);
|
||||
router.put('/:id', authenticateJWT, eventController.updateEvent);
|
||||
router.delete('/:id', authenticateJWT, eventController.deleteEvent);
|
||||
|
||||
// 이벤트 참가자 관리
|
||||
router.post('/:id/participants/list', authenticateJWT, eventController.getEventParticipants);
|
||||
router.post('/:id/participants', authenticateJWT, eventController.addEventParticipant);
|
||||
router.put('/:id/participants', authenticateJWT, eventController.updateEventParticipant);
|
||||
router.delete('/:id/participants', authenticateJWT, eventController.deleteEventParticipant);
|
||||
|
||||
// 이벤트 점수 관리
|
||||
router.get('/:id/scores', authenticateJWT, eventController.getEventScores);
|
||||
router.post('/:id/scores', authenticateJWT, eventController.addEventScore);
|
||||
router.put('/:id/scores/:scoreId', authenticateJWT, eventController.updateEventScore);
|
||||
router.delete('/:id/scores/:scoreId', authenticateJWT, eventController.deleteEventScore);
|
||||
router.delete('/:id/participants/:participantId/scores', authenticateJWT, eventController.deleteParticipantScores);
|
||||
|
||||
// 사용자별 이벤트 관리
|
||||
router.get('/user/events', authenticateJWT, eventController.getUserEvents);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const featureController = require('../controllers/featureController');
|
||||
const { authenticateJWT } = require('../middleware/authMiddleware');
|
||||
|
||||
// 기본 기능 관리
|
||||
router.get('/', authenticateJWT, featureController.getFeatures);
|
||||
|
||||
// 기능 구매 관련 API
|
||||
router.get('/available', authenticateJWT, featureController.getAvailableFeatures);
|
||||
router.get('/active', authenticateJWT, featureController.getActiveFeatures);
|
||||
router.get('/payments', authenticateJWT, featureController.getFeaturePayments);
|
||||
router.post('/purchase', authenticateJWT, featureController.purchaseFeature);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 메뉴 라우트
|
||||
* 사용자 역할과 클럽의 구독/기능 상태에 따른 메뉴 아이템을 제공하는 API
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authenticateJWT } = require('../middleware/authMiddleware');
|
||||
const menuController = require('../controllers/menuController');
|
||||
|
||||
// 사용자의 접근 가능한 메뉴 목록 조회
|
||||
router.post('/', authenticateJWT, menuController.getUserMenus);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 알림 라우트
|
||||
* 사용자 알림 관련 API
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const notificationController = require('../controllers/notificationController');
|
||||
const { check } = require('express-validator');
|
||||
const { authenticateJWT } = require('../middleware/authMiddleware');
|
||||
|
||||
// 사용자 알림 목록 가져오기
|
||||
router.get('/', authenticateJWT, notificationController.getUserNotifications);
|
||||
|
||||
// 알림 읽음 표시
|
||||
router.put('/:notificationId/read', authenticateJWT, notificationController.markAsRead);
|
||||
|
||||
// 모든 알림 읽음 표시
|
||||
router.put('/read-all', authenticateJWT, notificationController.markAllAsRead);
|
||||
|
||||
// 알림 삭제
|
||||
router.delete('/:notificationId', authenticateJWT, notificationController.deleteNotification);
|
||||
|
||||
// 알림 생성 (관리자 전용)
|
||||
router.post('/',
|
||||
authenticateJWT,
|
||||
[
|
||||
check('userId', '사용자 ID가 필요합니다.').not().isEmpty(),
|
||||
check('title', '알림 제목이 필요합니다.').not().isEmpty(),
|
||||
check('message', '알림 내용이 필요합니다.').not().isEmpty()
|
||||
],
|
||||
notificationController.createNotification
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const subscriptionController = require('../controllers/subscriptionController');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
// 구독 목록 조회
|
||||
router.get('/', authMiddleware, subscriptionController.getClubSubscriptions);
|
||||
|
||||
// 구독 플랜 관리 (관리자 전용)
|
||||
router.get('/plans', authMiddleware, subscriptionController.getSubscriptionPlans);
|
||||
router.post('/plans', authMiddleware, subscriptionController.createSubscriptionPlan);
|
||||
router.put('/plans/:id', authMiddleware, subscriptionController.updateSubscriptionPlan);
|
||||
router.delete('/plans/:id', authMiddleware, subscriptionController.deleteSubscriptionPlan);
|
||||
router.get('/plans/:planId/subscriptions', authMiddleware, subscriptionController.getPlanSubscriptions);
|
||||
|
||||
// 클럽 구독 관리
|
||||
router.post('/subscribe', authMiddleware, subscriptionController.subscribeClub);
|
||||
router.get('/clubs/:clubId', authMiddleware, subscriptionController.getClubSubscription);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 사용자 라우트
|
||||
* 사용자 관련 API 엔드포인트 정의
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getCurrentUser } = require('../controllers/authController');
|
||||
const { getAllUsers, getUser, updateUser } = require('../controllers/userController');
|
||||
const { authenticateJWT } = require('../middleware/authMiddleware');
|
||||
|
||||
// 전체 사용자 목록 조회
|
||||
router.get('/all', authenticateJWT, getAllUsers);
|
||||
|
||||
// 사용자 프로필 조회
|
||||
router.get('/profile', authenticateJWT, getCurrentUser);
|
||||
|
||||
// 단일 사용자 정보 조회
|
||||
router.get('/:id', authenticateJWT, getUser);
|
||||
|
||||
// 사용자 정보 수정
|
||||
router.put('/:id', authenticateJWT, updateUser);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,38 @@
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
async function checkDatabaseTables() {
|
||||
try {
|
||||
// 데이터베이스 연결
|
||||
await sequelize.authenticate();
|
||||
console.log('데이터베이스 연결 성공');
|
||||
|
||||
// 모든 테이블 목록 조회
|
||||
const [tables] = await sequelize.query('SHOW TABLES');
|
||||
|
||||
console.log('데이터베이스 테이블 목록:');
|
||||
tables.forEach(table => {
|
||||
const tableName = Object.values(table)[0];
|
||||
console.log(`- ${tableName}`);
|
||||
});
|
||||
|
||||
// 각 테이블의 구조 확인
|
||||
for (const table of tables) {
|
||||
const tableName = Object.values(table)[0];
|
||||
const [columns] = await sequelize.query(`DESCRIBE \`${tableName}\``);
|
||||
|
||||
console.log(`\n테이블: ${tableName}`);
|
||||
columns.forEach(column => {
|
||||
console.log(` - ${column.Field}: ${column.Type} ${column.Null === 'NO' ? 'NOT NULL' : 'NULL'} ${column.Key === 'PRI' ? 'PRIMARY KEY' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('오류 발생:', error);
|
||||
} finally {
|
||||
// 연결 종료
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
checkDatabaseTables();
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 기본 클럽 생성 스크립트
|
||||
* 데이터베이스에 기본 클럽을 생성하고 사용자에게 할당합니다.
|
||||
*/
|
||||
const { sequelize } = require('../src/config/database');
|
||||
const Club = require('../src/models/Club');
|
||||
const User = require('../src/models/User');
|
||||
const UserClub = require('../src/models/UserClub');
|
||||
require('dotenv').config();
|
||||
|
||||
async function createDefaultClub() {
|
||||
try {
|
||||
// 데이터베이스 연결 확인
|
||||
await sequelize.authenticate();
|
||||
console.log('데이터베이스 연결 성공');
|
||||
|
||||
// 관리자 사용자 찾기
|
||||
const adminUser = await User.findOne({
|
||||
where: {
|
||||
role: 'admin'
|
||||
}
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
console.log('관리자 사용자를 찾을 수 없습니다. 기본 클럽을 생성할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('관리자 사용자 찾음:', adminUser.email);
|
||||
|
||||
// 기존 클럽 확인
|
||||
const existingClubs = await Club.findAll();
|
||||
|
||||
let defaultClub;
|
||||
|
||||
if (existingClubs.length === 0) {
|
||||
// 기본 클럽 생성
|
||||
defaultClub = await Club.create({
|
||||
name: '볼링 마스터 클럽',
|
||||
description: '볼링 클럽 매니저 기본 클럽입니다.',
|
||||
ownerId: adminUser.id,
|
||||
active: true,
|
||||
subscriptionStatus: '활성',
|
||||
subscriptionType: '기본 구독',
|
||||
subscriptionExpiry: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1년 후
|
||||
memberCount: 1
|
||||
});
|
||||
console.log('기본 클럽 생성 완료:', defaultClub.name);
|
||||
} else {
|
||||
defaultClub = existingClubs[0];
|
||||
console.log('기존 클럽 사용:', defaultClub.name);
|
||||
}
|
||||
|
||||
// 모든 사용자 가져오기
|
||||
const users = await User.findAll();
|
||||
|
||||
// 각 사용자에게 클럽 할당
|
||||
for (const user of users) {
|
||||
// 이미 클럽에 속해 있는지 확인
|
||||
const existingUserClub = await UserClub.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
clubId: defaultClub.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingUserClub) {
|
||||
// 사용자-클럽 관계 생성
|
||||
await UserClub.create({
|
||||
userId: user.id,
|
||||
clubId: defaultClub.id,
|
||||
role: user.role === 'admin' || user.role === 'superadmin' ? 'admin' : 'member',
|
||||
joinDate: new Date(),
|
||||
status: 'active'
|
||||
});
|
||||
console.log(`사용자 ${user.email}에게 클럽 할당 완료`);
|
||||
} else {
|
||||
console.log(`사용자 ${user.email}는 이미 클럽에 속해 있습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('기본 클럽 생성 및 사용자 할당이 완료되었습니다.');
|
||||
console.log('기본 클럽 ID:', defaultClub.id);
|
||||
|
||||
return defaultClub.id;
|
||||
} catch (error) {
|
||||
console.error('기본 클럽 생성 중 오류 발생:', error);
|
||||
} finally {
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
createDefaultClub();
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 메뉴 생성 스크립트
|
||||
* 데이터베이스에 기본 메뉴 항목을 생성합니다.
|
||||
*/
|
||||
const { Menu } = require('../src/models');
|
||||
const { sequelize } = require('../src/config/database');
|
||||
require('dotenv').config();
|
||||
|
||||
// 관리자용 메뉴 항목
|
||||
const adminMenus = [
|
||||
{
|
||||
title: '대시보드',
|
||||
icon: 'pi pi-home',
|
||||
to: '/admin',
|
||||
role: 'admin',
|
||||
order: 1,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '클럽 관리',
|
||||
icon: 'pi pi-users',
|
||||
to: '/admin/clubs',
|
||||
role: 'admin',
|
||||
order: 2,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '사용자 관리',
|
||||
icon: 'pi pi-user',
|
||||
to: '/admin/users',
|
||||
role: 'admin',
|
||||
order: 3,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '구독 관리',
|
||||
icon: 'pi pi-credit-card',
|
||||
to: '/admin/subscriptions',
|
||||
role: 'admin',
|
||||
order: 4,
|
||||
visible: true
|
||||
}
|
||||
];
|
||||
|
||||
// 일반 사용자용 메뉴 항목
|
||||
const userMenus = [
|
||||
{
|
||||
title: '대시보드',
|
||||
icon: 'pi pi-home',
|
||||
to: '/clubs',
|
||||
role: 'user',
|
||||
order: 1,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '회원 관리',
|
||||
icon: 'pi pi-users',
|
||||
to: '/clubs/members',
|
||||
role: 'user',
|
||||
order: 2,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '모임 관리',
|
||||
icon: 'pi pi-calendar',
|
||||
to: '/clubs/meetings',
|
||||
role: 'user',
|
||||
order: 3,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '점수 관리',
|
||||
icon: 'pi pi-chart-bar',
|
||||
to: '/clubs/meetings/scores',
|
||||
role: 'user',
|
||||
order: 4,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '통계',
|
||||
icon: 'pi pi-chart-line',
|
||||
to: '/statistics',
|
||||
role: 'user',
|
||||
order: 5,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '예약',
|
||||
icon: 'pi pi-bookmark',
|
||||
to: '/clubs/reservations',
|
||||
role: 'user',
|
||||
order: 6,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '이벤트 관리',
|
||||
icon: 'pi pi-list',
|
||||
to: '/clubs/events',
|
||||
role: 'user',
|
||||
order: 7,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
title: '이벤트 캘린더',
|
||||
icon: 'pi pi-calendar',
|
||||
to: '/clubs/events/calendar',
|
||||
role: 'user',
|
||||
order: 8,
|
||||
visible: true
|
||||
}
|
||||
];
|
||||
|
||||
// 메뉴 생성 함수
|
||||
async function createMenus() {
|
||||
try {
|
||||
// 데이터베이스 연결 확인
|
||||
await sequelize.authenticate();
|
||||
console.log('데이터베이스 연결 성공');
|
||||
|
||||
// 메뉴 테이블 동기화
|
||||
await Menu.sync({ alter: true });
|
||||
console.log('메뉴 테이블 동기화 완료');
|
||||
|
||||
// 기존 메뉴 항목 확인
|
||||
const existingMenus = await Menu.findAll();
|
||||
|
||||
if (existingMenus.length > 0) {
|
||||
console.log('메뉴 항목이 이미 존재합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 관리자용 메뉴 생성
|
||||
for (const menu of adminMenus) {
|
||||
await Menu.create(menu);
|
||||
}
|
||||
console.log('관리자용 메뉴 생성 완료');
|
||||
|
||||
// 일반 사용자용 메뉴 생성
|
||||
for (const menu of userMenus) {
|
||||
await Menu.create(menu);
|
||||
}
|
||||
console.log('일반 사용자용 메뉴 생성 완료');
|
||||
|
||||
console.log('모든 메뉴 항목이 성공적으로 생성되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('메뉴 생성 중 오류 발생:', error);
|
||||
} finally {
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
createMenus();
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 초기 알림 데이터 생성 스크립트
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const User = require('../models/User');
|
||||
const Notification = require('../models/Notification');
|
||||
|
||||
// MongoDB 연결
|
||||
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/bowlingManager', {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
})
|
||||
.then(() => console.log('MongoDB 연결 성공'))
|
||||
.catch(err => {
|
||||
console.error('MongoDB 연결 오류:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 초기 알림 데이터 생성
|
||||
const createNotifications = async () => {
|
||||
try {
|
||||
// 모든 사용자 가져오기
|
||||
const users = await User.find();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('사용자가 없습니다. 먼저 사용자를 생성해주세요.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 기존 알림 삭제
|
||||
await Notification.deleteMany({});
|
||||
console.log('기존 알림 데이터 삭제 완료');
|
||||
|
||||
// 각 사용자에 대한 알림 생성
|
||||
const notificationPromises = users.map(async (user) => {
|
||||
// 알림 타입 및 메시지 배열
|
||||
const notificationTemplates = [
|
||||
{
|
||||
title: '시스템 알림',
|
||||
message: '볼링 클럽 매니저 시스템에 오신 것을 환영합니다.',
|
||||
icon: 'pi pi-info-circle',
|
||||
type: 'info',
|
||||
read: false
|
||||
},
|
||||
{
|
||||
title: '클럽 알림',
|
||||
message: '새로운 클럽 회원이 가입했습니다.',
|
||||
icon: 'pi pi-users',
|
||||
type: 'success',
|
||||
read: false
|
||||
},
|
||||
{
|
||||
title: '예약 알림',
|
||||
message: '레인 예약이 확정되었습니다.',
|
||||
icon: 'pi pi-calendar',
|
||||
type: 'info',
|
||||
read: true
|
||||
},
|
||||
{
|
||||
title: '대회 알림',
|
||||
message: '다음 주 토요일 클럽 대회가 예정되어 있습니다.',
|
||||
icon: 'pi pi-flag',
|
||||
type: 'warning',
|
||||
read: false
|
||||
},
|
||||
{
|
||||
title: '시스템 업데이트',
|
||||
message: '시스템이 업데이트 되었습니다. 새로운 기능을 확인해보세요.',
|
||||
icon: 'pi pi-refresh',
|
||||
type: 'info',
|
||||
read: true
|
||||
}
|
||||
];
|
||||
|
||||
// 사용자별 알림 생성 (시간 차이를 두고)
|
||||
const notifications = notificationTemplates.map((template, index) => {
|
||||
const hoursAgo = (index + 1) * 12; // 12시간, 24시간, 36시간... 간격
|
||||
const createdAt = new Date(Date.now() - (hoursAgo * 3600000));
|
||||
|
||||
return new Notification({
|
||||
user: user._id,
|
||||
title: template.title,
|
||||
message: template.message,
|
||||
icon: template.icon,
|
||||
type: template.type,
|
||||
read: template.read,
|
||||
createdAt
|
||||
});
|
||||
});
|
||||
|
||||
return Notification.insertMany(notifications);
|
||||
});
|
||||
|
||||
await Promise.all(notificationPromises);
|
||||
console.log('알림 데이터 생성 완료');
|
||||
|
||||
// 연결 종료
|
||||
mongoose.connection.close();
|
||||
console.log('MongoDB 연결 종료');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('알림 데이터 생성 오류:', error);
|
||||
mongoose.connection.close();
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
createNotifications();
|
||||
@@ -0,0 +1,46 @@
|
||||
require('dotenv').config();
|
||||
const { User, syncModels, testConnection } = require('../src/models');
|
||||
|
||||
async function createSuperAdmin() {
|
||||
try {
|
||||
// 데이터베이스 연결 테스트
|
||||
console.log('데이터베이스 연결 테스트 중...');
|
||||
await testConnection();
|
||||
|
||||
// 모델 동기화 (필요한 경우)
|
||||
// console.log('데이터베이스 모델 동기화 중...');
|
||||
// await syncModels();
|
||||
|
||||
// 기존 superadmin 계정 확인
|
||||
const existingAdmin = await User.findOne({
|
||||
where: { email: process.env.ADMIN_EMAIL }
|
||||
});
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log('이미 superadmin 계정이 존재합니다:', existingAdmin.email);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// superadmin 계정 생성
|
||||
const superAdmin = await User.create({
|
||||
username: 'master',
|
||||
password: process.env.ADMIN_PASSWORD,
|
||||
name: '최고관리자',
|
||||
email: process.env.ADMIN_EMAIL,
|
||||
role: 'superadmin',
|
||||
active: true
|
||||
});
|
||||
|
||||
console.log('superadmin 계정이 성공적으로 생성되었습니다:');
|
||||
console.log('- 이메일:', superAdmin.email);
|
||||
console.log('- 역할:', superAdmin.role);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('superadmin 계정 생성 중 오류 발생:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
createSuperAdmin();
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
// 관리자 계정 비밀번호 해시 생성
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash('Dlrwns0304#', salt);
|
||||
|
||||
// 관리자 계정 생성
|
||||
await queryInterface.bulkInsert('users', [{
|
||||
name: '관리자',
|
||||
email: 'master@jaybe.dev',
|
||||
password: hashedPassword,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}], {});
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
// 관리자 계정 삭제
|
||||
await queryInterface.bulkDelete('users', { email: 'master@jaybe.dev' }, {});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// 데이터베이스 모델 동기화 스크립트
|
||||
const { sequelize, syncModels, testConnection } = require('./src/models');
|
||||
|
||||
async function initializeDatabase() {
|
||||
try {
|
||||
// 데이터베이스 연결 테스트
|
||||
console.log('데이터베이스 연결 테스트 중...');
|
||||
await testConnection();
|
||||
|
||||
// 모델 동기화 (테이블 생성)
|
||||
console.log('데이터베이스 모델 동기화 중...');
|
||||
await syncModels();
|
||||
|
||||
console.log('데이터베이스 초기화가 완료되었습니다.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('데이터베이스 초기화 중 오류 발생:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
initializeDatabase();
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 알림 생성 예제 유틸리티
|
||||
* 다양한 상황에서 알림을 생성하는 예제 함수들을 제공합니다.
|
||||
*/
|
||||
|
||||
const notificationController = require('../controllers/notificationController');
|
||||
|
||||
/**
|
||||
* 새 회원 가입 알림 생성
|
||||
* @param {Object} clubOwner 클럽 소유자 정보
|
||||
* @param {Object} newMember 새로 가입한 회원 정보
|
||||
*/
|
||||
exports.newMemberJoinNotification = async (clubOwner, newMember) => {
|
||||
await notificationController.createSystemNotification(
|
||||
clubOwner.id,
|
||||
'새 회원 가입',
|
||||
`${newMember.name}님이 클럽에 가입했습니다.`,
|
||||
{
|
||||
icon: 'pi pi-users',
|
||||
type: 'success',
|
||||
link: `/club/members/${newMember.id}`
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모임 생성 알림 생성
|
||||
* @param {Array} members 알림을 받을 회원 목록
|
||||
* @param {Object} meeting 생성된 모임 정보
|
||||
*/
|
||||
exports.newMeetingNotification = async (members, meeting) => {
|
||||
for (const member of members) {
|
||||
await notificationController.createSystemNotification(
|
||||
member.id,
|
||||
'새 모임 생성',
|
||||
`새로운 모임 "${meeting.title}"이(가) 생성되었습니다. (${meeting.date})`,
|
||||
{
|
||||
icon: 'pi pi-calendar',
|
||||
type: 'info',
|
||||
link: `/meetings/${meeting.id}`
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 점수 등록 알림 생성
|
||||
* @param {Object} member 점수를 등록한 회원
|
||||
* @param {Object} score 등록된 점수 정보
|
||||
*/
|
||||
exports.scoreRegisteredNotification = async (member, score) => {
|
||||
await notificationController.createSystemNotification(
|
||||
member.id,
|
||||
'점수 등록 완료',
|
||||
`게임 점수 ${score.score}점이 등록되었습니다.`,
|
||||
{
|
||||
icon: 'pi pi-chart-bar',
|
||||
type: 'success',
|
||||
link: `/scores/${score.id}`
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 구독 만료 예정 알림 생성
|
||||
* @param {Object} clubOwner 클럽 소유자 정보
|
||||
* @param {Object} subscription 구독 정보
|
||||
* @param {Number} daysLeft 만료까지 남은 일수
|
||||
*/
|
||||
exports.subscriptionExpiryNotification = async (clubOwner, subscription, daysLeft) => {
|
||||
await notificationController.createSystemNotification(
|
||||
clubOwner.id,
|
||||
'구독 만료 예정',
|
||||
`클럽 구독이 ${daysLeft}일 후에 만료됩니다. 갱신하시려면 결제를 진행해주세요.`,
|
||||
{
|
||||
icon: 'pi pi-clock',
|
||||
type: 'warning',
|
||||
link: '/subscription'
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 시스템 유지보수 알림 생성
|
||||
* @param {Array} users 알림을 받을 사용자 목록
|
||||
* @param {String} maintenanceDate 유지보수 일자
|
||||
* @param {String} maintenanceTime 유지보수 시간
|
||||
*/
|
||||
exports.systemMaintenanceNotification = async (users, maintenanceDate, maintenanceTime) => {
|
||||
for (const user of users) {
|
||||
await notificationController.createSystemNotification(
|
||||
user.id,
|
||||
'시스템 유지보수 안내',
|
||||
`${maintenanceDate} ${maintenanceTime}에 시스템 유지보수가 예정되어 있습니다. 서비스 이용에 참고해주세요.`,
|
||||
{
|
||||
icon: 'pi pi-info-circle',
|
||||
type: 'info'
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 결제 완료 알림 생성
|
||||
* @param {Object} user 결제한 사용자 정보
|
||||
* @param {Object} payment 결제 정보
|
||||
*/
|
||||
exports.paymentCompletedNotification = async (user, payment) => {
|
||||
await notificationController.createSystemNotification(
|
||||
user.id,
|
||||
'결제 완료',
|
||||
`${payment.amount.toLocaleString()}원 결제가 완료되었습니다.`,
|
||||
{
|
||||
icon: 'pi pi-credit-card',
|
||||
type: 'success',
|
||||
link: '/payments/history'
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 대회 일정 알림 생성
|
||||
* @param {Array} participants 대회 참가자 목록
|
||||
* @param {Object} tournament 대회 정보
|
||||
*/
|
||||
exports.tournamentReminderNotification = async (participants, tournament) => {
|
||||
for (const participant of participants) {
|
||||
await notificationController.createSystemNotification(
|
||||
participant.id,
|
||||
'대회 일정 안내',
|
||||
`내일 "${tournament.title}" 대회가 ${tournament.location}에서 개최됩니다.`,
|
||||
{
|
||||
icon: 'pi pi-flag',
|
||||
type: 'warning',
|
||||
link: `/tournaments/${tournament.id}`
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 오류 발생 알림 생성 (관리자용)
|
||||
* @param {Array} admins 알림을 받을 관리자 목록
|
||||
* @param {String} errorMessage 오류 메시지
|
||||
* @param {String} errorLocation 오류 발생 위치
|
||||
*/
|
||||
exports.systemErrorNotification = async (admins, errorMessage, errorLocation) => {
|
||||
for (const admin of admins) {
|
||||
await notificationController.createSystemNotification(
|
||||
admin.id,
|
||||
'시스템 오류 발생',
|
||||
`위치: ${errorLocation}\n오류: ${errorMessage}`,
|
||||
{
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
type: 'error',
|
||||
link: '/admin/logs'
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Generated
+3528
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>© {{ 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="logo">
|
||||
<h1>볼링 클럽 매니저</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoggedIn" class="header-actions">
|
||||
<!-- 관리자 메뉴 -->
|
||||
<div v-if="isAdmin" class="admin-menu-wrapper">
|
||||
<Button @click="toggleAdminMenu" class="admin-menu-button p-button-outlined">
|
||||
<i class="pi pi-cog"></i>
|
||||
</Button>
|
||||
<Menu ref="adminMenu" :model="adminMenuItems" :popup="true" />
|
||||
</div>
|
||||
<!-- 알림 버튼 -->
|
||||
<div class="notification-wrapper">
|
||||
<Button icon="pi pi-bell" class="notification-button p-button-outlined" @click="toggleNotifications"
|
||||
:badge="unreadNotificationsCount > 0 ? unreadNotificationsCount.toString() : undefined"
|
||||
badgeClass="p-badge-danger" />
|
||||
<div v-if="showNotifications" class="notifications-panel">
|
||||
<div class="notifications-header">
|
||||
<h3>알림</h3>
|
||||
<Button icon="pi pi-times" class="p-button-text p-button-rounded" @click="showNotifications = false" />
|
||||
</div>
|
||||
<div class="notifications-content">
|
||||
<div v-if="notifications.length > 0">
|
||||
<div v-for="(notification, index) in notifications" :key="index"
|
||||
class="notification-item"
|
||||
:class="{ 'unread': !notification.read }"
|
||||
@click="handleNotificationClick(notification)">
|
||||
<i :class="notification.icon"></i>
|
||||
<div class="notification-details">
|
||||
<div class="notification-title">{{ notification.title }}</div>
|
||||
<div class="notification-message">{{ notification.message }}</div>
|
||||
<div class="notification-time">{{ notification.time }}</div>
|
||||
</div>
|
||||
<Button icon="pi pi-trash" class="p-button-text p-button-rounded" @click="deleteNotification(notification.id, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-notifications">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<p>새로운 알림이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notifications-footer">
|
||||
<Button label="모든 알림 읽음 표시" class="p-button-text" @click="markAllAsRead" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 사용자 메뉴 -->
|
||||
<div class="user-menu-wrapper">
|
||||
<Button @click="toggleUserMenu" class="user-menu-button p-button-outlined">
|
||||
<Avatar :image="userAvatar" :label="userInitials" size="normal" shape="circle" />
|
||||
<span class="user-name hide-on-mobile">{{ userName }}</span>
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
</Button>
|
||||
<Menu ref="userMenu" :model="userMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config';
|
||||
import Button from 'primevue/button';
|
||||
import Menu from 'primevue/menu';
|
||||
import Avatar from 'primevue/avatar';
|
||||
|
||||
const router = useRouter();
|
||||
const userMenu = ref(null);
|
||||
const adminMenu = ref(null);
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('token'));
|
||||
const isAdmin = computed(() => {
|
||||
const role = localStorage.getItem('userRole');
|
||||
return role === 'admin' || role === 'superadmin';
|
||||
});
|
||||
|
||||
// 사용자 정보 (App.vue에서 공유)
|
||||
const user = inject('user');
|
||||
|
||||
// 사용자 정보 computed 속성
|
||||
const userName = computed(() => user.value?.name || '사용자');
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value?.name) return '';
|
||||
return user.value.name.split(' ').map(n => n[0]).join('');
|
||||
});
|
||||
const userAvatar = computed(() => user.value?.avatar || '');
|
||||
|
||||
// 로그아웃
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('clubId');
|
||||
localStorage.removeItem('userRole');
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
// 알림 관련 상태
|
||||
const showNotifications = ref(false);
|
||||
const notifications = ref([]);
|
||||
const unreadNotificationsCount = ref(0);
|
||||
|
||||
// 알림 가져오기
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await axios.get(`${API_URL}/notifications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
notifications.value = response.data.map(notification => {
|
||||
const createdAt = new Date(notification.createdAt);
|
||||
const now = new Date();
|
||||
const diffMs = now - createdAt;
|
||||
const diffMins = Math.round(diffMs / 60000);
|
||||
const diffHours = Math.round(diffMs / 3600000);
|
||||
const diffDays = Math.round(diffMs / 86400000);
|
||||
|
||||
let timeText = '';
|
||||
if (diffMins < 60) {
|
||||
timeText = `${diffMins}분 전`;
|
||||
} else if (diffHours < 24) {
|
||||
timeText = `${diffHours}시간 전`;
|
||||
} else {
|
||||
timeText = `${diffDays}일 전`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: notification._id,
|
||||
icon: notification.icon || 'pi pi-info-circle',
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
time: timeText,
|
||||
read: notification.read,
|
||||
link: notification.link
|
||||
};
|
||||
});
|
||||
|
||||
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('알림 가져오기 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 모든 알림 읽음 표시
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
await axios.put(`${API_URL}/notifications/read-all`, {}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
notifications.value = notifications.value.map(notification => ({
|
||||
...notification,
|
||||
read: true
|
||||
}));
|
||||
|
||||
unreadNotificationsCount.value = 0;
|
||||
} catch (error) {
|
||||
console.error('모든 알림 읽음 표시 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 단일 알림 읽음 표시
|
||||
const markAsRead = async (notificationId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
await axios.put(`${API_URL}/notifications/${notificationId}/read`, {}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const index = notifications.value.findIndex(n => n.id === notificationId);
|
||||
if (index !== -1) {
|
||||
notifications.value[index].read = true;
|
||||
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('알림 읽음 표시 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 클릭 처리
|
||||
const handleNotificationClick = (notification) => {
|
||||
if (!notification.read) {
|
||||
markAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.link) {
|
||||
router.push(notification.link);
|
||||
showNotifications.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 삭제
|
||||
const deleteNotification = async (notificationId, event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
await axios.delete(`${API_URL}/notifications/${notificationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
notifications.value = notifications.value.filter(n => n.id !== notificationId);
|
||||
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
|
||||
} catch (error) {
|
||||
console.error('알림 삭제 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 관리자 메뉴 아이템
|
||||
const adminMenuItems = ref([
|
||||
{
|
||||
label: '대시보드',
|
||||
icon: 'pi pi-home',
|
||||
command: () => {
|
||||
router.push('/admin');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '클럽 관리',
|
||||
icon: 'pi pi-users',
|
||||
command: () => {
|
||||
router.push('/admin/clubs');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '사용자 관리',
|
||||
icon: 'pi pi-user',
|
||||
command: () => {
|
||||
router.push('/admin/users');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '구독 관리',
|
||||
icon: 'pi pi-credit-card',
|
||||
command: () => {
|
||||
router.push('/admin/subscriptions');
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// 사용자 메뉴 아이템
|
||||
const userMenuItems = ref([
|
||||
{
|
||||
label: '프로필',
|
||||
icon: 'pi pi-user',
|
||||
command: () => {
|
||||
router.push('/profile');
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: '로그아웃',
|
||||
icon: 'pi pi-power-off',
|
||||
command: logout
|
||||
}
|
||||
]);
|
||||
|
||||
const toggleUserMenu = (event) => {
|
||||
userMenu.value.toggle(event);
|
||||
};
|
||||
|
||||
const toggleAdminMenu = (event) => {
|
||||
adminMenu.value.toggle(event);
|
||||
};
|
||||
|
||||
// 메뉴 토글
|
||||
const toggleNotifications = (event) => {
|
||||
showNotifications.value = !showNotifications.value;
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// 문서 클릭 시 알림 패널 닫기
|
||||
const handleDocumentClick = (event) => {
|
||||
const notificationWrapper = document.querySelector('.notification-wrapper');
|
||||
if (notificationWrapper && !notificationWrapper.contains(event.target)) {
|
||||
showNotifications.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트/언마운트
|
||||
onMounted(() => {
|
||||
if (isLoggedIn.value) {
|
||||
fetchNotifications();
|
||||
}
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-menu-wrapper,
|
||||
.admin-menu-wrapper,
|
||||
.notification-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-button,
|
||||
.admin-menu-button,
|
||||
.notification-button {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.user-menu-button:hover,
|
||||
.admin-menu-button:hover,
|
||||
.notification-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.user-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.notifications-panel {
|
||||
position: absolute;
|
||||
top: 45px;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1001;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notifications-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.notifications-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notifications-content {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background-color: #f0f7ff;
|
||||
border-left: 3px solid #1976d2;
|
||||
}
|
||||
|
||||
.notification-item i {
|
||||
font-size: 1.2rem;
|
||||
color: #1976d2;
|
||||
margin-right: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.notification-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.no-notifications {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-notifications i {
|
||||
font-size: 2rem;
|
||||
color: #ccc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.notifications-footer {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
:deep(.p-menu) {
|
||||
z-index: 1001 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.hide-on-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.notifications-panel {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: auto;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<div class="notification-system">
|
||||
<Button
|
||||
icon="pi pi-bell"
|
||||
class="p-button-rounded p-button-text notification-button"
|
||||
@click="toggleNotificationPanel"
|
||||
:badge="unreadCount > 0 ? unreadCount.toString() : undefined"
|
||||
badge-class="p-badge-danger"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="notificationPanelVisible"
|
||||
header="알림"
|
||||
:style="{ width: '400px' }"
|
||||
:modal="false"
|
||||
position="right"
|
||||
:draggable="false"
|
||||
:closable="true"
|
||||
:closeOnEscape="true"
|
||||
>
|
||||
<div class="notification-panel">
|
||||
<div class="notification-actions">
|
||||
<Button
|
||||
label="모두 읽음으로 표시"
|
||||
class="p-button-text p-button-sm"
|
||||
@click="markAllAsRead"
|
||||
:disabled="notifications.length === 0 || unreadCount === 0"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
class="p-button-text p-button-sm p-button-danger"
|
||||
@click="clearAllNotifications"
|
||||
:disabled="notifications.length === 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="notifications.length === 0" class="empty-state">
|
||||
<i class="pi pi-bell-slash empty-icon"></i>
|
||||
<p>알림이 없습니다</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
:class="{ 'unread': !notification.read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="notification-icon">
|
||||
<i :class="getNotificationIcon(notification.type)"></i>
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">{{ notification.title }}</div>
|
||||
<div class="notification-message">{{ notification.message }}</div>
|
||||
<div class="notification-time">{{ formatTime(notification.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="notification-actions">
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
class="p-button-rounded p-button-text p-button-sm"
|
||||
@click.stop="removeNotification(notification.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/ko';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('ko');
|
||||
|
||||
const toast = useToast();
|
||||
const API_URL = 'http://localhost:3030/api';
|
||||
|
||||
// 상태 변수
|
||||
const notifications = ref([]);
|
||||
const notificationPanelVisible = ref(false);
|
||||
let notificationPolling = null;
|
||||
|
||||
// 읽지 않은 알림 수
|
||||
const unreadCount = computed(() => {
|
||||
return notifications.value.filter(notification => !notification.read).length;
|
||||
});
|
||||
|
||||
// 컴포넌트 마운트 시 알림 가져오기 및 폴링 시작
|
||||
onMounted(() => {
|
||||
fetchNotifications();
|
||||
startNotificationPolling();
|
||||
});
|
||||
|
||||
// 컴포넌트 언마운트 시 폴링 중지
|
||||
onUnmounted(() => {
|
||||
stopNotificationPolling();
|
||||
});
|
||||
|
||||
// 알림 패널 토글
|
||||
const toggleNotificationPanel = () => {
|
||||
notificationPanelVisible.value = !notificationPanelVisible.value;
|
||||
};
|
||||
|
||||
// 알림 가져오기
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await axios.get(`${API_URL}/notifications`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
notifications.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('알림을 가져오는 중 오류 발생:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 폴링 시작 (30초마다)
|
||||
const startNotificationPolling = () => {
|
||||
notificationPolling = setInterval(() => {
|
||||
fetchNotifications();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
// 알림 폴링 중지
|
||||
const stopNotificationPolling = () => {
|
||||
if (notificationPolling) {
|
||||
clearInterval(notificationPolling);
|
||||
notificationPolling = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 클릭 처리
|
||||
const handleNotificationClick = async (notification) => {
|
||||
// 읽음으로 표시
|
||||
if (!notification.read) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// 알림 유형에 따른 처리
|
||||
switch (notification.type) {
|
||||
case 'club_invitation':
|
||||
// 클럽 초대 페이지로 이동
|
||||
window.location.href = `/clubs/invitations`;
|
||||
break;
|
||||
case 'meeting_reminder':
|
||||
// 모임 상세 페이지로 이동
|
||||
window.location.href = `/meetings/${notification.data.meetingId}`;
|
||||
break;
|
||||
case 'score_update':
|
||||
// 점수 페이지로 이동
|
||||
window.location.href = `/scores/${notification.data.scoreId}`;
|
||||
break;
|
||||
case 'subscription_reminder':
|
||||
// 구독 페이지로 이동
|
||||
window.location.href = `/subscriptions`;
|
||||
break;
|
||||
default:
|
||||
// 기본적으로 알림 패널 닫기
|
||||
notificationPanelVisible.value = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 읽음으로 표시
|
||||
const markAsRead = async (notificationId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.put(`${API_URL}/notifications/${notificationId}/read`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
const index = notifications.value.findIndex(n => n.id === notificationId);
|
||||
if (index !== -1) {
|
||||
notifications.value[index].read = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('알림을 읽음으로 표시하는 중 오류 발생:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 모든 알림 읽음으로 표시
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.put(`${API_URL}/notifications/read-all`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
notifications.value = notifications.value.map(notification => ({
|
||||
...notification,
|
||||
read: true
|
||||
}));
|
||||
|
||||
toast.add({ severity: 'success', summary: '성공', detail: '모든 알림이 읽음으로 표시되었습니다.', life: 3000 });
|
||||
} catch (error) {
|
||||
console.error('모든 알림을 읽음으로 표시하는 중 오류 발생:', error);
|
||||
toast.add({ severity: 'error', summary: '오류', detail: '알림 상태를 업데이트하는 중 오류가 발생했습니다.', life: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 삭제
|
||||
const removeNotification = async (notificationId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(`${API_URL}/notifications/${notificationId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
notifications.value = notifications.value.filter(n => n.id !== notificationId);
|
||||
|
||||
toast.add({ severity: 'success', summary: '성공', detail: '알림이 삭제되었습니다.', life: 3000 });
|
||||
} catch (error) {
|
||||
console.error('알림을 삭제하는 중 오류 발생:', error);
|
||||
toast.add({ severity: 'error', summary: '오류', detail: '알림을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
// 모든 알림 삭제
|
||||
const clearAllNotifications = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(`${API_URL}/notifications/clear-all`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
notifications.value = [];
|
||||
|
||||
toast.add({ severity: 'success', summary: '성공', detail: '모든 알림이 삭제되었습니다.', life: 3000 });
|
||||
} catch (error) {
|
||||
console.error('모든 알림을 삭제하는 중 오류 발생:', error);
|
||||
toast.add({ severity: 'error', summary: '오류', detail: '알림을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 아이콘 가져오기
|
||||
const getNotificationIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'club_invitation':
|
||||
return 'pi pi-users';
|
||||
case 'meeting_reminder':
|
||||
return 'pi pi-calendar';
|
||||
case 'score_update':
|
||||
return 'pi pi-chart-bar';
|
||||
case 'subscription_reminder':
|
||||
return 'pi pi-credit-card';
|
||||
case 'system':
|
||||
return 'pi pi-info-circle';
|
||||
default:
|
||||
return 'pi pi-bell';
|
||||
}
|
||||
};
|
||||
|
||||
// 시간 포맷팅
|
||||
const formatTime = (timestamp) => {
|
||||
return dayjs(timestamp).fromNow();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background-color: #bbdefb;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #f0f0f0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.9rem;
|
||||
color: #616161;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 0.8rem;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
:header="eventModel.title"
|
||||
:style="{ width: '90vw', maxWidth: '1200px' }"
|
||||
:modal="true"
|
||||
class="event-details-dialog"
|
||||
>
|
||||
<TabView>
|
||||
<!-- 기본 정보 탭 -->
|
||||
<TabPanel header="기본 정보">
|
||||
<div class="event-details">
|
||||
<div class="event-details-header">
|
||||
<Badge :value="getEventTypeName(eventModel.eventType)" :severity="getStatusSeverity(eventModel.status)" />
|
||||
<Badge :value="getStatusName(eventModel.status)" :severity="getStatusSeverity(eventModel.status)" />
|
||||
</div>
|
||||
|
||||
<div class="event-details-content">
|
||||
<div class="event-details-section">
|
||||
<h4>이벤트 정보</h4>
|
||||
<div class="grid">
|
||||
<div class="col-12 md:col-6">
|
||||
<p><strong>시작일:</strong> {{ formattedStartDate }}</p>
|
||||
<p><strong>종료일:</strong> {{ formattedEndDate }}</p>
|
||||
<p><strong>장소:</strong> {{ eventModel.location }}</p>
|
||||
</div>
|
||||
<div class="col-12 md:col-6">
|
||||
<p><strong>최대 참가자 수:</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p>
|
||||
<p><strong>현재 참가자 수:</strong> {{ eventModel.participants?.length || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-details-section">
|
||||
<h4>설명</h4>
|
||||
<p>{{ eventModel.description || '설명이 없습니다.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- 참가자 관리 탭 -->
|
||||
<TabPanel header="참가자 관리">
|
||||
<ParticipantManagement
|
||||
:eventId="eventModel.id"
|
||||
:availableMembers="availableMembers"
|
||||
@participant-updated="$emit('participant-updated')"
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<!-- 점수 관리 탭 -->
|
||||
<TabPanel header="점수 관리">
|
||||
<ScoreManagement
|
||||
:eventId="eventModel.id"
|
||||
:participants="eventModel.participants || []"
|
||||
:maxGames="eventModel.gameCount"
|
||||
@score-updated="$emit('score-updated')"
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<Button label="수정" icon="pi pi-pencil" class="p-button-text" @click="$emit('edit')" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import TabView from 'primevue/tabview';
|
||||
import TabPanel from 'primevue/tabpanel';
|
||||
import Badge from 'primevue/badge';
|
||||
import Button from 'primevue/button';
|
||||
import ParticipantManagement from './ParticipantManagement.vue';
|
||||
import ScoreManagement from './ScoreManagement.vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
event: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
getEventTypeName: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getStatusName: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getStatusSeverity: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
formatDate: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
availableMembers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'hide', 'edit', 'participant-updated', 'score-updated']);
|
||||
|
||||
const eventModel = computed(() => props.event);
|
||||
|
||||
const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate));
|
||||
const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDate));
|
||||
|
||||
const hideDialog = () => {
|
||||
emit('hide');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-details {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.event-details-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.event-details-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.event-details-section h4 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-dialog-content) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.p-tabview-panels) {
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
:header="isNew ? '이벤트 추가' : '이벤트 수정'"
|
||||
:style="{ width: '650px' }"
|
||||
:modal="true"
|
||||
class="p-fluid"
|
||||
>
|
||||
<div class="grid">
|
||||
<div class="col-12">
|
||||
<div class="field">
|
||||
<label for="title">제목 *</label>
|
||||
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" required autofocus />
|
||||
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="field">
|
||||
<label for="description">설명</label>
|
||||
<Textarea id="description" v-model="eventModel.description" rows="3" autoResize />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="eventType">이벤트 유형 *</label>
|
||||
<Dropdown id="eventType" v-model="eventModel.eventType" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType}" />
|
||||
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="status">상태 *</label>
|
||||
<Dropdown id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status}" />
|
||||
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="startDate">시작일 *</label>
|
||||
<Calendar id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.startDate}" />
|
||||
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="endDate">종료일</label>
|
||||
<Calendar id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.endDate}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="field">
|
||||
<label for="location">장소</label>
|
||||
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="maxParticipants">최대 참가자 수</label>
|
||||
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" showButtons />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="gameCount">게임 수</label>
|
||||
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" showButtons />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="participantFee">참가비</label>
|
||||
<InputNumber id="participantFee" v-model="eventModel.entryFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="registrationDeadline">등록 마감일</label>
|
||||
<Calendar id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
|
||||
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveEvent" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, toRefs, computed } from 'vue';
|
||||
|
||||
const statusOptions = [
|
||||
{ name: '준비', value: '준비' },
|
||||
{ name: '활성', value: '활성' },
|
||||
{ name: '완료', value: '완료' },
|
||||
{ name: '취소', value: '취소' },
|
||||
{ name: '삭제', value: '삭제' }
|
||||
];
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
event: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isNew: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
submitted: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'save', 'hide']);
|
||||
|
||||
// 이벤트 타입 옵션
|
||||
const eventTypeOptions = [
|
||||
{ name: '정기전', value: '정기전' },
|
||||
{ name: '번개', value: '번개' },
|
||||
{ name: '연습', value: '연습' },
|
||||
{ name: '교류전', value: '교류전' },
|
||||
{ name: '이벤트', value: '이벤트' },
|
||||
{ name: '기타', value: '기타' }
|
||||
];
|
||||
|
||||
// 이벤트 모델
|
||||
const eventModel = ref({ ...props.event });
|
||||
|
||||
// 날짜 형식 변환
|
||||
const formatDate = (date) => {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
d.setHours(d.getHours() + 9); // UTC to KST
|
||||
return d;
|
||||
};
|
||||
|
||||
// props 변경 시 이벤트 모델 업데이트
|
||||
watch(() => props.event, (newValue) => {
|
||||
const formattedEvent = {
|
||||
...newValue,
|
||||
startDate: formatDate(newValue.startDate),
|
||||
endDate: formatDate(newValue.endDate),
|
||||
registrationDeadline: formatDate(newValue.registrationDeadline)
|
||||
};
|
||||
eventModel.value = formattedEvent;
|
||||
}, { deep: true });
|
||||
|
||||
// 이벤트 저장 시 UTC로 변환
|
||||
const saveEvent = () => {
|
||||
const eventData = { ...eventModel.value };
|
||||
if (eventData.startDate) {
|
||||
const d = new Date(eventData.startDate);
|
||||
d.setHours(d.getHours() - 9); // KST to UTC
|
||||
eventData.startDate = d;
|
||||
}
|
||||
if (eventData.endDate) {
|
||||
const d = new Date(eventData.endDate);
|
||||
d.setHours(d.getHours() - 9); // KST to UTC
|
||||
eventData.endDate = d;
|
||||
}
|
||||
if (eventData.registrationDeadline) {
|
||||
const d = new Date(eventData.registrationDeadline);
|
||||
d.setHours(d.getHours() - 9); // KST to UTC
|
||||
eventData.registrationDeadline = d;
|
||||
}
|
||||
emit('save', eventData);
|
||||
};
|
||||
|
||||
// 다이얼로그 닫기
|
||||
const hideDialog = () => {
|
||||
emit('hide');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: -0.25rem;
|
||||
}
|
||||
|
||||
.col-12 {
|
||||
width: 100%;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.md\:col-6 {
|
||||
width: 50%;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-dialog-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.p-dialog-footer {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-dropdown),
|
||||
:deep(.p-calendar),
|
||||
:deep(.p-inputnumber) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="participant-management">
|
||||
<Toast />
|
||||
<div class="header">
|
||||
<Button label="참가자 추가" icon="pi pi-plus" class="p-button-success" @click="openNewDialog" />
|
||||
</div>
|
||||
<DataTable
|
||||
:value="participants"
|
||||
:loading="loading"
|
||||
class="mt-4"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
stripedRows
|
||||
v-model:filters="filters"
|
||||
>
|
||||
<Column field="Member.name" header="이름" sortable>
|
||||
<template #body="slotProps">
|
||||
<div class="flex flex-column">
|
||||
<span>{{ slotProps.data.Member.name }}</span>
|
||||
<small class="text-500">{{ slotProps.data.Member.phone }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="Member.memberType" header="회원 유형" sortable>
|
||||
<template #body="slotProps">
|
||||
<Badge :value="getMemberTypeName(slotProps.data.Member.memberType)" :severity="getMemberTypeSeverity(slotProps.data.Member.memberType)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="참가 상태" sortable>
|
||||
<template #body="slotProps">
|
||||
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="paymentStatus" header="결제 상태" sortable>
|
||||
<template #body="slotProps">
|
||||
<Badge :value="getPaymentStatusName(slotProps.data.paymentStatus)" :severity="getPaymentStatusSeverity(slotProps.data.paymentStatus)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="createdAt" header="등록일" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.createdAt) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="작업">
|
||||
<template #body="slotProps">
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editParticipant(slotProps.data)" />
|
||||
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger" @click="confirmDeleteParticipant(slotProps.data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- 참가자 추가/수정 다이얼로그 -->
|
||||
<Dialog v-model:visible="participantDialog" :header="dialogTitle" modal class="p-fluid" :style="{width: '450px'}">
|
||||
<div class="field">
|
||||
<label for="member">회원</label>
|
||||
<Dropdown
|
||||
id="member"
|
||||
v-model="editingParticipant.memberId"
|
||||
:options="props.availableMembers"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
placeholder="회원을 선택하세요"
|
||||
:class="{ 'p-invalid': submitted && !editingParticipant.memberId }"
|
||||
:disabled="!!editingParticipant.id"
|
||||
/>
|
||||
<small v-if="submitted && !editingParticipant.memberId" class="p-error">회원을 선택해주세요.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status">참가 상태</label>
|
||||
<Dropdown
|
||||
id="status"
|
||||
v-model="editingParticipant.status"
|
||||
:options="participantStatusOptions"
|
||||
optionLabel="name"
|
||||
optionValue="value"
|
||||
placeholder="상태를 선택하세요"
|
||||
:class="{ 'p-invalid': submitted && !editingParticipant.status }"
|
||||
/>
|
||||
<small v-if="submitted && !editingParticipant.status" class="p-error">참가 상태를 선택해주세요.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="paymentStatus">결제 상태</label>
|
||||
<Dropdown
|
||||
id="paymentStatus"
|
||||
v-model="editingParticipant.paymentStatus"
|
||||
:options="paymentStatusOptions"
|
||||
optionLabel="name"
|
||||
optionValue="value"
|
||||
placeholder="결제 상태를 선택하세요"
|
||||
:class="{ 'p-invalid': submitted && !editingParticipant.paymentStatus }"
|
||||
/>
|
||||
<small v-if="submitted && !editingParticipant.paymentStatus" class="p-error">결제 상태를 선택해주세요.</small>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
|
||||
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveParticipant" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<Dialog v-model:visible="deleteDialog" :style="{width: '450px'}" header="확인" :modal="true">
|
||||
<div class="confirmation-content">
|
||||
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
|
||||
<span>이 참가자를 제거하시겠습니까?</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteDialog = false" />
|
||||
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteParticipant" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, defineEmits } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import eventService from '@/services/eventService';
|
||||
import dayjs from 'dayjs';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import Badge from 'primevue/badge';
|
||||
import Toast from 'primevue/toast';
|
||||
|
||||
const props = defineProps({
|
||||
eventId: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
availableMembers: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['participant-updated']);
|
||||
const toast = useToast();
|
||||
const participants = ref([]);
|
||||
const loading = ref(false);
|
||||
const filters = ref({});
|
||||
const participantDialog = ref(false);
|
||||
const deleteDialog = ref(false);
|
||||
const submitted = ref(false);
|
||||
const clubId = ref(localStorage.getItem('clubId') || null);
|
||||
const editingParticipant = ref({
|
||||
clubId: clubId.value,
|
||||
memberId: null,
|
||||
status: '참가예정',
|
||||
paymentStatus: '미납'
|
||||
});
|
||||
const participantToDelete = ref(null);
|
||||
|
||||
const participantStatusOptions = [
|
||||
{ name: '참가 예정', value: '참가예정' },
|
||||
{ name: '참가 확정', value: '참가확정' },
|
||||
{ name: '취소', value: '취소' }
|
||||
];
|
||||
|
||||
const paymentStatusOptions = [
|
||||
{ name: '미납', value: '미납' },
|
||||
{ name: '납부 완료', value: '납부완료' }
|
||||
];
|
||||
|
||||
const fetchParticipants = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await eventService.getEventParticipants(props.eventId);
|
||||
participants.value = data;
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: '참가자 목록을 불러오는 중 오류가 발생했습니다.',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openNewDialog = () => {
|
||||
editingParticipant.value = {
|
||||
clubId: localStorage.getItem('clubId') || null,
|
||||
memberId: null,
|
||||
status: '참가예정',
|
||||
paymentStatus: '미납'
|
||||
};
|
||||
submitted.value = false;
|
||||
participantDialog.value = true;
|
||||
};
|
||||
|
||||
const editParticipant = (participant) => {
|
||||
editingParticipant.value = {
|
||||
id: participant.id,
|
||||
clubId: localStorage.getItem('clubId') || null,
|
||||
memberId: participant.memberId,
|
||||
status: participant.status,
|
||||
paymentStatus: participant.paymentStatus
|
||||
};
|
||||
participantDialog.value = true;
|
||||
submitted.value = false;
|
||||
};
|
||||
|
||||
const hideDialog = () => {
|
||||
participantDialog.value = false;
|
||||
submitted.value = false;
|
||||
};
|
||||
|
||||
const saveParticipant = async () => {
|
||||
submitted.value = true;
|
||||
|
||||
if (!editingParticipant.value.memberId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingParticipant.value.id) {
|
||||
// 수정
|
||||
await eventService.updateEventParticipant(props.eventId, editingParticipant.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: '성공',
|
||||
detail: '참가자 정보가 수정되었습니다.',
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
// 신규 등록
|
||||
await eventService.addEventParticipant(props.eventId, editingParticipant.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: '성공',
|
||||
detail: '참가자가 추가되었습니다.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
await fetchParticipants();
|
||||
hideDialog();
|
||||
} catch (error) {
|
||||
console.error('참가자 저장 오류:', error.message);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: error.message,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteParticipant = (participant) => {
|
||||
participantToDelete.value = participant;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
|
||||
const deleteParticipant = async () => {
|
||||
try {
|
||||
await eventService.removeEventParticipant(props.eventId, { id: participantToDelete.value.id });
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: '성공',
|
||||
detail: '참가자가 삭제되었습니다.',
|
||||
life: 3000
|
||||
});
|
||||
deleteDialog.value = false;
|
||||
participantToDelete.value = null;
|
||||
await fetchParticipants();
|
||||
} catch (error) {
|
||||
console.error('참가자 삭제 오류:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: '참가자 삭제 중 오류가 발생했습니다.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm');
|
||||
};
|
||||
|
||||
const getMemberTypeName = (type) => {
|
||||
const types = {
|
||||
regular: '정회원',
|
||||
associate: '준회원',
|
||||
guest: '게스트'
|
||||
};
|
||||
return types[type] || type;
|
||||
};
|
||||
|
||||
const getMemberTypeSeverity = (type) => {
|
||||
const severities = {
|
||||
regular: 'success',
|
||||
associate: 'info',
|
||||
guest: 'warning'
|
||||
};
|
||||
return severities[type] || 'info';
|
||||
};
|
||||
|
||||
const getStatusName = (status) => {
|
||||
const statuses = {
|
||||
참가예정: '참가 예정',
|
||||
참가완료: '참가 완료',
|
||||
취소: '취소'
|
||||
};
|
||||
return statuses[status] || status;
|
||||
};
|
||||
|
||||
const getStatusSeverity = (status) => {
|
||||
const severities = {
|
||||
참가예정: 'warning',
|
||||
참가완료: 'success',
|
||||
취소: 'danger'
|
||||
};
|
||||
return severities[status] || 'info';
|
||||
};
|
||||
|
||||
const getPaymentStatusName = (status) => {
|
||||
const statuses = {
|
||||
미납: '미납',
|
||||
납부완료: '납부 완료'
|
||||
};
|
||||
return statuses[status] || status;
|
||||
};
|
||||
|
||||
const getPaymentStatusSeverity = (status) => {
|
||||
const severities = {
|
||||
미납: 'danger',
|
||||
납부완료: 'success'
|
||||
};
|
||||
return severities[status] || 'info';
|
||||
};
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return editingParticipant.value.id ? '참가자 정보 수정' : '참가자 추가';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchParticipants();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.participant-management {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.confirmation-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
:deep(.p-toast) {
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<div class="score-management">
|
||||
<DataTable
|
||||
:value="groupedScores"
|
||||
:loading="loading"
|
||||
class="mt-4"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
stripedRows
|
||||
v-model:filters="filters"
|
||||
>
|
||||
<Column field="participant.name" header="참가자" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ slotProps.data.participant?.name }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="teamNumber" header="팀" sortable style="width: 80px">
|
||||
<template #body="slotProps">
|
||||
{{ slotProps.data.teamNumber }}
|
||||
</template>
|
||||
</Column>
|
||||
<template v-for="i in maxGames" :key="i">
|
||||
<Column :field="'games.' + (i-1) + '.score'" :header="i + '게임'" sortable>
|
||||
<template #body="slotProps">
|
||||
<div class="flex flex-column">
|
||||
<span :class="getScoreClass(getTotalScore(slotProps.data.games[i-1]))">
|
||||
{{ getTotalScore(slotProps.data.games[i-1]) || '-' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500" v-if="slotProps.data.games[i-1]?.score && slotProps.data.games[i-1]?.handicap !== 0">
|
||||
{{ formatScoreWithHandicap(slotProps.data.games[i-1]) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</template>
|
||||
<Column field="average" header="평균" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ calculateAverage(slotProps.data.games) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="작업">
|
||||
<template #body="slotProps">
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editScore(slotProps.data)" />
|
||||
<Button icon="pi pi-trash" class="p-button-rounded p-button-text" @click="deleteParticipantScores(slotProps.data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- 점수 입력/수정 다이얼로그 -->
|
||||
<Dialog v-model:visible="scoreDialog" :header="scoreDialogTitle" modal class="p-fluid" :style="{width: '600px'}">
|
||||
<div class="field">
|
||||
<label for="participant">참가자</label>
|
||||
<Dropdown
|
||||
id="participant"
|
||||
v-model="editingScore.participantId"
|
||||
:options="participants"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
placeholder="참가자 선택"
|
||||
:class="{'p-invalid': submitted && !editingScore.participantId}"
|
||||
:disabled="!!editingScore.id"
|
||||
/>
|
||||
<small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="teamNumber">팀</label>
|
||||
<InputNumber
|
||||
id="teamNumber"
|
||||
v-model="editingScore.teamNumber"
|
||||
:min="1"
|
||||
:max="10"
|
||||
placeholder="팀 번호"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<template v-for="game in editingScore.games" :key="game.gameNumber">
|
||||
<div class="col-6">
|
||||
<div class="field">
|
||||
<label :for="'game' + game.gameNumber">{{ game.gameNumber }}게임 점수</label>
|
||||
<InputNumber
|
||||
:id="'game' + game.gameNumber"
|
||||
v-model="game.score"
|
||||
:min="0"
|
||||
:max="300"
|
||||
:placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'"
|
||||
@update:modelValue="() => onScoreChange(game)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="field">
|
||||
<label :for="'handicap' + game.gameNumber">{{ game.gameNumber }}게임 핸디캡</label>
|
||||
<InputNumber
|
||||
:id="'handicap' + game.gameNumber"
|
||||
v-model="game.handicap"
|
||||
:min="-300"
|
||||
:max="300"
|
||||
placeholder="핸디캡 입력"
|
||||
@update:modelValue="() => onScoreChange(game)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideScoreDialog" />
|
||||
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveScore" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<Dialog v-model:visible="deleteScoreDialog" :style="{width: '450px'}" header="확인" :modal="true">
|
||||
<div class="confirmation-content">
|
||||
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
|
||||
<span>이 점수를 삭제하시겠습니까?</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteScoreDialog = false" />
|
||||
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteScore" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 점수 추가 버튼 -->
|
||||
<div class="flex justify-content-end mt-4">
|
||||
<Button label="점수 추가" icon="pi pi-plus" @click="showScoreDialog" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import eventService from '@/services/eventService';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const props = defineProps({
|
||||
eventId: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
maxGames: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['score-updated']);
|
||||
const toast = useToast();
|
||||
const scores = ref([]);
|
||||
const loading = ref(false);
|
||||
const filters = ref({});
|
||||
const scoreDialog = ref(false);
|
||||
const deleteScoreDialog = ref(false);
|
||||
const submitted = ref(false);
|
||||
const participants = ref([]);
|
||||
|
||||
const editingScore = ref({
|
||||
isEdit: false,
|
||||
participantId: null,
|
||||
teamNumber: null,
|
||||
games: []
|
||||
});
|
||||
|
||||
// 점수 목록 조회
|
||||
const fetchScores = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await eventService.getEventScores(props.eventId);
|
||||
scores.value = response || [];
|
||||
await fetchParticipants();
|
||||
} catch (error) {
|
||||
console.error('점수 조회 오류:', error);
|
||||
scores.value = [];
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: error.message || '점수 목록을 가져오는 중 오류가 발생했습니다.',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 참가자 목록 조회
|
||||
const fetchParticipants = async () => {
|
||||
try {
|
||||
const data = await eventService.getEventParticipants(props.eventId);
|
||||
if (Array.isArray(data)) {
|
||||
participants.value = data.map(p => ({
|
||||
id: p.id,
|
||||
name: p.Member.name
|
||||
}));
|
||||
} else {
|
||||
participants.value = [];
|
||||
console.error('참가자 데이터가 배열이 아닙니다:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('참가자 조회 오류:', error);
|
||||
participants.value = [];
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: error.message || '참가자 목록을 가져오는 중 오류가 발생했습니다.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 점수 수정 다이얼로그 열기
|
||||
const editScore = (data) => {
|
||||
// console.log('Edit score data:', data);
|
||||
editingScore.value = {
|
||||
isEdit: true,
|
||||
participantId: data.participant.id,
|
||||
teamNumber: data.teamNumber,
|
||||
games: data.games.map((game, index) => ({
|
||||
id: game.id,
|
||||
gameNumber: game.gameNumber ?? index + 1,
|
||||
score: game.score ?? null,
|
||||
handicap: game.handicap ?? null,
|
||||
isModified: false
|
||||
}))
|
||||
};
|
||||
scoreDialog.value = true;
|
||||
};
|
||||
|
||||
// 신규 점수 입력 다이얼로그 열기
|
||||
const showScoreDialog = async () => {
|
||||
try {
|
||||
// 참가자 목록 갱신
|
||||
await fetchParticipants();
|
||||
|
||||
editingScore.value = {
|
||||
isEdit: false,
|
||||
participantId: null,
|
||||
teamNumber: null,
|
||||
games: Array.from({ length: props.maxGames }, (_, i) => ({
|
||||
id: null,
|
||||
gameNumber: i + 1,
|
||||
score: null,
|
||||
handicap: null,
|
||||
isModified: false
|
||||
}))
|
||||
};
|
||||
submitted.value = false;
|
||||
scoreDialog.value = true;
|
||||
} catch (error) {
|
||||
console.error('점수 입력 다이얼로그 오류:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: '참가자 목록을 가져오는 중 오류가 발생했습니다.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 다이얼로그 닫기
|
||||
const hideScoreDialog = () => {
|
||||
scoreDialog.value = false;
|
||||
submitted.value = false;
|
||||
};
|
||||
|
||||
// 점수 저장
|
||||
const saveScore = async () => {
|
||||
submitted.value = true;
|
||||
|
||||
if (!editingScore.value.participantId) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: '참가자를 선택해주세요.',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingScore.value.isEdit) {
|
||||
// 수정 모드: 변경된 게임만 업데이트
|
||||
const updatePromises = editingScore.value.games
|
||||
.map((game) => {
|
||||
// 점수가 없으면 건너뛰기
|
||||
if (game.score === null || game.score === undefined) return null;
|
||||
|
||||
// 기존 게임이면서 수정되지 않은 경우 건너뛰기
|
||||
if (game.id && !game.isModified) return null;
|
||||
|
||||
const scoreData = {
|
||||
gameNumber: game.gameNumber,
|
||||
score: game.score,
|
||||
handicap: game.handicap || 0
|
||||
};
|
||||
|
||||
// 기존 게임 수정
|
||||
if (game.id) {
|
||||
console.log('Updating existing game:', {
|
||||
eventId: props.eventId,
|
||||
scoreId: game.id,
|
||||
...scoreData
|
||||
});
|
||||
return eventService.updateScore(
|
||||
Number(props.eventId),
|
||||
Number(game.id),
|
||||
scoreData
|
||||
);
|
||||
}
|
||||
|
||||
// 새로운 게임 추가
|
||||
console.log('Adding new game in edit mode:', {
|
||||
eventId: props.eventId,
|
||||
participantId: editingScore.value.participantId,
|
||||
...scoreData
|
||||
});
|
||||
return eventService.addScore(props.eventId, {
|
||||
...scoreData,
|
||||
participantId: editingScore.value.participantId,
|
||||
teamNumber: editingScore.value.teamNumber
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (updatePromises.length > 0) {
|
||||
await Promise.all(updatePromises);
|
||||
}
|
||||
} else {
|
||||
// 신규 등록 모드 (변경 없음)
|
||||
const addPromises = editingScore.value.games
|
||||
.filter(game => game.score !== null && game.score !== undefined)
|
||||
.map(game => {
|
||||
const scoreData = {
|
||||
participantId: editingScore.value.participantId,
|
||||
teamNumber: editingScore.value.teamNumber,
|
||||
gameNumber: game.gameNumber,
|
||||
score: Number(game.score),
|
||||
handicap: Number(game.handicap || 0)
|
||||
};
|
||||
|
||||
console.log('Adding game:', scoreData);
|
||||
return eventService.addScore(props.eventId, scoreData);
|
||||
});
|
||||
|
||||
if (addPromises.length > 0) {
|
||||
await Promise.all(addPromises);
|
||||
}
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: '성공',
|
||||
detail: '점수가 저장되었습니다.',
|
||||
life: 3000
|
||||
});
|
||||
|
||||
hideScoreDialog();
|
||||
await fetchScores();
|
||||
} catch (error) {
|
||||
console.error('점수 저장 오류:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: error.message || '점수 저장 중 오류가 발생했습니다.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreClass = (score) => {
|
||||
if (!score) return '';
|
||||
if (score >= 200) return 'text-red-600 font-bold';
|
||||
return '';
|
||||
};
|
||||
|
||||
const scoreDialogTitle = computed(() => {
|
||||
return editingScore.value.isEdit ? '점수 수정' : '점수 등록';
|
||||
});
|
||||
|
||||
const getTotalScore = (game) => {
|
||||
if (!game || !game.score || game.score === 0) return null;
|
||||
return game.score + (game.handicap || 0);
|
||||
};
|
||||
|
||||
const calculateAverage = (games) => {
|
||||
const validGames = games.filter(game => game && game.score && game.score > 0);
|
||||
if (!validGames.length) return '-';
|
||||
const total = validGames.reduce((sum, game) => sum + getTotalScore(game), 0);
|
||||
return (total / validGames.length).toFixed(1);
|
||||
};
|
||||
|
||||
const formatScoreWithHandicap = (game) => {
|
||||
if (!game || !game.score) return '';
|
||||
|
||||
const handicap = game.handicap || 0;
|
||||
if (handicap === 0) return `(${game.score})`;
|
||||
if (handicap < 0) return `(${game.score}${handicap})`;
|
||||
return `(${game.score}+${handicap})`;
|
||||
};
|
||||
|
||||
const groupedScores = computed(() => {
|
||||
if (!participants.value || !scores.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
const participantsWithScores = new Set();
|
||||
|
||||
scores.value.forEach(score => {
|
||||
if (!grouped[score.participantId]) {
|
||||
const participant = participants.value.find(p => p.id === score.participantId);
|
||||
if (participant) {
|
||||
grouped[score.participantId] = {
|
||||
participant,
|
||||
participantId: participant.id,
|
||||
teamNumber: score.teamNumber,
|
||||
games: Array(props.maxGames).fill(null).map(() => ({ score: null, handicap: null, gameNumber: null }))
|
||||
};
|
||||
participantsWithScores.add(score.participantId);
|
||||
}
|
||||
}
|
||||
if (grouped[score.participantId]) {
|
||||
grouped[score.participantId].games[score.gameNumber - 1] = {
|
||||
id: score.id,
|
||||
score: score.score,
|
||||
handicap: score.handicap,
|
||||
gameNumber: score.gameNumber
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(grouped);
|
||||
});
|
||||
|
||||
// 점수 변경 시 호출
|
||||
const onScoreChange = (game) => {
|
||||
if (game) {
|
||||
game.isModified = true;
|
||||
/*
|
||||
console.log('Score changed:', {
|
||||
gameNumber: game.gameNumber,
|
||||
score: game.score,
|
||||
handicap: game.handicap,
|
||||
isModified: game.isModified
|
||||
});
|
||||
*/
|
||||
}
|
||||
};
|
||||
|
||||
const deleteParticipantScores = async (data) => {
|
||||
try {
|
||||
await eventService.deleteParticipantScores(
|
||||
Number(props.eventId),
|
||||
Number(data.participant.id)
|
||||
);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: '성공',
|
||||
detail: '점수가 삭제되었습니다.',
|
||||
life: 3000
|
||||
});
|
||||
|
||||
await fetchScores();
|
||||
} catch (error) {
|
||||
console.error('점수 삭제 오류:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: '오류',
|
||||
detail: error.message || '점수 삭제 중 오류가 발생했습니다.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchScores(); // 참가자 목록은 여기서 가져오지 않음
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.score-management {
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user