This commit is contained in:
2025-04-17 23:32:39 +09:00
commit 5b719b6766
105 changed files with 25653 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.history
.env
.windsurfrules
config.js
backend/node_modules/*
frontend/node_modules/*
View File
+86
View File
@@ -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;
+40
View File
@@ -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
};
+221
View File
@@ -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: '프로필 업데이트 중 오류가 발생했습니다.' });
}
};
+925
View File
@@ -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
+203
View File
@@ -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: '기능 구매 중 오류가 발생했습니다.' });
}
};
+166
View File
@@ -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: '플랜별 구독 목록 조회 중 오류가 발생했습니다.' });
}
};
+91
View File
@@ -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
};
+209
View File
@@ -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
);
+26
View File
@@ -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: '유효하지 않은 토큰입니다.' });
}
};
+48
View File
@@ -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();
};
};
+57
View File
@@ -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;
+48
View File
@@ -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;
+49
View File
@@ -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;
+103
View File
@@ -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;
+59
View File
@@ -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;
+52
View File
@@ -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;
+59
View File
@@ -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;
+91
View File
@@ -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;
+70
View File
@@ -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;
+59
View File
@@ -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;
+63
View File
@@ -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;
+36
View File
@@ -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;
+36
View File
@@ -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;
+78
View File
@@ -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;
+40
View File
@@ -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;
+855
View File
@@ -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
};
+3753
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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
+20
View File
@@ -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;
+61
View File
@@ -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;
+29
View File
@@ -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;
+15
View File
@@ -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;
+13
View File
@@ -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;
+34
View File
@@ -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;
+19
View File
@@ -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;
+23
View File
@@ -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;
+38
View File
@@ -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();
+94
View File
@@ -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();
+153
View File
@@ -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();
+109
View File
@@ -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();
+46
View File
@@ -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();
+27
View File
@@ -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' }, {});
}
};
+23
View File
@@ -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();
+160
View File
@@ -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'
}
);
}
};
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+29
View File
@@ -0,0 +1,29 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+3528
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2",
"chart.js": "^4.4.8",
"dayjs": "^1.11.10",
"jwt-decode": "^4.0.0",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0",
"yup": "^1.3.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.2.1",
"vite-plugin-vue-devtools": "^7.7.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

After

Width:  |  Height:  |  Size: 277 B

+35
View File
@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
+489
View File
@@ -0,0 +1,489 @@
<template>
<header class="app-header">
<div class="logo">
<h1>볼링 클럽 매니저</h1>
</div>
<div v-if="isLoggedIn" class="header-actions">
<!-- 관리자 메뉴 -->
<div v-if="isAdmin" class="admin-menu-wrapper">
<Button @click="toggleAdminMenu" class="admin-menu-button p-button-outlined">
<i class="pi pi-cog"></i>
</Button>
<Menu ref="adminMenu" :model="adminMenuItems" :popup="true" />
</div>
<!-- 알림 버튼 -->
<div class="notification-wrapper">
<Button icon="pi pi-bell" class="notification-button p-button-outlined" @click="toggleNotifications"
:badge="unreadNotificationsCount > 0 ? unreadNotificationsCount.toString() : undefined"
badgeClass="p-badge-danger" />
<div v-if="showNotifications" class="notifications-panel">
<div class="notifications-header">
<h3>알림</h3>
<Button icon="pi pi-times" class="p-button-text p-button-rounded" @click="showNotifications = false" />
</div>
<div class="notifications-content">
<div v-if="notifications.length > 0">
<div v-for="(notification, index) in notifications" :key="index"
class="notification-item"
:class="{ 'unread': !notification.read }"
@click="handleNotificationClick(notification)">
<i :class="notification.icon"></i>
<div class="notification-details">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-message">{{ notification.message }}</div>
<div class="notification-time">{{ notification.time }}</div>
</div>
<Button icon="pi pi-trash" class="p-button-text p-button-rounded" @click="deleteNotification(notification.id, $event)" />
</div>
</div>
<div v-else class="no-notifications">
<i class="pi pi-info-circle"></i>
<p>새로운 알림이 없습니다.</p>
</div>
</div>
<div class="notifications-footer">
<Button label="모든 알림 읽음 표시" class="p-button-text" @click="markAllAsRead" />
</div>
</div>
</div>
<!-- 사용자 메뉴 -->
<div class="user-menu-wrapper">
<Button @click="toggleUserMenu" class="user-menu-button p-button-outlined">
<Avatar :image="userAvatar" :label="userInitials" size="normal" shape="circle" />
<span class="user-name hide-on-mobile">{{ userName }}</span>
<i class="pi pi-chevron-down"></i>
</Button>
<Menu ref="userMenu" :model="userMenuItems" :popup="true" />
</div>
</div>
</header>
</template>
<script setup>
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { API_URL } from '../config';
import Button from 'primevue/button';
import Menu from 'primevue/menu';
import Avatar from 'primevue/avatar';
const router = useRouter();
const userMenu = ref(null);
const adminMenu = ref(null);
const isLoggedIn = computed(() => !!localStorage.getItem('token'));
const isAdmin = computed(() => {
const role = localStorage.getItem('userRole');
return role === 'admin' || role === 'superadmin';
});
// (App.vue )
const user = inject('user');
// computed
const userName = computed(() => user.value?.name || '사용자');
const userInitials = computed(() => {
if (!user.value?.name) return '';
return user.value.name.split(' ').map(n => n[0]).join('');
});
const userAvatar = computed(() => user.value?.avatar || '');
//
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('clubId');
localStorage.removeItem('userRole');
router.push('/login');
};
//
const showNotifications = ref(false);
const notifications = ref([]);
const unreadNotificationsCount = ref(0);
//
const fetchNotifications = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await axios.get(`${API_URL}/notifications`, {
headers: {
Authorization: `Bearer ${token}`
}
});
if (response.data) {
notifications.value = response.data.map(notification => {
const createdAt = new Date(notification.createdAt);
const now = new Date();
const diffMs = now - createdAt;
const diffMins = Math.round(diffMs / 60000);
const diffHours = Math.round(diffMs / 3600000);
const diffDays = Math.round(diffMs / 86400000);
let timeText = '';
if (diffMins < 60) {
timeText = `${diffMins}분 전`;
} else if (diffHours < 24) {
timeText = `${diffHours}시간 전`;
} else {
timeText = `${diffDays}일 전`;
}
return {
id: notification._id,
icon: notification.icon || 'pi pi-info-circle',
title: notification.title,
message: notification.message,
time: timeText,
read: notification.read,
link: notification.link
};
});
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
}
} catch (error) {
console.error('알림 가져오기 오류:', error);
}
};
//
const markAllAsRead = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
await axios.put(`${API_URL}/notifications/read-all`, {}, {
headers: {
Authorization: `Bearer ${token}`
}
});
notifications.value = notifications.value.map(notification => ({
...notification,
read: true
}));
unreadNotificationsCount.value = 0;
} catch (error) {
console.error('모든 알림 읽음 표시 오류:', error);
}
};
//
const markAsRead = async (notificationId) => {
try {
const token = localStorage.getItem('token');
if (!token) return;
await axios.put(`${API_URL}/notifications/${notificationId}/read`, {}, {
headers: {
Authorization: `Bearer ${token}`
}
});
const index = notifications.value.findIndex(n => n.id === notificationId);
if (index !== -1) {
notifications.value[index].read = true;
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
}
} catch (error) {
console.error('알림 읽음 표시 오류:', error);
}
};
//
const handleNotificationClick = (notification) => {
if (!notification.read) {
markAsRead(notification.id);
}
if (notification.link) {
router.push(notification.link);
showNotifications.value = false;
}
};
//
const deleteNotification = async (notificationId, event) => {
event.stopPropagation();
try {
const token = localStorage.getItem('token');
if (!token) return;
await axios.delete(`${API_URL}/notifications/${notificationId}`, {
headers: {
Authorization: `Bearer ${token}`
}
});
notifications.value = notifications.value.filter(n => n.id !== notificationId);
unreadNotificationsCount.value = notifications.value.filter(n => !n.read).length;
} catch (error) {
console.error('알림 삭제 오류:', error);
}
};
//
const adminMenuItems = ref([
{
label: '대시보드',
icon: 'pi pi-home',
command: () => {
router.push('/admin');
}
},
{
label: '클럽 관리',
icon: 'pi pi-users',
command: () => {
router.push('/admin/clubs');
}
},
{
label: '사용자 관리',
icon: 'pi pi-user',
command: () => {
router.push('/admin/users');
}
},
{
label: '구독 관리',
icon: 'pi pi-credit-card',
command: () => {
router.push('/admin/subscriptions');
}
}
]);
//
const userMenuItems = ref([
{
label: '프로필',
icon: 'pi pi-user',
command: () => {
router.push('/profile');
}
},
{
separator: true
},
{
label: '로그아웃',
icon: 'pi pi-power-off',
command: logout
}
]);
const toggleUserMenu = (event) => {
userMenu.value.toggle(event);
};
const toggleAdminMenu = (event) => {
adminMenu.value.toggle(event);
};
//
const toggleNotifications = (event) => {
showNotifications.value = !showNotifications.value;
event.stopPropagation();
};
//
const handleDocumentClick = (event) => {
const notificationWrapper = document.querySelector('.notification-wrapper');
if (notificationWrapper && !notificationWrapper.contains(event.target)) {
showNotifications.value = false;
}
};
// /
onMounted(() => {
if (isLoggedIn.value) {
fetchNotifications();
}
document.addEventListener('click', handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
});
</script>
<style scoped>
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: #1976d2;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.logo h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.user-menu-wrapper,
.admin-menu-wrapper,
.notification-wrapper {
position: relative;
}
.user-menu-button,
.admin-menu-button,
.notification-button {
background-color: transparent !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: white !important;
}
.user-menu-button:hover,
.admin-menu-button:hover,
.notification-button:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.user-menu-button {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
}
.user-name {
margin: 0 0.5rem;
}
.notifications-panel {
position: absolute;
top: 45px;
right: 0;
width: 320px;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
color: #333;
}
.notifications-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.notifications-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
}
.notifications-content {
max-height: 350px;
overflow-y: auto;
}
.notification-item {
display: flex;
align-items: flex-start;
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.notification-item:hover {
background-color: #f5f5f5;
}
.notification-item.unread {
background-color: #f0f7ff;
border-left: 3px solid #1976d2;
}
.notification-item i {
font-size: 1.2rem;
color: #1976d2;
margin-right: 0.75rem;
margin-top: 0.25rem;
}
.notification-details {
flex: 1;
min-width: 0;
}
.notification-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.notification-message {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.notification-time {
font-size: 0.8rem;
color: #999;
}
.no-notifications {
padding: 2rem;
text-align: center;
color: #666;
}
.no-notifications i {
font-size: 2rem;
color: #ccc;
margin-bottom: 0.5rem;
}
.notifications-footer {
padding: 0.75rem;
text-align: center;
border-top: 1px solid #eee;
}
:deep(.p-menu) {
z-index: 1001 !important;
}
@media (max-width: 767px) {
.hide-on-mobile {
display: none;
}
.app-header {
padding: 1rem;
}
.notifications-panel {
position: fixed;
top: 64px;
left: 0;
right: 0;
width: auto;
margin: 0.5rem;
}
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
@@ -0,0 +1,365 @@
<template>
<div class="notification-system">
<Button
icon="pi pi-bell"
class="p-button-rounded p-button-text notification-button"
@click="toggleNotificationPanel"
:badge="unreadCount > 0 ? unreadCount.toString() : undefined"
badge-class="p-badge-danger"
/>
<Dialog
v-model:visible="notificationPanelVisible"
header="알림"
:style="{ width: '400px' }"
:modal="false"
position="right"
:draggable="false"
:closable="true"
:closeOnEscape="true"
>
<div class="notification-panel">
<div class="notification-actions">
<Button
label="모두 읽음으로 표시"
class="p-button-text p-button-sm"
@click="markAllAsRead"
:disabled="notifications.length === 0 || unreadCount === 0"
/>
<Button
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
@click="clearAllNotifications"
:disabled="notifications.length === 0"
/>
</div>
<div v-if="notifications.length === 0" class="empty-state">
<i class="pi pi-bell-slash empty-icon"></i>
<p>알림이 없습니다</p>
</div>
<div v-else class="notification-list">
<div
v-for="notification in notifications"
:key="notification.id"
class="notification-item"
:class="{ 'unread': !notification.read }"
@click="handleNotificationClick(notification)"
>
<div class="notification-icon">
<i :class="getNotificationIcon(notification.type)"></i>
</div>
<div class="notification-content">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-message">{{ notification.message }}</div>
<div class="notification-time">{{ formatTime(notification.createdAt) }}</div>
</div>
<div class="notification-actions">
<Button
icon="pi pi-times"
class="p-button-rounded p-button-text p-button-sm"
@click.stop="removeNotification(notification.id)"
/>
</div>
</div>
</div>
</div>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import axios from 'axios';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/ko';
dayjs.extend(relativeTime);
dayjs.locale('ko');
const toast = useToast();
const API_URL = 'http://localhost:3030/api';
//
const notifications = ref([]);
const notificationPanelVisible = ref(false);
let notificationPolling = null;
//
const unreadCount = computed(() => {
return notifications.value.filter(notification => !notification.read).length;
});
//
onMounted(() => {
fetchNotifications();
startNotificationPolling();
});
//
onUnmounted(() => {
stopNotificationPolling();
});
//
const toggleNotificationPanel = () => {
notificationPanelVisible.value = !notificationPanelVisible.value;
};
//
const fetchNotifications = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await axios.get(`${API_URL}/notifications`, {
headers: { Authorization: `Bearer ${token}` }
});
notifications.value = response.data;
} catch (error) {
console.error('알림을 가져오는 중 오류 발생:', error);
}
};
// (30)
const startNotificationPolling = () => {
notificationPolling = setInterval(() => {
fetchNotifications();
}, 30000);
};
//
const stopNotificationPolling = () => {
if (notificationPolling) {
clearInterval(notificationPolling);
notificationPolling = null;
}
};
//
const handleNotificationClick = async (notification) => {
//
if (!notification.read) {
await markAsRead(notification.id);
}
//
switch (notification.type) {
case 'club_invitation':
//
window.location.href = `/clubs/invitations`;
break;
case 'meeting_reminder':
//
window.location.href = `/meetings/${notification.data.meetingId}`;
break;
case 'score_update':
//
window.location.href = `/scores/${notification.data.scoreId}`;
break;
case 'subscription_reminder':
//
window.location.href = `/subscriptions`;
break;
default:
//
notificationPanelVisible.value = false;
break;
}
};
//
const markAsRead = async (notificationId) => {
try {
const token = localStorage.getItem('token');
await axios.put(`${API_URL}/notifications/${notificationId}/read`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
//
const index = notifications.value.findIndex(n => n.id === notificationId);
if (index !== -1) {
notifications.value[index].read = true;
}
} catch (error) {
console.error('알림을 읽음으로 표시하는 중 오류 발생:', error);
}
};
//
const markAllAsRead = async () => {
try {
const token = localStorage.getItem('token');
await axios.put(`${API_URL}/notifications/read-all`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
//
notifications.value = notifications.value.map(notification => ({
...notification,
read: true
}));
toast.add({ severity: 'success', summary: '성공', detail: '모든 알림이 읽음으로 표시되었습니다.', life: 3000 });
} catch (error) {
console.error('모든 알림을 읽음으로 표시하는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '알림 상태를 업데이트하는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const removeNotification = async (notificationId) => {
try {
const token = localStorage.getItem('token');
await axios.delete(`${API_URL}/notifications/${notificationId}`, {
headers: { Authorization: `Bearer ${token}` }
});
//
notifications.value = notifications.value.filter(n => n.id !== notificationId);
toast.add({ severity: 'success', summary: '성공', detail: '알림이 삭제되었습니다.', life: 3000 });
} catch (error) {
console.error('알림을 삭제하는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '알림을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const clearAllNotifications = async () => {
try {
const token = localStorage.getItem('token');
await axios.delete(`${API_URL}/notifications/clear-all`, {
headers: { Authorization: `Bearer ${token}` }
});
//
notifications.value = [];
toast.add({ severity: 'success', summary: '성공', detail: '모든 알림이 삭제되었습니다.', life: 3000 });
} catch (error) {
console.error('모든 알림을 삭제하는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '알림을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const getNotificationIcon = (type) => {
switch (type) {
case 'club_invitation':
return 'pi pi-users';
case 'meeting_reminder':
return 'pi pi-calendar';
case 'score_update':
return 'pi pi-chart-bar';
case 'subscription_reminder':
return 'pi pi-credit-card';
case 'system':
return 'pi pi-info-circle';
default:
return 'pi pi-bell';
}
};
//
const formatTime = (timestamp) => {
return dayjs(timestamp).fromNow();
};
</script>
<style scoped>
.notification-button {
position: relative;
}
.notification-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.notification-actions {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f0f0f0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #9e9e9e;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 1rem;
}
.notification-list {
overflow-y: auto;
max-height: 400px;
}
.notification-item {
display: flex;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.notification-item:hover {
background-color: #f5f5f5;
}
.notification-item.unread {
background-color: #e3f2fd;
}
.notification-item.unread:hover {
background-color: #bbdefb;
}
.notification-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f0f0f0;
margin-right: 0.75rem;
}
.notification-content {
flex: 1;
}
.notification-title {
font-weight: bold;
margin-bottom: 0.25rem;
}
.notification-message {
font-size: 0.9rem;
color: #616161;
margin-bottom: 0.25rem;
}
.notification-time {
font-size: 0.8rem;
color: #9e9e9e;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
@@ -0,0 +1,158 @@
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
:header="eventModel.title"
:style="{ width: '90vw', maxWidth: '1200px' }"
:modal="true"
class="event-details-dialog"
>
<TabView>
<!-- 기본 정보 -->
<TabPanel header="기본 정보">
<div class="event-details">
<div class="event-details-header">
<Badge :value="getEventTypeName(eventModel.eventType)" :severity="getStatusSeverity(eventModel.status)" />
<Badge :value="getStatusName(eventModel.status)" :severity="getStatusSeverity(eventModel.status)" />
</div>
<div class="event-details-content">
<div class="event-details-section">
<h4>이벤트 정보</h4>
<div class="grid">
<div class="col-12 md:col-6">
<p><strong>시작일:</strong> {{ formattedStartDate }}</p>
<p><strong>종료일:</strong> {{ formattedEndDate }}</p>
<p><strong>장소:</strong> {{ eventModel.location }}</p>
</div>
<div class="col-12 md:col-6">
<p><strong>최대 참가자 :</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p>
<p><strong>현재 참가자 :</strong> {{ eventModel.participants?.length || 0 }}</p>
</div>
</div>
</div>
<div class="event-details-section">
<h4>설명</h4>
<p>{{ eventModel.description || '설명이 없습니다.' }}</p>
</div>
</div>
</div>
</TabPanel>
<!-- 참가자 관리 -->
<TabPanel header="참가자 관리">
<ParticipantManagement
:eventId="eventModel.id"
:availableMembers="availableMembers"
@participant-updated="$emit('participant-updated')"
/>
</TabPanel>
<!-- 점수 관리 -->
<TabPanel header="점수 관리">
<ScoreManagement
:eventId="eventModel.id"
:participants="eventModel.participants || []"
:maxGames="eventModel.gameCount"
@score-updated="$emit('score-updated')"
/>
</TabPanel>
</TabView>
<template #footer>
<div class="dialog-footer">
<Button label="수정" icon="pi pi-pencil" class="p-button-text" @click="$emit('edit')" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { ref, computed } from 'vue';
import Dialog from 'primevue/dialog';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
import ParticipantManagement from './ParticipantManagement.vue';
import ScoreManagement from './ScoreManagement.vue';
import dayjs from 'dayjs';
const props = defineProps({
visible: {
type: Boolean,
required: true
},
event: {
type: Object,
required: true
},
getEventTypeName: {
type: Function,
required: true
},
getStatusName: {
type: Function,
required: true
},
getStatusSeverity: {
type: Function,
required: true
},
formatDate: {
type: Function,
required: true
},
availableMembers: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:visible', 'hide', 'edit', 'participant-updated', 'score-updated']);
const eventModel = computed(() => props.event);
const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate));
const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDate));
const hideDialog = () => {
emit('hide');
};
</script>
<style scoped>
.event-details {
margin-top: 1rem;
}
.event-details-header {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.event-details-section {
margin-bottom: 2rem;
}
.event-details-section h4 {
margin-bottom: 1rem;
color: var(--primary-color);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
:deep(.p-dialog-content) {
padding: 0 !important;
}
:deep(.p-tabview-panels) {
padding: 1.5rem !important;
}
</style>
@@ -0,0 +1,233 @@
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
:header="isNew ? '이벤트 추가' : '이벤트 수정'"
:style="{ width: '650px' }"
:modal="true"
class="p-fluid"
>
<div class="grid">
<div class="col-12">
<div class="field">
<label for="title">제목 *</label>
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" required autofocus />
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
</div>
</div>
<div class="col-12">
<div class="field">
<label for="description">설명</label>
<Textarea id="description" v-model="eventModel.description" rows="3" autoResize />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="eventType">이벤트 유형 *</label>
<Dropdown id="eventType" v-model="eventModel.eventType" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType}" />
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="status">상태 *</label>
<Dropdown id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status}" />
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="startDate">시작일 *</label>
<Calendar id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.startDate}" />
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="endDate">종료일</label>
<Calendar id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.endDate}" />
</div>
</div>
<div class="col-12">
<div class="field">
<label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location}" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" showButtons />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" showButtons />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.entryFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="registrationDeadline">등록 마감일</label>
<Calendar id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" />
</div>
</div>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveEvent" />
</template>
</Dialog>
</template>
<script setup>
import { ref, watch, toRefs, computed } from 'vue';
const statusOptions = [
{ name: '준비', value: '준비' },
{ name: '활성', value: '활성' },
{ name: '완료', value: '완료' },
{ name: '취소', value: '취소' },
{ name: '삭제', value: '삭제' }
];
const props = defineProps({
visible: {
type: Boolean,
required: true
},
event: {
type: Object,
required: true
},
isNew: {
type: Boolean,
required: true
},
submitted: {
type: Boolean,
required: true
}
});
const emit = defineEmits(['update:visible', 'save', 'hide']);
//
const eventTypeOptions = [
{ name: '정기전', value: '정기전' },
{ name: '번개', value: '번개' },
{ name: '연습', value: '연습' },
{ name: '교류전', value: '교류전' },
{ name: '이벤트', value: '이벤트' },
{ name: '기타', value: '기타' }
];
//
const eventModel = ref({ ...props.event });
//
const formatDate = (date) => {
if (!date) return null;
const d = new Date(date);
d.setHours(d.getHours() + 9); // UTC to KST
return d;
};
// props
watch(() => props.event, (newValue) => {
const formattedEvent = {
...newValue,
startDate: formatDate(newValue.startDate),
endDate: formatDate(newValue.endDate),
registrationDeadline: formatDate(newValue.registrationDeadline)
};
eventModel.value = formattedEvent;
}, { deep: true });
// UTC
const saveEvent = () => {
const eventData = { ...eventModel.value };
if (eventData.startDate) {
const d = new Date(eventData.startDate);
d.setHours(d.getHours() - 9); // KST to UTC
eventData.startDate = d;
}
if (eventData.endDate) {
const d = new Date(eventData.endDate);
d.setHours(d.getHours() - 9); // KST to UTC
eventData.endDate = d;
}
if (eventData.registrationDeadline) {
const d = new Date(eventData.registrationDeadline);
d.setHours(d.getHours() - 9); // KST to UTC
eventData.registrationDeadline = d;
}
emit('save', eventData);
};
//
const hideDialog = () => {
emit('hide');
};
</script>
<style scoped>
.field {
margin-bottom: 0.75rem;
}
.grid {
display: flex;
flex-wrap: wrap;
margin: -0.25rem;
}
.col-12 {
width: 100%;
padding: 0.25rem;
}
.md\:col-6 {
width: 50%;
padding: 0.25rem;
}
.dialog-footer {
padding: 0.5rem;
}
.p-dialog-content {
padding: 1rem;
}
.p-dialog-footer {
padding: 0.5rem;
}
:deep(.p-dropdown),
:deep(.p-calendar),
:deep(.p-inputnumber) {
width: 100%;
}
:deep(.p-inputtext) {
width: 100%;
}
</style>
@@ -0,0 +1,380 @@
<template>
<div class="participant-management">
<Toast />
<div class="header">
<Button label="참가자 추가" icon="pi pi-plus" class="p-button-success" @click="openNewDialog" />
</div>
<DataTable
:value="participants"
:loading="loading"
class="mt-4"
:paginator="true"
:rows="10"
stripedRows
v-model:filters="filters"
>
<Column field="Member.name" header="이름" sortable>
<template #body="slotProps">
<div class="flex flex-column">
<span>{{ slotProps.data.Member.name }}</span>
<small class="text-500">{{ slotProps.data.Member.phone }}</small>
</div>
</template>
</Column>
<Column field="Member.memberType" header="회원 유형" sortable>
<template #body="slotProps">
<Badge :value="getMemberTypeName(slotProps.data.Member.memberType)" :severity="getMemberTypeSeverity(slotProps.data.Member.memberType)" />
</template>
</Column>
<Column field="status" header="참가 상태" sortable>
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column field="paymentStatus" header="결제 상태" sortable>
<template #body="slotProps">
<Badge :value="getPaymentStatusName(slotProps.data.paymentStatus)" :severity="getPaymentStatusSeverity(slotProps.data.paymentStatus)" />
</template>
</Column>
<Column field="createdAt" header="등록일" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.createdAt) }}
</template>
</Column>
<Column header="작업">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editParticipant(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger" @click="confirmDeleteParticipant(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 참가자 추가/수정 다이얼로그 -->
<Dialog v-model:visible="participantDialog" :header="dialogTitle" modal class="p-fluid" :style="{width: '450px'}">
<div class="field">
<label for="member">회원</label>
<Dropdown
id="member"
v-model="editingParticipant.memberId"
:options="props.availableMembers"
optionLabel="name"
optionValue="id"
placeholder="회원을 선택하세요"
:class="{ 'p-invalid': submitted && !editingParticipant.memberId }"
:disabled="!!editingParticipant.id"
/>
<small v-if="submitted && !editingParticipant.memberId" class="p-error">회원을 선택해주세요.</small>
</div>
<div class="field">
<label for="status">참가 상태</label>
<Dropdown
id="status"
v-model="editingParticipant.status"
:options="participantStatusOptions"
optionLabel="name"
optionValue="value"
placeholder="상태를 선택하세요"
:class="{ 'p-invalid': submitted && !editingParticipant.status }"
/>
<small v-if="submitted && !editingParticipant.status" class="p-error">참가 상태를 선택해주세요.</small>
</div>
<div class="field">
<label for="paymentStatus">결제 상태</label>
<Dropdown
id="paymentStatus"
v-model="editingParticipant.paymentStatus"
:options="paymentStatusOptions"
optionLabel="name"
optionValue="value"
placeholder="결제 상태를 선택하세요"
:class="{ 'p-invalid': submitted && !editingParticipant.paymentStatus }"
/>
<small v-if="submitted && !editingParticipant.paymentStatus" class="p-error">결제 상태를 선택해주세요.</small>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveParticipant" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteDialog" :style="{width: '450px'}" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span> 참가자를 제거하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteParticipant" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, defineEmits } from 'vue';
import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService';
import dayjs from 'dayjs';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import Badge from 'primevue/badge';
import Toast from 'primevue/toast';
const props = defineProps({
eventId: {
type: [String, Number],
required: true
},
availableMembers: {
type: Array,
required: true
}
});
const emit = defineEmits(['participant-updated']);
const toast = useToast();
const participants = ref([]);
const loading = ref(false);
const filters = ref({});
const participantDialog = ref(false);
const deleteDialog = ref(false);
const submitted = ref(false);
const clubId = ref(localStorage.getItem('clubId') || null);
const editingParticipant = ref({
clubId: clubId.value,
memberId: null,
status: '참가예정',
paymentStatus: '미납'
});
const participantToDelete = ref(null);
const participantStatusOptions = [
{ name: '참가 예정', value: '참가예정' },
{ name: '참가 확정', value: '참가확정' },
{ name: '취소', value: '취소' }
];
const paymentStatusOptions = [
{ name: '미납', value: '미납' },
{ name: '납부 완료', value: '납부완료' }
];
const fetchParticipants = async () => {
try {
loading.value = true;
const data = await eventService.getEventParticipants(props.eventId);
participants.value = data;
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 목록을 불러오는 중 오류가 발생했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
const openNewDialog = () => {
editingParticipant.value = {
clubId: localStorage.getItem('clubId') || null,
memberId: null,
status: '참가예정',
paymentStatus: '미납'
};
submitted.value = false;
participantDialog.value = true;
};
const editParticipant = (participant) => {
editingParticipant.value = {
id: participant.id,
clubId: localStorage.getItem('clubId') || null,
memberId: participant.memberId,
status: participant.status,
paymentStatus: participant.paymentStatus
};
participantDialog.value = true;
submitted.value = false;
};
const hideDialog = () => {
participantDialog.value = false;
submitted.value = false;
};
const saveParticipant = async () => {
submitted.value = true;
if (!editingParticipant.value.memberId) {
return;
}
try {
if (editingParticipant.value.id) {
//
await eventService.updateEventParticipant(props.eventId, editingParticipant.value);
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자 정보가 수정되었습니다.',
life: 3000
});
} else {
//
await eventService.addEventParticipant(props.eventId, editingParticipant.value);
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자가 추가되었습니다.',
life: 3000
});
}
await fetchParticipants();
hideDialog();
} catch (error) {
console.error('참가자 저장 오류:', error.message);
toast.add({
severity: 'error',
summary: '오류',
detail: error.message,
life: 3000
});
}
};
const confirmDeleteParticipant = (participant) => {
participantToDelete.value = participant;
deleteDialog.value = true;
};
const deleteParticipant = async () => {
try {
await eventService.removeEventParticipant(props.eventId, { id: participantToDelete.value.id });
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자가 삭제되었습니다.',
life: 3000
});
deleteDialog.value = false;
participantToDelete.value = null;
await fetchParticipants();
} catch (error) {
console.error('참가자 삭제 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 삭제 중 오류가 발생했습니다.',
life: 3000
});
}
};
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm');
};
const getMemberTypeName = (type) => {
const types = {
regular: '정회원',
associate: '준회원',
guest: '게스트'
};
return types[type] || type;
};
const getMemberTypeSeverity = (type) => {
const severities = {
regular: 'success',
associate: 'info',
guest: 'warning'
};
return severities[type] || 'info';
};
const getStatusName = (status) => {
const statuses = {
참가예정: '참가 예정',
참가완료: '참가 완료',
취소: '취소'
};
return statuses[status] || status;
};
const getStatusSeverity = (status) => {
const severities = {
참가예정: 'warning',
참가완료: 'success',
취소: 'danger'
};
return severities[status] || 'info';
};
const getPaymentStatusName = (status) => {
const statuses = {
미납: '미납',
납부완료: '납부 완료'
};
return statuses[status] || status;
};
const getPaymentStatusSeverity = (status) => {
const severities = {
미납: 'danger',
납부완료: 'success'
};
return severities[status] || 'info';
};
const dialogTitle = computed(() => {
return editingParticipant.value.id ? '참가자 정보 수정' : '참가자 추가';
});
onMounted(() => {
fetchParticipants();
});
</script>
<style scoped>
.participant-management {
padding: 1rem;
}
.header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.field {
margin-bottom: 1.5rem;
}
.field label {
display: block;
margin-bottom: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
}
</style>
<style>
:deep(.p-toast) {
z-index: 9999;
}
</style>
@@ -0,0 +1,489 @@
<template>
<div class="score-management">
<DataTable
:value="groupedScores"
:loading="loading"
class="mt-4"
:paginator="true"
:rows="10"
stripedRows
v-model:filters="filters"
>
<Column field="participant.name" header="참가자" sortable>
<template #body="slotProps">
{{ slotProps.data.participant?.name }}
</template>
</Column>
<Column field="teamNumber" header="팀" sortable style="width: 80px">
<template #body="slotProps">
{{ slotProps.data.teamNumber }}
</template>
</Column>
<template v-for="i in maxGames" :key="i">
<Column :field="'games.' + (i-1) + '.score'" :header="i + '게임'" sortable>
<template #body="slotProps">
<div class="flex flex-column">
<span :class="getScoreClass(getTotalScore(slotProps.data.games[i-1]))">
{{ getTotalScore(slotProps.data.games[i-1]) || '-' }}
</span>
<span class="text-xs text-gray-500" v-if="slotProps.data.games[i-1]?.score && slotProps.data.games[i-1]?.handicap !== 0">
{{ formatScoreWithHandicap(slotProps.data.games[i-1]) }}
</span>
</div>
</template>
</Column>
</template>
<Column field="average" header="평균" sortable>
<template #body="slotProps">
{{ calculateAverage(slotProps.data.games) }}
</template>
</Column>
<Column header="작업">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editScore(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text" @click="deleteParticipantScores(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 점수 입력/수정 다이얼로그 -->
<Dialog v-model:visible="scoreDialog" :header="scoreDialogTitle" modal class="p-fluid" :style="{width: '600px'}">
<div class="field">
<label for="participant">참가자</label>
<Dropdown
id="participant"
v-model="editingScore.participantId"
:options="participants"
optionLabel="name"
optionValue="id"
placeholder="참가자 선택"
:class="{'p-invalid': submitted && !editingScore.participantId}"
:disabled="!!editingScore.id"
/>
<small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small>
</div>
<div class="field">
<label for="teamNumber"></label>
<InputNumber
id="teamNumber"
v-model="editingScore.teamNumber"
:min="1"
:max="10"
placeholder="팀 번호"
/>
</div>
<div class="grid">
<template v-for="game in editingScore.games" :key="game.gameNumber">
<div class="col-6">
<div class="field">
<label :for="'game' + game.gameNumber">{{ game.gameNumber }}게임 점수</label>
<InputNumber
:id="'game' + game.gameNumber"
v-model="game.score"
:min="0"
:max="300"
:placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'"
@update:modelValue="() => onScoreChange(game)"
/>
</div>
</div>
<div class="col-6">
<div class="field">
<label :for="'handicap' + game.gameNumber">{{ game.gameNumber }}게임 핸디캡</label>
<InputNumber
:id="'handicap' + game.gameNumber"
v-model="game.handicap"
:min="-300"
:max="300"
placeholder="핸디캡 입력"
@update:modelValue="() => onScoreChange(game)"
/>
</div>
</div>
</template>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideScoreDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveScore" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteScoreDialog" :style="{width: '450px'}" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span> 점수를 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteScoreDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteScore" />
</template>
</Dialog>
<!-- 점수 추가 버튼 -->
<div class="flex justify-content-end mt-4">
<Button label="점수 추가" icon="pi pi-plus" @click="showScoreDialog" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService';
import dayjs from 'dayjs';
const props = defineProps({
eventId: {
type: [String, Number],
required: true
},
maxGames: {
type: Number,
required: true
}
});
const emit = defineEmits(['score-updated']);
const toast = useToast();
const scores = ref([]);
const loading = ref(false);
const filters = ref({});
const scoreDialog = ref(false);
const deleteScoreDialog = ref(false);
const submitted = ref(false);
const participants = ref([]);
const editingScore = ref({
isEdit: false,
participantId: null,
teamNumber: null,
games: []
});
//
const fetchScores = async () => {
loading.value = true;
try {
const response = await eventService.getEventScores(props.eventId);
scores.value = response || [];
await fetchParticipants();
} catch (error) {
console.error('점수 조회 오류:', error);
scores.value = [];
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '점수 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
//
const fetchParticipants = async () => {
try {
const data = await eventService.getEventParticipants(props.eventId);
if (Array.isArray(data)) {
participants.value = data.map(p => ({
id: p.id,
name: p.Member.name
}));
} else {
participants.value = [];
console.error('참가자 데이터가 배열이 아닙니다:', data);
}
} catch (error) {
console.error('참가자 조회 오류:', error);
participants.value = [];
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '참가자 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
}
};
//
const editScore = (data) => {
// console.log('Edit score data:', data);
editingScore.value = {
isEdit: true,
participantId: data.participant.id,
teamNumber: data.teamNumber,
games: data.games.map((game, index) => ({
id: game.id,
gameNumber: game.gameNumber ?? index + 1,
score: game.score ?? null,
handicap: game.handicap ?? null,
isModified: false
}))
};
scoreDialog.value = true;
};
//
const showScoreDialog = async () => {
try {
//
await fetchParticipants();
editingScore.value = {
isEdit: false,
participantId: null,
teamNumber: null,
games: Array.from({ length: props.maxGames }, (_, i) => ({
id: null,
gameNumber: i + 1,
score: null,
handicap: null,
isModified: false
}))
};
submitted.value = false;
scoreDialog.value = true;
} catch (error) {
console.error('점수 입력 다이얼로그 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
}
};
//
const hideScoreDialog = () => {
scoreDialog.value = false;
submitted.value = false;
};
//
const saveScore = async () => {
submitted.value = true;
if (!editingScore.value.participantId) {
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자를 선택해주세요.',
life: 3000
});
return;
}
try {
if (editingScore.value.isEdit) {
// :
const updatePromises = editingScore.value.games
.map((game) => {
//
if (game.score === null || game.score === undefined) return null;
//
if (game.id && !game.isModified) return null;
const scoreData = {
gameNumber: game.gameNumber,
score: game.score,
handicap: game.handicap || 0
};
//
if (game.id) {
console.log('Updating existing game:', {
eventId: props.eventId,
scoreId: game.id,
...scoreData
});
return eventService.updateScore(
Number(props.eventId),
Number(game.id),
scoreData
);
}
//
console.log('Adding new game in edit mode:', {
eventId: props.eventId,
participantId: editingScore.value.participantId,
...scoreData
});
return eventService.addScore(props.eventId, {
...scoreData,
participantId: editingScore.value.participantId,
teamNumber: editingScore.value.teamNumber
});
})
.filter(Boolean);
if (updatePromises.length > 0) {
await Promise.all(updatePromises);
}
} else {
// ( )
const addPromises = editingScore.value.games
.filter(game => game.score !== null && game.score !== undefined)
.map(game => {
const scoreData = {
participantId: editingScore.value.participantId,
teamNumber: editingScore.value.teamNumber,
gameNumber: game.gameNumber,
score: Number(game.score),
handicap: Number(game.handicap || 0)
};
console.log('Adding game:', scoreData);
return eventService.addScore(props.eventId, scoreData);
});
if (addPromises.length > 0) {
await Promise.all(addPromises);
}
}
toast.add({
severity: 'success',
summary: '성공',
detail: '점수가 저장되었습니다.',
life: 3000
});
hideScoreDialog();
await fetchScores();
} catch (error) {
console.error('점수 저장 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '점수 저장 중 오류가 발생했습니다.',
life: 3000
});
}
};
const getScoreClass = (score) => {
if (!score) return '';
if (score >= 200) return 'text-red-600 font-bold';
return '';
};
const scoreDialogTitle = computed(() => {
return editingScore.value.isEdit ? '점수 수정' : '점수 등록';
});
const getTotalScore = (game) => {
if (!game || !game.score || game.score === 0) return null;
return game.score + (game.handicap || 0);
};
const calculateAverage = (games) => {
const validGames = games.filter(game => game && game.score && game.score > 0);
if (!validGames.length) return '-';
const total = validGames.reduce((sum, game) => sum + getTotalScore(game), 0);
return (total / validGames.length).toFixed(1);
};
const formatScoreWithHandicap = (game) => {
if (!game || !game.score) return '';
const handicap = game.handicap || 0;
if (handicap === 0) return `(${game.score})`;
if (handicap < 0) return `(${game.score}${handicap})`;
return `(${game.score}+${handicap})`;
};
const groupedScores = computed(() => {
if (!participants.value || !scores.value) {
return [];
}
const grouped = {};
const participantsWithScores = new Set();
scores.value.forEach(score => {
if (!grouped[score.participantId]) {
const participant = participants.value.find(p => p.id === score.participantId);
if (participant) {
grouped[score.participantId] = {
participant,
participantId: participant.id,
teamNumber: score.teamNumber,
games: Array(props.maxGames).fill(null).map(() => ({ score: null, handicap: null, gameNumber: null }))
};
participantsWithScores.add(score.participantId);
}
}
if (grouped[score.participantId]) {
grouped[score.participantId].games[score.gameNumber - 1] = {
id: score.id,
score: score.score,
handicap: score.handicap,
gameNumber: score.gameNumber
};
}
});
return Object.values(grouped);
});
//
const onScoreChange = (game) => {
if (game) {
game.isModified = true;
/*
console.log('Score changed:', {
gameNumber: game.gameNumber,
score: game.score,
handicap: game.handicap,
isModified: game.isModified
});
*/
}
};
const deleteParticipantScores = async (data) => {
try {
await eventService.deleteParticipantScores(
Number(props.eventId),
Number(data.participant.id)
);
toast.add({
severity: 'success',
summary: '성공',
detail: '점수가 삭제되었습니다.',
life: 3000
});
await fetchScores();
} catch (error) {
console.error('점수 삭제 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: error.message || '점수 삭제 중 오류가 발생했습니다.',
life: 3000
});
}
};
onMounted(() => {
fetchScores(); //
});
</script>
<style scoped>
.score-management {
padding: 1rem;
}
</style>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="club-layout">
<router-view></router-view>
</div>
</template>
<script>
export default {
name: 'ClubLayout'
}
</script>
<style scoped>
.club-layout {
width: 100%;
height: 100%;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// PrimeVue
import PrimeVue from 'primevue/config'
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
import 'primeflex/primeflex.css'
// PrimeVue Components
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import Dropdown from 'primevue/dropdown'
import Toast from 'primevue/toast'
import ToastService from 'primevue/toastservice'
import Card from 'primevue/card'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Menu from 'primevue/menu'
import Menubar from 'primevue/menubar'
import PanelMenu from 'primevue/panelmenu'
import Chart from 'primevue/chart'
import Avatar from 'primevue/avatar'
import Checkbox from 'primevue/checkbox'
import Password from 'primevue/password'
import Textarea from 'primevue/textarea'
import Badge from 'primevue/badge'
import Calendar from 'primevue/calendar'
import InputNumber from 'primevue/inputnumber'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
const app = createApp(App)
// Register PrimeVue
app.use(PrimeVue)
app.use(ToastService)
app.use(createPinia())
app.use(router)
// Register PrimeVue Components
app.component('Button', Button)
app.component('InputText', InputText)
app.component('DataTable', DataTable)
app.component('Column', Column)
app.component('Dialog', Dialog)
app.component('Dropdown', Dropdown)
app.component('Toast', Toast)
app.component('Card', Card)
app.component('TabView', TabView)
app.component('TabPanel', TabPanel)
app.component('Menu', Menu)
app.component('Menubar', Menubar)
app.component('PanelMenu', PanelMenu)
app.component('Chart', Chart)
app.component('Avatar', Avatar)
app.component('Checkbox', Checkbox)
app.component('Password', Password)
app.component('Textarea', Textarea)
app.component('Badge', Badge)
app.component('Calendar', Calendar)
app.component('InputNumber', InputNumber)
app.component('Divider', Divider)
app.component('ProgressSpinner', ProgressSpinner)
app.mount('#app')
+180
View File
@@ -0,0 +1,180 @@
import { createRouter, createWebHistory } from 'vue-router';
// 인증 관련 뷰
const Login = () => import('@/views/auth/Login.vue');
const Profile = () => import('@/views/auth/Profile.vue');
// 관리자 뷰
const AdminDashboard = () => import('@/views/admin/Dashboard.vue');
const ClubManagement = () => import('@/views/admin/ClubManagement.vue');
const UserManagement = () => import('@/views/admin/UserManagement.vue');
const SubscriptionManagement = () => import('@/views/admin/SubscriptionManagement.vue');
// 클럽 뷰
const ClubDashboard = () => import('@/views/club/Dashboard.vue');
const MemberManagement = () => import('@/views/club/MemberManagement.vue');
const Statistics = () => import('@/views/club/Statistics.vue');
const EventManagement = () => import('@/views/club/EventManagement.vue');
const EventCalendar = () => import('@/views/club/EventCalendar.vue');
const ScoreManagement = () => import('@/views/club/ScoreManagement.vue');
const UserClubCreate = () => import('@/views/club/ClubCreate.vue');
// 구독 관리 컴포넌트 import
const ClubSubscriptionManagement = () => import('@/views/club/subscription/SubscriptionManagement.vue');
const routes = [
// 공개 경로
{
path: '/',
redirect: '/login'
},
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false }
},
// 인증 필요 경로
{
path: '/profile',
name: 'Profile',
component: Profile,
meta: { requiresAuth: true }
},
// 관리자 경로
{
path: '/admin',
name: 'AdminDashboard',
component: AdminDashboard,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/clubs',
name: 'ClubManagement',
component: ClubManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/users',
name: 'UserManagement',
component: UserManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/subscriptions',
name: 'SubscriptionManagement',
component: SubscriptionManagement,
meta: { requiresAuth: true, requiresAdmin: true }
},
// 클럽 경로
{
path: '/club',
component: () => import('@/layouts/ClubLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'ClubDashboard',
component: () => import('@/views/club/Dashboard.vue')
},
{
path: 'members',
name: 'MemberManagement',
component: () => import('@/views/club/MemberManagement.vue')
},
{
path: 'events',
name: 'EventManagement',
component: () => import('@/views/club/EventManagement.vue')
},
{
path: 'events/calendar',
name: 'EventCalendar',
component: () => import('@/views/club/EventCalendar.vue')
},
{
path: 'events/:eventId/scores',
name: 'ScoreManagement',
component: () => import('@/views/club/ScoreManagement.vue'),
props: true
},
{
path: 'statistics',
name: 'Statistics',
component: () => import('@/views/club/Statistics.vue')
},
{
path: 'subscription',
name: 'ClubSubscription',
component: ClubSubscriptionManagement,
meta: {
requiresAuth: true,
requiredRoles: ['모임장', '운영진']
}
}
]
},
{
path: '/club/create',
name: 'UserClubCreate',
component: () => import('@/views/club/ClubCreate.vue'),
meta: { requiresAuth: true }
}
];
const router = createRouter({
history: createWebHistory(),
routes,
linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-active'
});
// 네비게이션 가드
router.beforeEach(async (to, from, next) => {
const isAuthenticated = !!localStorage.getItem('token');
const userRole = localStorage.getItem('userRole');
const clubId = localStorage.getItem('clubId');
const userClubRole = localStorage.getItem('userClubRole');
// 인증이 필요한 페이지인데 로그인하지 않은 경우
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'Login' });
return;
}
// 관리자 권한이 필요한 페이지인데 관리자가 아닌 경우
if (to.meta.requiresAdmin && userRole !== 'admin' && userRole !== 'superadmin') {
next({ name: 'ClubDashboard' });
return;
}
// 이미 로그인한 상태에서 로그인 페이지로 이동하려는 경우
if (to.name === 'Login' && isAuthenticated) {
if (userRole === 'admin' || userRole === 'superadmin') {
next({ name: 'AdminDashboard' });
} else {
next({ name: 'ClubDashboard' });
}
return;
}
// 일반 사용자가 클럽이 없는 경우, 클럽 생성 페이지로 리다이렉트 (관리자 제외)
if (isAuthenticated && userRole !== 'admin' && userRole !== 'superadmin' && !clubId &&
to.name !== 'UserClubCreate' && to.name !== 'Profile' && to.name !== 'Login') {
next({ name: 'UserClubCreate' });
return;
}
// 클럽 구독 관리 페이지에 접근하려는 경우, 권한 확인
if (to.name === 'ClubSubscription' && (userClubRole !== '모임장' && userClubRole !== '운영진')) {
next({ name: 'ClubDashboard' });
return;
}
next();
});
export default router;
+351
View File
@@ -0,0 +1,351 @@
import apiClient from './api';
/**
* 관리자 API 서비스
*/
const adminService = {
/**
* 모든 클럽 목록 조회
* @returns {Promise} - 클럽 목록
*/
getClubs() {
return apiClient.get('/api/admin/clubs');
},
/**
* 모든 클럽 조회
* @returns {Promise} - 클럽 목록
*/
getAllClubs() {
return apiClient.get('/api/admin/clubs');
},
/**
* 특정 클럽 조회
* @param {Number} id - 클럽 ID
* @returns {Promise} - 클럽 정보
*/
getClubById(id) {
return apiClient.get(`/api/admin/clubs/${id}`);
},
/**
* 클럽 정보 업데이트
* @param {Number} id - 클럽 ID
* @param {Object} clubData - 업데이트할 클럽 정보
* @returns {Promise} - 업데이트된 클럽 정보
*/
updateClub(id, clubData) {
return apiClient.put(`/api/admin/clubs/${id}`, clubData);
},
/**
* 클럽 삭제
* @param {Number} id - 클럽 ID
* @returns {Promise} - 삭제 결과
*/
deleteClub(id) {
return apiClient.delete(`/api/admin/clubs/${id}`);
},
/**
* 클럽 멤버 조회
* @param {Number} id - 클럽 ID
* @returns {Promise} - 클럽 멤버 목록
*/
getClubMembers(id) {
return apiClient.get(`/api/admin/clubs/${id}/members`);
},
/**
* 클럽 멤버 추가
* @param {Number} id - 클럽 ID
* @param {Object} memberData - 멤버 정보
* @returns {Promise} - 추가된 멤버 정보
*/
addClubMember(id, memberData) {
return apiClient.post(`/api/admin/clubs/${id}/members`, memberData);
},
/**
* 클럽 멤버 정보 업데이트
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 멤버 ID
* @param {Object} memberData - 업데이트할 멤버 정보
* @returns {Promise} - 업데이트된 멤버 정보
*/
updateClubMember(clubId, memberId, memberData) {
return apiClient.put(`/api/admin/clubs/${clubId}/members/${memberId}`, memberData);
},
/**
* 클럽 회원 삭제
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 멤버 ID
* @returns {Promise} - 삭제 결과
*/
deleteClubMember(clubId, memberId) {
return apiClient.delete(`/api/admin/clubs/${clubId}/members/${memberId}`);
},
/**
* 모든 사용자 조회
* @returns {Promise} - 사용자 목록
*/
getAllUsers() {
return apiClient.get('/api/admin/users');
},
/**
* 특정 사용자 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 사용자 정보
*/
getUserById(id) {
return apiClient.get(`/api/admin/users/${id}`);
},
/**
* 사용자 정보 업데이트
* @param {Number} id - 사용자 ID
* @param {Object} userData - 업데이트할 사용자 정보
* @returns {Promise} - 업데이트된 사용자 정보
*/
updateUser(id, userData) {
return apiClient.put(`/api/admin/users/${id}`, userData);
},
/**
* 사용자 삭제
* @param {Number} id - 사용자 ID
* @returns {Promise} - 삭제 결과
*/
deleteUser(id) {
return apiClient.delete(`/api/admin/users/${id}`);
},
/**
* 모든 이벤트 조회
* @returns {Promise} - 이벤트 목록
*/
getAllEvents() {
return apiClient.get('/api/admin/events');
},
/**
* 특정 이벤트 조회
* @param {Number} id - 이벤트 ID
* @returns {Promise} - 이벤트 정보
*/
getEventById(id) {
return apiClient.get(`/api/admin/events/${id}`);
},
/**
* 이벤트 생성
* @param {Object} eventData - 이벤트 정보
* @returns {Promise} - 생성된 이벤트 정보
*/
createEvent(eventData) {
return apiClient.post('/api/admin/events', eventData);
},
/**
* 이벤트 정보 업데이트
* @param {Number} id - 이벤트 ID
* @param {Object} eventData - 업데이트할 이벤트 정보
* @returns {Promise} - 업데이트된 이벤트 정보
*/
updateEvent(id, eventData) {
return apiClient.put(`/api/admin/events/${id}`, eventData);
},
/**
* 이벤트 삭제
* @param {Number} id - 이벤트 ID
* @returns {Promise} - 삭제 결과
*/
deleteEvent(id) {
return apiClient.delete(`/api/admin/events/${id}`);
},
/**
* 이벤트 참가자 목록 조회
* @param {Number} eventId - 이벤트 ID
* @returns {Promise} - 참가자 목록
*/
getEventParticipants(eventId) {
return apiClient.get(`/api/admin/events/${eventId}/participants`);
},
/**
* 이벤트 참가자 추가
* @param {Number} eventId - 이벤트 ID
* @param {Number} userId - 사용자 ID
* @returns {Promise} - 추가된 참가자 정보
*/
addEventParticipant(eventId, userId) {
return apiClient.post(`/api/admin/events/${eventId}/participants`, { userId });
},
/**
* 이벤트 참가자 상태 업데이트
* @param {Number} eventId - 이벤트 ID
* @param {Number} participantId - 참가자 ID
* @param {String} status - 새로운 상태
* @returns {Promise} - 업데이트된 참가자 정보
*/
updateEventParticipantStatus(eventId, participantId, status) {
return apiClient.put(`/api/admin/events/${eventId}/participants/${participantId}`, { status });
},
/**
* 이벤트 참가자 제거
* @param {Number} eventId - 이벤트 ID
* @param {Number} participantId - 참가자 ID
* @returns {Promise} - 제거 결과
*/
removeEventParticipant(eventId, participantId) {
return apiClient.delete(`/api/admin/events/${eventId}/participants/${participantId}`);
},
/**
* 구독 플랜 목록 조회
* @returns {Promise} - 구독 플랜 목록
*/
getPlans() {
return apiClient.get('/api/admin/subscriptions/plans');
},
/**
* 특정 구독 플랜 조회
* @param {Number} id - 구독 플랜 ID
* @returns {Promise} - 구독 플랜 정보
*/
getPlanById(id) {
return apiClient.get(`/api/admin/subscriptions/plans/${id}`);
},
/**
* 구독 플랜 생성
* @param {Object} planData - 구독 플랜 정보
* @returns {Promise} - 생성된 구독 플랜 정보
*/
createPlan(planData) {
return apiClient.post('/api/admin/subscriptions/plans', planData);
},
/**
* 구독 플랜 정보 업데이트
* @param {Number} id - 구독 플랜 ID
* @param {Object} planData - 업데이트할 구독 플랜 정보
* @returns {Promise} - 업데이트된 구독 플랜 정보
*/
updatePlan(id, planData) {
return apiClient.put(`/api/admin/subscriptions/plans/${id}`, planData);
},
/**
* 구독 플랜 삭제
* @param {Number} id - 구독 플랜 ID
* @returns {Promise} - 삭제 결과
*/
deletePlan(id) {
return apiClient.delete(`/api/admin/subscriptions/plans/${id}`);
},
/**
* 모든 구독 목록 조회
* @returns {Promise} - 구독 목록
*/
getSubscriptions() {
return apiClient.get('/api/admin/subscriptions');
},
/**
* 구독 플랜의 구독 목록 조회
* @param {Number} planId - 구독 플랜 ID
* @returns {Promise} - 구독 목록
*/
getPlanSubscriptions(planId) {
return apiClient.get(`/api/admin/subscriptions/plans/${planId}/subscriptions`);
},
/**
* 모든 기능 목록 조회
* @returns {Promise} - 기능 목록
*/
getFeatures() {
return apiClient.get('/api/admin/features');
},
/**
* 구독 생성
* @param {Object} subscriptionData - 구독 정보
* @returns {Promise} - 생성된 구독 정보
*/
createSubscription(subscriptionData) {
return apiClient.post('/api/admin/subscriptions', subscriptionData);
},
/**
* 구독 정보 업데이트
* @param {Number} id - 구독 ID
* @param {Object} subscriptionData - 업데이트할 구독 정보
* @returns {Promise} - 업데이트된 구독 정보
*/
updateSubscription(id, subscriptionData) {
return apiClient.put(`/api/admin/subscriptions/${id}`, subscriptionData);
},
/**
* 구독 삭제
* @param {Number} id - 구독 ID
* @returns {Promise} - 삭제 결과
*/
deleteSubscription(id) {
return apiClient.delete(`/api/admin/subscriptions/${id}`);
},
/**
* 대시보드 데이터 조회
* @returns {Promise} - 대시보드 데이터
*/
getDashboardData() {
return apiClient.get('/api/admin/dashboard');
},
/**
* 최근 활동 조회
* @returns {Promise} - 최근 활동 목록
*/
getRecentActivities() {
return apiClient.get('/api/admin/activities/recent');
},
/**
* 통계 데이터 조회
* @returns {Promise} - 통계 데이터
*/
getStatistics() {
return apiClient.get('/api/admin/statistics');
},
/**
* 클럽 기능 관리
* @param {Number} clubId - 클럽 ID
* @param {Object} features - 클럽 기능 정보
* @returns {Promise} - 클럽 기능 정보
*/
manageClubFeatures: async (clubId, features) => {
try {
const response = await apiClient.put(`/api/admin/clubs/${clubId}/features`, { features });
return response.data;
} catch (error) {
console.error('클럽 기능 관리 중 오류:', error);
throw error;
}
}
};
export default adminService;
+46
View File
@@ -0,0 +1,46 @@
import axios from 'axios';
// 환경 변수에서 API URL을 가져오거나 기본값 사용
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3030';
// axios 인스턴스 생성
const apiClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000
});
// 요청 인터셉터 - 토큰 추가
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 응답 인터셉터 - 에러 처리
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// 401 에러 (인증 실패) 처리
if (error.response && error.response.status === 401) {
// 로컬 스토리지에서 토큰 제거
localStorage.removeItem('token');
// 로그인 페이지로 리디렉션
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
+51
View File
@@ -0,0 +1,51 @@
import apiClient from './api';
/**
* 인증 관련 API 서비스
*/
const authService = {
/**
* 로그인
* @param {Object} credentials - 로그인 정보 (이메일, 비밀번호)
* @returns {Promise} - 로그인 결과
*/
login(credentials) {
return apiClient.post('/api/auth/login', credentials);
},
/**
* 회원가입
* @param {Object} userData - 사용자 정보
* @returns {Promise} - 회원가입 결과
*/
register(userData) {
return apiClient.post('/api/auth/register', userData);
},
/**
* 현재 사용자 정보 조회
* @returns {Promise} - 사용자 정보
*/
getCurrentUser() {
return apiClient.get('/api/auth/me');
},
/**
* 비밀번호 변경
* @param {Object} passwordData - 비밀번호 변경 정보
* @returns {Promise} - 비밀번호 변경 결과
*/
changePassword(passwordData) {
return apiClient.post('/api/auth/change-password', passwordData);
},
/**
* 로그아웃
*/
logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
};
export default authService;
+109
View File
@@ -0,0 +1,109 @@
import apiClient from './api';
/**
* 클럽 관련 API 서비스
*/
const clubService = {
/**
* 모든 클럽 조회
* @returns {Promise} - 클럽 목록
*/
getAllClubs() {
return apiClient.get('/api/club');
},
/**
* 특정 클럽 조회
* @param {Number} id - 클럽 ID
* @returns {Promise} - 클럽 정보
*/
getClubById(id) {
return apiClient.post(`/api/club`, { clubId: id });
},
/**
* 클럽 생성
* @param {Object} clubData - 클럽 정보
* @returns {Promise} - 생성된 클럽 정보
*/
createClub(clubData) {
return apiClient.post('/api/club/create', clubData);
},
/**
* 클럽 정보 업데이트
* @param {Number} id - 클럽 ID
* @param {Object} clubData - 업데이트할 클럽 정보
* @returns {Promise} - 업데이트된 클럽 정보
*/
updateClub(id, clubData) {
return apiClient.put(`/api/club`, { clubId: id, ...clubData });
},
/**
* 클럽 삭제
* @param {Number} id - 클럽 ID
* @returns {Promise} - 삭제 결과
*/
deleteClub(id) {
return apiClient.delete(`/api/club`, { clubId: id });
},
/**
* 클럽 회원 목록 조회
* @param {Number} clubId - 클럽 ID
* @returns {Promise} - 회원 목록
*/
getClubMembers(clubId) {
return apiClient.post('/api/club/members', { clubId });
},
/**
* 클럽 회원 추가
* @param {Number} clubId - 클럽 ID
* @param {Object} memberData - 회원 정보
* @returns {Promise} - 추가된 회원 정보
*/
addClubMember(clubId, memberData) {
return apiClient.post('/api/club/members/add', {
clubId,
...memberData
});
},
/**
* 클럽 회원 정보 수정
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 회원 ID
* @param {Object} memberData - 수정할 회원 정보
* @returns {Promise} - 수정된 회원 정보
*/
updateClubMember(clubId, memberId, memberData) {
return apiClient.post('/api/club/members/update', {
clubId,
memberId,
...memberData
});
},
/**
* 클럽 회원 삭제
* @param {Number} clubId - 클럽 ID
* @param {Number} memberId - 회원 ID
* @returns {Promise} - 삭제 결과
*/
deleteClubMember(clubId, memberId) {
return apiClient.post('/api/club/members/delete', { clubId, memberId });
},
/**
* 클럽 회원 통계 조회
* @param {Number} clubId - 클럽 ID
* @returns {Promise} - 회원 통계 정보
*/
getMemberStats(clubId) {
return apiClient.post('/api/club/members/stats', { clubId });
}
};
export default clubService;
+370
View File
@@ -0,0 +1,370 @@
import apiClient from './api';
import dayjs from 'dayjs';
/**
* 이벤트 서비스 - 이벤트 관련 API 호출을 처리하는 서비스
*/
export default {
/**
* 모든 이벤트 목록 가져오기
* @param {number} clubId - 클럽 ID
* @returns {Promise<Array>} 이벤트 목록
*/
async getEvents(clubId) {
if (!clubId) {
throw new Error('클럽 ID가 필요합니다.');
}
try {
const response = await apiClient.post('/api/club/events', { clubId });
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 특정 이벤트 상세 정보 가져오기
* @param {number} id - 이벤트 ID
* @returns {Promise<Object>} 이벤트 상세 정보
*/
async getEventById(id) {
if (!id) {
throw new Error('이벤트 ID가 필요합니다.');
}
try {
const response = await apiClient.get(`/api/club/events/${id}`);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 이벤트 추가
* @param {Object} event - 이벤트 객체
* @returns {Promise<Object>} 생성된 이벤트 객체
*/
async createEvent(event) {
if (!event) {
throw new Error('이벤트 객체가 필요합니다.');
}
try {
const formattedEvent = {
...event,
startDate: dayjs(event.startDate).format('YYYY-MM-DD'),
endDate: event.endDate ? dayjs(event.endDate).format('YYYY-MM-DD') : null
};
const response = await apiClient.post('/api/club/events/create', formattedEvent);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 기존 이벤트 수정
* @param {Object} event - 수정할 이벤트 객체
* @returns {Promise<Object>} 수정된 이벤트 객체
*/
async updateEvent(event) {
if (!event) {
throw new Error('이벤트 객체가 필요합니다.');
}
try {
const formattedEvent = {
...event,
startDate: dayjs(event.startDate).format('YYYY-MM-DD'),
endDate: dayjs(event.endDate).format('YYYY-MM-DD')
};
const response = await apiClient.put(`/api/club/events/${event.id}`, formattedEvent);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 이벤트 삭제
* @param {number} clubId - 클럽 ID
* @param {number} id - 삭제할 이벤트 ID
* @returns {Promise<void>}
*/
async deleteEvent(clubId, id) {
if (!clubId || !id) {
throw new Error('클럽 ID와 이벤트 ID가 필요합니다.');
}
try {
await apiClient.delete(`/api/club/events/${id}`, {
data: { clubId }
});
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 이벤트 참가자 목록 조회
* @param {number} eventId - 이벤트 ID
* @returns {Promise<Array>} 참가자 목록
*/
async getEventParticipants(eventId) {
if (!eventId) {
throw new Error('이벤트 ID가 필요합니다.');
}
try {
const response = await apiClient.post(`/api/club/events/${eventId}/participants/list`, {
eventId,
clubId: localStorage.getItem('clubId')
});
return response.data.participants;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 이벤트 참가자 추가
* @param {number} eventId - 이벤트 ID
* @param {Object} participant - 참가자 정보
* @returns {Promise<Object>} 추가된 참가자 정보
*/
async addEventParticipant(eventId, participant) {
if (!eventId || !participant) {
throw new Error('이벤트 ID와 참가자 정보가 필요합니다.');
}
try {
const response = await apiClient.post(`/api/club/events/${eventId}/participants`, participant);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 참가자 정보 수정
* @param {number} eventId - 이벤트 ID
* @param {Object} participant - 수정할 참가자 정보
* @returns {Promise<Object>} 수정된 참가자 정보
*/
async updateEventParticipant(eventId, participant) {
if (!eventId || !participant) {
throw new Error('이벤트 ID와 참가자 정보가 필요합니다.');
}
try {
const response = await apiClient.put(`/api/club/events/${eventId}/participants`, participant);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 참가자 제거
* @param {number} eventId - 이벤트 ID
* @param {Object} participant - 제거할 참가자 정보
* @returns {Promise<void>}
*/
async removeEventParticipant(eventId, participant) {
if (!eventId || !participant) {
throw new Error('이벤트 ID와 참가자 정보가 필요합니다.');
}
try {
await apiClient.delete(`/api/club/events/${eventId}/participants`, {
data: participant
});
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 참가자 상태 수정
* @param {number} eventId - 이벤트 ID
* @param {number} userId - 사용자 ID
* @param {string} status - 변경할 상태
* @returns {Promise<Object>} 수정된 참가자 정보
*/
async updateParticipantStatus(eventId, userId, status) {
if (!eventId || !userId || !status) {
throw new Error('이벤트 ID, 사용자 ID, 상태가 필요합니다.');
}
try {
const response = await apiClient.put(`/api/club/events/${eventId}/participants/${userId}/status`, { status });
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
/**
* 점수 조회
* @param {number} eventId - 이벤트 ID
* @returns {Promise<Array>} 점수 목록
*/
async getEventScores(eventId) {
if (!eventId) {
throw new Error('이벤트 ID가 필요합니다.');
}
try {
const response = await apiClient.get(`/api/club/events/${eventId}/scores`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || '점수 조회 중 오류가 발생했습니다.');
}
},
/**
* 점수 등록
* @param {number} eventId - 이벤트 ID
* @param {Object} scoreData - 점수 정보
* @returns {Promise<Object>} 등록된 점수 정보
*/
async addScore(eventId, scoreData) {
if (!eventId || !scoreData) {
throw new Error('이벤트 ID와 점수 정보가 필요합니다.');
}
try {
const response = await apiClient.post(`/api/club/events/${eventId}/scores`, {
participantId: scoreData.participantId,
teamNumber: scoreData.teamNumber,
gameNumber: scoreData.gameNumber,
score: scoreData.score,
handicap: scoreData.handicap
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || '점수 등록 중 오류가 발생했습니다.');
}
},
/**
* 점수 수정
* @param {number} eventId - 이벤트 ID
* @param {number} scoreId - 점수 ID
* @param {Object} scoreData - 수정할 점수 정보
* @returns {Promise<Object>} 수정된 점수 정보
*/
async updateScore(eventId, scoreId, scoreData) {
if (!eventId || !scoreId || !scoreData) {
throw new Error('이벤트 ID, 점수 ID, 점수 정보가 필요합니다.');
}
if (scoreData.gameNumber === undefined || scoreData.gameNumber === null ||
scoreData.score === undefined || scoreData.score === null) {
throw new Error('게임 번호와 점수는 필수입니다.');
}
try {
// URL 파라미터 문자열로 변환
const url = `/api/club/events/${String(eventId)}/scores/${String(scoreId)}`;
const payload = {
gameNumber: Number(scoreData.gameNumber),
score: scoreData.score !== null ? Number(scoreData.score) : undefined,
handicap: scoreData.handicap !== null ? Number(scoreData.handicap) : 0
};
console.log('Updating score:', {
url,
eventId,
scoreId,
payload
});
const response = await apiClient.put(url, payload);
return response.data;
} catch (error) {
console.error('Score update error:', {
error: error.response?.data,
status: error.response?.status,
statusText: error.response?.statusText
});
throw new Error(error.response?.data?.message || '점수 수정 중 오류가 발생했습니다.');
}
},
/**
* 점수 삭제
* @param {number} eventId - 이벤트 ID
* @param {number} scoreId - 점수 ID
* @returns {Promise<void>}
*/
async deleteScore(eventId, scoreId) {
if (!eventId || !scoreId) {
throw new Error('이벤트 ID와 점수 ID가 필요합니다.');
}
try {
const response = await apiClient.delete(`/api/club/events/${eventId}/scores/${scoreId}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || '점수 삭제 중 오류가 발생했습니다.');
}
},
/**
* 참가자의 모든 점수 삭제
* @param {number} eventId - 이벤트 ID
* @param {number} participantId - 참가자 ID
* @returns {Promise<Object>} 삭제 결과
*/
async deleteParticipantScores(eventId, participantId) {
if (!eventId || !participantId) {
throw new Error('이벤트 ID와 참가자 ID가 필요합니다.');
}
try {
const response = await apiClient.delete(`/api/club/events/${eventId}/participants/${participantId}/scores`);
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
}
throw error;
}
},
};
+80
View File
@@ -0,0 +1,80 @@
import apiClient from './apiClient';
/**
* 점수 관리 서비스
*/
export default {
/**
* 모임의 점수 데이터 가져오기
* @param {number} meetingId - 모임 ID
* @returns {Promise} - API 응답
*/
async getMeetingScores(meetingId) {
return apiClient.get(`/meetings/${meetingId}/scores`);
},
/**
* 점수 저장하기
* @param {number} meetingId - 모임 ID
* @param {Array} scores - 점수 데이터 배열
* @returns {Promise} - API 응답
*/
async saveScores(meetingId, scores) {
return apiClient.post(`/meetings/${meetingId}/scores`, { scores });
},
/**
* 특정 참가자의 점수 이력 가져오기
* @param {number} memberId - 회원 ID
* @param {number} limit - 가져올 이력
* @returns {Promise} - API 응답
*/
async getMemberScoreHistory(memberId, limit = 5) {
return apiClient.get(`/members/${memberId}/scores/history`, { params: { limit } });
},
/**
* 점수 내보내기
* @param {number} meetingId - 모임 ID
* @param {string} format - 내보내기 형식 (xlsx, csv, pdf)
* @param {Array} content - 내보낼 내용 배열
* @returns {Promise} - API 응답 (파일 다운로드)
*/
async exportScores(meetingId, format, content) {
return apiClient.get(`/meetings/${meetingId}/scores/export`, {
params: { format, content: content.join(',') },
responseType: 'blob'
});
},
/**
* 팀별 점수 가져오기
* @param {number} meetingId - 모임 ID
* @returns {Promise} - API 응답
*/
async getTeamScores(meetingId) {
return apiClient.get(`/meetings/${meetingId}/scores/teams`);
},
/**
* 개인 순위 가져오기
* @param {number} meetingId - 모임 ID
* @param {string} type - 순위 유형 (scratch, handicap)
* @param {string} game - 게임 코드 (all, game1, game2, ...)
* @returns {Promise} - API 응답
*/
async getIndividualRankings(meetingId, type = 'handicap', game = 'all') {
return apiClient.get(`/meetings/${meetingId}/scores/rankings/individual`, {
params: { type, game }
});
},
/**
* 순위 가져오기
* @param {number} meetingId - 모임 ID
* @returns {Promise} - API 응답
*/
async getTeamRankings(meetingId) {
return apiClient.get(`/meetings/${meetingId}/scores/rankings/teams`);
}
};
+83
View File
@@ -0,0 +1,83 @@
import { io } from 'socket.io-client';
import { API_URL } from '../config';
// Socket.IO 서버 URL (API_URL에서 '/api' 부분 제거)
const SOCKET_URL = API_URL.replace('/api', '');
// Socket.IO 클라이언트 인스턴스
let socket;
// Socket.IO 연결 설정
export const initSocket = () => {
if (socket) return socket;
socket = io(SOCKET_URL, {
autoConnect: false,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 3000
});
// 연결 이벤트 핸들러
socket.on('connect', () => {
console.log('Socket.IO 서버에 연결되었습니다.');
// 사용자 인증 정보 전송
const token = localStorage.getItem('token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
socket.emit('authenticate', { id: payload.id });
} catch (error) {
console.error('토큰 파싱 오류:', error);
}
}
});
// 연결 오류 이벤트 핸들러
socket.on('connect_error', (error) => {
console.error('Socket.IO 연결 오류:', error);
});
// 연결 해제 이벤트 핸들러
socket.on('disconnect', (reason) => {
console.log('Socket.IO 연결이 해제되었습니다:', reason);
});
return socket;
};
// Socket.IO 연결
export const connectSocket = () => {
if (!socket) initSocket();
if (!socket.connected) socket.connect();
};
// Socket.IO 연결 해제
export const disconnectSocket = () => {
if (socket && socket.connected) socket.disconnect();
};
// 이벤트 리스너 등록
export const onEvent = (event, callback) => {
if (!socket) initSocket();
socket.on(event, callback);
};
// 이벤트 리스너 제거
export const offEvent = (event, callback) => {
if (!socket) return;
if (callback) {
socket.off(event, callback);
} else {
socket.off(event);
}
};
export default {
initSocket,
connectSocket,
disconnectSocket,
onEvent,
offEvent
};
+70
View File
@@ -0,0 +1,70 @@
import apiClient from './api';
/**
* 사용자 관련 API 서비스
*/
const userService = {
/**
* 모든 사용자 조회
* @returns {Promise} - 사용자 목록
*/
getAllUsers() {
return apiClient.get('/users/all');
},
/**
* 특정 사용자 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 사용자 정보
*/
getUserById(id) {
return apiClient.get(`/users/${id}`);
},
/**
* 사용자 정보 업데이트
* @param {Number} id - 사용자 ID
* @param {Object} userData - 업데이트할 사용자 정보
* @returns {Promise} - 업데이트된 사용자 정보
*/
updateUser(id, userData) {
return apiClient.put(`/users/${id}`, userData);
},
/**
* 사용자 프로필 조회
* @returns {Promise} - 사용자 프로필 정보
*/
getUserProfile() {
return apiClient.get('/users/profile');
},
/**
* 사용자 프로필 업데이트
* @param {Object} profileData - 업데이트할 프로필 정보
* @returns {Promise} - 업데이트된 프로필 정보
*/
updateUserProfile(profileData) {
return apiClient.put('/users/profile', profileData);
},
/**
* 사용자 참가 이벤트 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 이벤트 목록
*/
getUserEvents(id) {
return apiClient.get(`/users/${id}/events`);
},
/**
* 사용자 클럽 멤버십 조회
* @param {Number} id - 사용자 ID
* @returns {Promise} - 클럽 멤버십 목록
*/
getUserClubs(id) {
return apiClient.get(`/users/${id}/clubs`);
}
};
export default userService;
+65
View File
@@ -0,0 +1,65 @@
import { defineStore } from 'pinia';
import axios from 'axios';
export const useAdminStore = defineStore('admin', {
state: () => ({
users: [],
clubs: [],
subscriptions: [],
loading: false,
error: null
}),
actions: {
async fetchUsers() {
try {
this.loading = true;
const response = await axios.get('/api/admin/users', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
});
this.users = response.data;
} catch (error) {
this.error = error.response?.data?.message || '사용자 목록을 불러오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
async fetchClubs() {
try {
this.loading = true;
const response = await axios.get('/api/admin/clubs', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
});
this.clubs = response.data;
} catch (error) {
this.error = error.response?.data?.message || '클럽 목록을 불러오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
async fetchSubscriptions() {
try {
this.loading = true;
const response = await axios.get('/api/admin/subscriptions', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
});
this.subscriptions = response.data;
} catch (error) {
this.error = error.response?.data?.message || '구독 정보를 불러오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
}
}
});
+50
View File
@@ -0,0 +1,50 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import apiClient from '../services/api';
import { jwtDecode } from 'jwt-decode';
export const useAuthStore = defineStore('auth', () => {
const user = ref(null);
const isAuthenticated = ref(false);
// 현재 사용자 정보 로드
const loadUserInfo = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
user.value = null;
isAuthenticated.value = false;
return null;
}
const decoded = jwtDecode(token);
user.value = {
id: decoded.id,
email: decoded.email,
name: decoded.name,
role: decoded.role
};
isAuthenticated.value = true;
return user.value;
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
user.value = null;
isAuthenticated.value = false;
return null;
}
};
// 로그아웃
const logout = () => {
localStorage.removeItem('token');
user.value = null;
isAuthenticated.value = false;
};
return {
user,
isAuthenticated,
loadUserInfo,
logout
};
});
+48
View File
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia';
import apiClient from '../services/api';
export const useClubStore = defineStore('club', {
state: () => ({
clubs: [],
selectedClub: null,
loading: false,
error: null
}),
actions: {
async fetchClubs() {
this.loading = true;
try {
const response = await apiClient.get('/api/club');
this.clubs = response.data;
} catch (error) {
this.error = error.response?.data?.message || '클럽 목록을 가져오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
async fetchClubById(clubId) {
this.loading = true;
try {
const response = await apiClient.post(`/api/club`, { clubId });
this.selectedClub = response.data;
return response.data;
} catch (error) {
this.error = error.response?.data?.message || '클럽 정보를 가져오는데 실패했습니다.';
throw error;
} finally {
this.loading = false;
}
},
setSelectedClub(club) {
this.selectedClub = club;
},
clearSelectedClub() {
this.selectedClub = null;
}
}
});
+141
View File
@@ -0,0 +1,141 @@
import dayjs from 'dayjs';
/**
* 이벤트 유형 이름 가져오기
* @param {string} eventType - 이벤트 유형 코드
* @returns {string} - 이벤트 유형 이름
*/
export const getEventTypeName = (eventType) => {
switch (eventType) {
case 'meeting':
return '정기 모임';
case 'tournament':
return '토너먼트';
case 'training':
return '교육/강습';
case 'social':
return '친목 모임';
case 'other':
return '기타';
default:
return eventType;
}
};
/**
* 이벤트 유형 심각도 가져오기
* @param {string} eventType - 이벤트 유형 코드
* @returns {string} - 심각도 클래스
*/
export const getEventTypeSeverity = (eventType) => {
switch (eventType) {
case 'meeting':
return 'info';
case 'tournament':
return 'danger';
case 'training':
return 'success';
case 'social':
return 'warning';
case 'other':
return 'secondary';
default:
return 'info';
}
};
/**
* 상태 이름 가져오기
* @param {string} status - 상태 코드
* @returns {string} - 상태 이름
*/
export const getStatusName = (status) => {
switch (status) {
case 'scheduled':
return '예정됨';
case 'in_progress':
return '진행중';
case 'completed':
return '완료됨';
case 'cancelled':
return '취소됨';
default:
return status;
}
};
/**
* 상태 심각도 가져오기
* @param {string} status - 상태 코드
* @returns {string} - 심각도 클래스
*/
export const getStatusSeverity = (status) => {
switch (status) {
case 'scheduled':
return 'info';
case 'in_progress':
return 'warning';
case 'completed':
return 'success';
case 'cancelled':
return 'danger';
default:
return 'info';
}
};
/**
* 날짜 포맷팅
* @param {string|Date} date - 날짜
* @returns {string} - 포맷팅된 날짜 문자열
*/
export const formatDate = (date) => {
if (!date) return '';
return dayjs(date).format('YYYY-MM-DD HH:mm');
};
/**
* 이벤트 유형 옵션
*/
export const eventTypeOptions = [
{ name: '정기 모임', value: 'meeting' },
{ name: '토너먼트', value: 'tournament' },
{ name: '교육/강습', value: 'training' },
{ name: '친목 모임', value: 'social' },
{ name: '기타', value: 'other' }
];
/**
* 상태 옵션
*/
export const statusOptions = [
{ name: '예정됨', value: 'scheduled' },
{ name: '진행중', value: 'in_progress' },
{ name: '완료됨', value: 'completed' },
{ name: '취소됨', value: 'cancelled' }
];
/**
* 이벤트 객체 생성
* @param {number} clubId - 클럽 ID
* @returns {Object} - 이벤트 객체
*/
export const createNewEvent = (clubId) => {
const now = new Date();
return {
clubId,
title: '',
description: '',
eventType: 'meeting',
startDate: now,
endDate: now,
location: '',
status: 'scheduled',
maxParticipants: 0,
teamCount: 1,
gameCount: 3,
participantFee: 0,
laneCount: 0,
registrationDeadline: now
};
};
+718
View File
@@ -0,0 +1,718 @@
<template>
<div class="club-management">
<div class="header">
<h2>클럽 관리</h2>
<Button label="새 클럽 추가" icon="pi pi-plus" @click="openNewClubDialog" />
</div>
<DataTable :value="clubs" :paginator="true" :rows="10"
:rowsPerPageOptions="[5, 10, 20]"
stripedRows responsiveLayout="scroll"
:loading="loading">
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="클럽명" sortable style="width: 20%">
<template #body="slotProps">
<div>
<div class="club-name">{{ slotProps.data.name }}</div>
<div class="club-location">{{ slotProps.data.location }}</div>
</div>
</template>
</Column>
<Column field="owner.name" header="모임장" sortable style="width: 15%">
<template #body="slotProps">
{{ slotProps.data.owner?.name || '-' }}
</template>
</Column>
<Column field="memberCount" header="회원 수" sortable style="width: 10%"></Column>
<Column field="createdAt" header="생성일" sortable style="width: 15%">
<template #body="slotProps">
{{ formatDate(slotProps.data.createdAt) }}
</template>
</Column>
<Column header="관리" style="width: 20%">
<template #body="slotProps">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-success mr-2"
@click="editClub(slotProps.data)" />
<Button icon="pi pi-cog" class="p-button-rounded p-button-info mr-2"
@click="manageFeatures(slotProps.data)" />
<Button icon="pi pi-users" class="p-button-rounded p-button-warning mr-2"
@click="manageMembers(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-danger"
@click="confirmDeleteClub(slotProps.data)" />
</template>
</Column>
</DataTable>
<!-- 클럽 추가/수정 다이얼로그 -->
<Dialog v-model:visible="clubDialog" :style="{width: '450px'}"
:header="dialogMode === 'new' ? '새 클럽 추가' : '클럽 수정'"
:modal="true" class="p-fluid">
<div class="field">
<label for="name">클럽명</label>
<InputText id="name" v-model="club.name" required autofocus :class="{'p-invalid': submitted && !club.name}" />
<small class="p-error" v-if="submitted && !club.name">클럽명은 필수입니다.</small>
</div>
<div class="field">
<label for="description">클럽 설명</label>
<Textarea id="description" v-model="club.description" rows="3" />
</div>
<div class="field">
<label for="ownerEmail">모임장 이메일</label>
<InputText id="ownerEmail" v-model="club.ownerEmail" required :class="{'p-invalid': submitted && !club.ownerEmail}" />
<small class="p-error" v-if="submitted && !club.ownerEmail">모임장 이메일은 필수입니다.</small>
</div>
<div class="field">
<label for="location">볼링장</label>
<InputText id="location" v-model="club.location" />
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveClub" :loading="saving" />
</template>
</Dialog>
<!-- 기능 관리 다이얼로그 -->
<Dialog v-model:visible="featureDialog" :style="{width: '500px'}"
header="클럽 기능 관리" :modal="true">
<div v-if="selectedClub">
<h3>{{ selectedClub.name }} - 기능 관리</h3>
<div v-for="feature in clubFeatures" :key="feature.id" class="feature-item">
<div class="feature-header">
<h4>{{ feature.name }}</h4>
<div class="feature-toggle">
<span>{{ feature.enabled ? '활성화됨' : '비활성화됨' }}</span>
<Button :icon="feature.enabled ? 'pi pi-check' : 'pi pi-times'"
:class="feature.enabled ? 'p-button-success' : 'p-button-secondary'"
@click="toggleFeature(feature)" />
</div>
</div>
<p>{{ feature.description }}</p>
<p class="feature-price"> {{ feature.price.toLocaleString() }}</p>
</div>
</div>
<template #footer>
<Button label="닫기" icon="pi pi-times" class="p-button-text" @click="featureDialog = false" />
<Button label="저장" icon="pi pi-check" @click="saveFeatures" :loading="savingFeatures" />
</template>
</Dialog>
<!-- 회원 관리 다이얼로그 -->
<Dialog v-model:visible="memberDialog" :style="{width: '800px'}"
header="회원 관리" :modal="true">
<div v-if="selectedClub">
<h3>{{ selectedClub.name }} - 회원 관리</h3>
<div class="mb-3">
<Button label="새 회원 추가" icon="pi pi-plus" @click="openNewMemberDialog" />
</div>
<DataTable :value="clubMembers" :paginator="true" :rows="10"
:rowsPerPageOptions="[5, 10, 20]"
stripedRows
scrollable scrollHeight="400px"
:loading="loadingMembers">
<Column field="name" header="이름" sortable style="min-width: 12rem">
<template #body="slotProps">
<div>
<div class="member-name">{{ slotProps.data.name }}</div>
<div class="member-contact">
<div>{{ slotProps.data.phone }}</div>
<div>{{ slotProps.data.email }}</div>
</div>
</div>
</template>
</Column>
<Column field="gender" header="성별" sortable style="min-width: 6rem"></Column>
<Column field="memberType" header="회원 유형" sortable style="min-width: 8rem">
<template #body="slotProps">
<Dropdown v-model="slotProps.data.memberType"
:options="memberTypes"
@change="updateMemberType(slotProps.data)"
:disabled="slotProps.data.memberType === '모임장' && !canChangeOwner" />
</template>
</Column>
<Column field="handicap" header="핸디캡" sortable style="min-width: 6rem"></Column>
<Column field="average" header="평균" sortable style="min-width: 6rem">
<template #body="slotProps">
{{ slotProps.data.average.toFixed(1) }}
</template>
</Column>
<Column field="games" header="게임 수" sortable style="min-width: 6rem"></Column>
<Column field="joinDate" header="가입일" sortable style="min-width: 8rem">
<template #body="slotProps">
{{ formatDate(slotProps.data.joinDate) }}
</template>
</Column>
<Column header="관리" style="min-width: 8rem">
<template #body="slotProps">
<Button icon="pi pi-pencil"
class="p-button-rounded p-button-success mr-2"
@click="editMember(slotProps.data)" />
<Button icon="pi pi-trash"
class="p-button-rounded p-button-danger"
@click="confirmDeleteMember(slotProps.data)"
:disabled="slotProps.data.memberType === '모임장'" />
</template>
</Column>
</DataTable>
</div>
</Dialog>
<!-- 회원 추가/수정 다이얼로그 -->
<Dialog v-model:visible="memberFormDialog" :style="{width: '450px'}"
:header="memberDialogMode === 'new' ? '새 회원 추가' : '회원 정보 수정'"
:modal="true" class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="member.name" required autofocus :class="{'p-invalid': memberSubmitted && !member.name}" />
<small class="p-error" v-if="memberSubmitted && !member.name">이름은 필수입니다.</small>
</div>
<div class="field">
<label for="gender">성별</label>
<Dropdown id="gender" v-model="member.gender"
:options="['남성', '여성']" placeholder="성별 선택"
:class="{'p-invalid': memberSubmitted && !member.gender}" />
<small class="p-error" v-if="memberSubmitted && !member.gender">성별은 필수입니다.</small>
</div>
<div class="field">
<label for="memberType">회원 유형</label>
<Dropdown id="memberType" v-model="member.memberType"
:options="memberTypes" placeholder="회원 유형 선택"
:class="{'p-invalid': memberSubmitted && !member.memberType}" />
<small class="p-error" v-if="memberSubmitted && !member.memberType">회원 유형은 필수입니다.</small>
</div>
<div class="field">
<label for="handicap">핸디캡</label>
<InputNumber id="handicap" v-model="member.handicap" :min="0" :max="300" />
</div>
<div class="field">
<label for="phone">연락처</label>
<InputText id="phone" v-model="member.phone" />
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="member.email" type="email" />
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="memberFormDialog = false" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveMember" :loading="savingMember" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteDialog" :style="{width: '450px'}"
header="클럽 삭제" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="selectedClub">정말로 <b>{{ selectedClub.name }}</b> 클럽을 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteClub" :loading="deleting" />
</template>
</Dialog>
<!-- 회원 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteMemberDialog" :style="{width: '450px'}"
header="회원 삭제" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="selectedMember">정말로 <b>{{ selectedMember.name }}</b> 회원을 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteMemberDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteMember" :loading="deletingMember" />
</template>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import dayjs from 'dayjs';
import adminService from '@/services/adminService';
const toast = useToast();
//
const loading = ref(false);
const saving = ref(false);
const savingFeatures = ref(false);
const deleting = ref(false);
const loadingMembers = ref(false);
const deletingMember = ref(false);
const savingMember = ref(false);
const submitted = ref(false);
const memberSubmitted = ref(false);
//
const clubs = ref([]);
const clubMembers = ref([]);
//
const memberTypes = ref(['모임장', '정회원', '준회원', '운영진']);
//
const allFeatures = ref([
{ id: 1, name: '회원 관리', description: '클럽 회원 목록 관리 및 회원 정보 수정', price: 5000 },
{ id: 2, name: '정기 모임 관리', description: '정기 모임 일정 및 참석자 관리', price: 8000 },
{ id: 3, name: '점수 관리', description: '게임별 점수 기록 및 통계', price: 10000 },
{ id: 4, name: '팀 관리', description: '팀 구성 및 팀별 점수 관리', price: 7000 },
{ id: 5, name: '통계 대시보드', description: '상세한 통계 및 분석 대시보드', price: 12000 }
]);
//
const clubFeatures = ref([]);
//
const clubDialog = ref(false);
const featureDialog = ref(false);
const deleteDialog = ref(false);
const memberDialog = ref(false);
const memberFormDialog = ref(false);
const deleteMemberDialog = ref(false);
const dialogMode = ref('new');
const memberDialogMode = ref('new');
//
const selectedClub = ref(null);
const selectedMember = ref(null);
const club = reactive({
id: null,
name: '',
description: '',
ownerEmail: '',
location: '',
memberCount: 0,
createdAt: ''
});
//
const member = reactive({
id: null,
name: '',
gender: null,
memberType: null,
handicap: 0,
phone: '',
email: ''
});
//
const canChangeOwner = ref(true);
//
onMounted(async () => {
await fetchClubs();
});
//
const fetchClubs = async () => {
loading.value = true;
try {
const response = await adminService.getAllClubs();
clubs.value = response.data;
} catch (error) {
console.error('클럽 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
//
const formatDate = (date) => {
if (!date) return '';
return dayjs(date).format('YYYY-MM-DD');
};
//
const openNewClubDialog = () => {
club.id = null;
club.name = '';
club.description = '';
club.ownerEmail = '';
club.location = '';
submitted.value = false;
dialogMode.value = 'new';
clubDialog.value = true;
};
//
const editClub = async (data) => {
dialogMode.value = 'edit';
selectedClub.value = { ...data };
try {
//
const response = await adminService.getClubById(data.id);
const clubData = response.data;
club.id = clubData.id;
club.name = clubData.name;
club.description = clubData.description;
club.ownerEmail = clubData.owner?.email; // owner
club.location = clubData.location;
club.memberCount = clubData.memberCount;
club.createdAt = clubData.createdAt;
clubDialog.value = true;
} catch (error) {
console.error('클럽 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const hideDialog = () => {
clubDialog.value = false;
submitted.value = false;
};
//
const saveClub = async () => {
submitted.value = true;
if (!club.name || !club.ownerEmail) {
toast.add({ severity: 'error', summary: '오류', detail: '필수 항목을 모두 입력해주세요.', life: 3000 });
return;
}
saving.value = true;
try {
if (dialogMode.value === 'new') {
//
const clubData = {
name: club.name,
description: club.description,
ownerEmail: club.ownerEmail,
location: club.location
};
await adminService.createClub(clubData);
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 추가되었습니다.', life: 3000 });
} else {
//
await adminService.updateClub(club.id, club);
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 수정되었습니다.', life: 3000 });
}
clubDialog.value = false;
await fetchClubs();
} catch (error) {
console.error('클럽 저장 중 오류 발생:', error);
let errorMessage = '클럽 저장 중 오류가 발생했습니다.';
if (error.response && error.response.data && error.response.data.message) {
errorMessage = error.response.data.message;
}
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
saving.value = false;
}
};
//
const manageFeatures = async (data) => {
selectedClub.value = { ...data };
try {
//
const response = await adminService.getClubById(data.id);
const clubData = response.data;
//
clubFeatures.value = allFeatures.value.map(feature => {
const isEnabled = clubData.features && clubData.features.some(f => f.id === feature.id);
return { ...feature, enabled: isEnabled };
});
featureDialog.value = true;
} catch (error) {
console.error('클럽 기능 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 기능 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const toggleFeature = (feature) => {
feature.enabled = !feature.enabled;
};
//
const saveFeatures = async () => {
if (!selectedClub.value) return;
savingFeatures.value = true;
try {
//
const enabledFeatures = clubFeatures.value
.filter(feature => feature.enabled)
.map(feature => ({ id: feature.id, name: feature.name }));
// API
await adminService.manageClubFeatures(selectedClub.value.id, enabledFeatures);
toast.add({ severity: 'success', summary: '성공', detail: '클럽 기능이 업데이트되었습니다.', life: 3000 });
featureDialog.value = false;
//
await fetchClubs();
} catch (error) {
console.error('클럽 기능 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 기능 저장 중 오류가 발생했습니다.', life: 3000 });
} finally {
savingFeatures.value = false;
}
};
//
const manageMembers = async (data) => {
selectedClub.value = data;
loadingMembers.value = true;
memberDialog.value = true;
try {
const response = await adminService.getClubMembers(data.id);
clubMembers.value = response.data;
} catch (error) {
console.error('회원 목록 조회 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loadingMembers.value = false;
}
};
//
const updateMemberType = async (member) => {
try {
if (!selectedClub.value || !selectedClub.value.id) {
toast.add({ severity: 'error', summary: '오류', detail: '선택된 클럽 정보가 없습니다.', life: 3000 });
return;
}
const clubId = parseInt(selectedClub.value.id);
const memberId = parseInt(member.id);
const response = await adminService.updateClubMember(clubId, memberId, {
memberType: member.memberType
});
//
const index = clubMembers.value.findIndex(m => m.id === memberId);
if (index !== -1) {
clubMembers.value[index] = response.data;
}
toast.add({ severity: 'success', summary: '성공', detail: '회원 유형이 업데이트되었습니다.', life: 3000 });
} catch (error) {
console.error('회원 유형 업데이트 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 유형을 업데이트하는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const confirmDeleteMember = (member) => {
selectedMember.value = member;
deleteMemberDialog.value = true;
};
//
const deleteMember = async () => {
if (!selectedMember.value || !selectedClub.value) return;
deletingMember.value = true;
try {
await adminService.deleteClubMember(selectedClub.value.id, selectedMember.value.id);
//
clubMembers.value = clubMembers.value.filter(m => m.id !== selectedMember.value.id);
toast.add({ severity: 'success', summary: '성공', detail: '회원이 삭제되었습니다.', life: 3000 });
deleteMemberDialog.value = false;
} catch (error) {
console.error('회원 삭제 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원을 삭제하는 중 오류가 발생했습니다.', life: 3000 });
} finally {
deletingMember.value = false;
}
};
//
const confirmDeleteClub = (data) => {
selectedClub.value = data;
deleteDialog.value = true;
};
//
const deleteClub = async () => {
if (!selectedClub.value) return;
deleting.value = true;
try {
// API
await adminService.deleteClub(selectedClub.value.id);
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 삭제되었습니다.', life: 3000 });
deleteDialog.value = false;
//
await fetchClubs();
} catch (error) {
console.error('클럽 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 삭제 중 오류가 발생했습니다.', life: 3000 });
} finally {
deleting.value = false;
}
};
//
const openNewMemberDialog = () => {
memberDialogMode.value = 'new';
memberSubmitted.value = false;
Object.assign(member, {
id: null,
name: '',
gender: null,
memberType: '준회원',
handicap: 0,
phone: '',
email: ''
});
memberFormDialog.value = true;
};
//
const editMember = (data) => {
memberDialogMode.value = 'edit';
memberSubmitted.value = false;
Object.assign(member, {
id: data.id,
name: data.name,
gender: data.gender,
memberType: data.memberType,
handicap: data.handicap,
phone: data.phone,
email: data.email
});
memberFormDialog.value = true;
};
//
const saveMember = async () => {
memberSubmitted.value = true;
if (!member.name || !member.gender || !member.memberType) {
return;
}
savingMember.value = true;
try {
let response;
if (memberDialogMode.value === 'new') {
response = await adminService.addClubMember(selectedClub.value.id, member);
clubMembers.value.push(response.data);
toast.add({ severity: 'success', summary: '성공', detail: '새 회원이 추가되었습니다.', life: 3000 });
} else {
response = await adminService.updateClubMember(selectedClub.value.id, member.id, member);
const index = clubMembers.value.findIndex(m => m.id === member.id);
if (index !== -1) {
clubMembers.value[index] = response.data;
}
toast.add({ severity: 'success', summary: '성공', detail: '회원 정보가 수정되었습니다.', life: 3000 });
}
memberFormDialog.value = false;
} catch (error) {
console.error('회원 저장 오류:', error);
toast.add({ severity: 'error', summary: '오류', detail: '회원 정보를 저장하는 중 오류가 발생했습니다.', life: 3000 });
} finally {
savingMember.value = false;
}
};
</script>
<style scoped>
.club-management {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.feature-item {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.feature-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.feature-header h4 {
margin: 0;
}
.feature-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
}
.feature-price {
font-weight: bold;
color: #6366f1;
margin-top: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0;
}
.mr-2 {
margin-right: 0.5rem;
}
.club-name {
font-weight: 500;
margin-bottom: 0.2rem;
}
.club-location {
font-size: 0.85rem;
color: #666;
}
.member-name {
font-weight: 500;
margin-bottom: 0.2rem;
}
.member-contact {
font-size: 0.75rem;
color: #666;
line-height: 1.2;
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="admin-dashboard">
<h2>관리자 대시보드</h2>
<div v-if="loading" class="loading-spinner">
<ProgressSpinner />
</div>
<div v-else class="grid">
<!-- 요약 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-users"></i>
<h3>사용자</h3>
</div>
<div class="card-content">
<p> 사용자 : {{ summary.totalUsers }}</p>
</div>
<div class="card-footer">
<Button label="사용자 관리" icon="pi pi-arrow-right" @click="navigateTo('/admin/users')" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-building"></i>
<h3>클럽</h3>
</div>
<div class="card-content">
<p> 클럽 : {{ summary.totalClubs }}</p>
</div>
<div class="card-footer">
<Button label="클럽 관리" icon="pi pi-arrow-right" @click="navigateTo('/admin/clubs')" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-credit-card"></i>
<h3>구독</h3>
</div>
<div class="card-content">
<p>활성 구독: {{ summary.activeSubscriptions }}</p>
</div>
<div class="card-footer">
<Button label="구독 관리" icon="pi pi-arrow-right" @click="navigateTo('/admin/subscriptions')" />
</div>
</div>
</div>
<!-- 최근 데이터 -->
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-users"></i>
<h3>최근 가입한 사용자</h3>
</div>
<div class="card-content">
<DataTable :value="recent.users" :rows="5" stripedRows>
<Column field="name" header="이름"></Column>
<Column field="email" header="이메일"></Column>
<Column field="role" header="권한"></Column>
<Column field="createdAt" header="가입일">
<template #body="{ data }">
{{ formatDate(data.createdAt) }}
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-building"></i>
<h3>최근 생성된 클럽</h3>
</div>
<div class="card-content">
<DataTable :value="recent.clubs" :rows="5" stripedRows>
<Column field="name" header="클럽명"></Column>
<Column field="memberCount" header="회원 수"></Column>
<Column field="owner.name" header="모임장"></Column>
<Column field="createdAt" header="생성일">
<template #body="{ data }">
{{ formatDate(data.createdAt) }}
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- 월별 통계 차트 -->
<div class="col-12">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-chart-line"></i>
<h3>월별 통계</h3>
</div>
<div class="card-content">
<Chart type="line" :data="chartData" :options="chartOptions" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import dayjs from 'dayjs';
import adminService from '@/services/adminService';
// main.js import
const router = useRouter();
const toast = useToast();
//
const loading = ref(true);
const summary = ref({
totalUsers: 0,
totalClubs: 0,
activeSubscriptions: 0
});
const recent = ref({
users: [],
clubs: [],
subscriptions: []
});
const stats = ref({
users: [],
clubs: [],
subscriptions: []
});
//
const chartData = computed(() => {
const months = stats.value.users.map(item => item.month);
return {
labels: months,
datasets: [
{
label: '사용자',
data: stats.value.users.map(item => item.count),
borderColor: '#42A5F5',
tension: 0.4
},
{
label: '클럽',
data: stats.value.clubs.map(item => item.count),
borderColor: '#66BB6A',
tension: 0.4
},
{
label: '구독',
data: stats.value.subscriptions.map(item => item.count),
borderColor: '#FFA726',
tension: 0.4
}
]
};
});
//
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
};
//
onMounted(async () => {
try {
loading.value = true;
await loadDashboardData();
} catch (error) {
console.error('관리자 대시보드 데이터 로딩 오류:', error);
} finally {
loading.value = false;
}
});
//
const loadDashboardData = async () => {
try {
const response = await adminService.getDashboardData();
const data = response.data;
summary.value = data.summary;
recent.value = data.recent;
stats.value = data.stats;
} catch (error) {
console.error('관리자 대시보드 데이터 로딩 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '관리자 대시보드 데이터를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD');
};
const navigateTo = (path) => {
router.push(path);
};
</script>
<style scoped>
.admin-dashboard {
padding: 1rem;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.dashboard-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.card-header i {
font-size: 1.5rem;
margin-right: 0.5rem;
color: #3B82F6;
}
.card-header h3 {
margin: 0;
font-size: 1.25rem;
}
.card-content {
flex: 1;
margin-bottom: 1rem;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
:deep(.p-datatable) {
box-shadow: none;
}
:deep(.p-datatable .p-datatable-thead > tr > th) {
background-color: #f8f9fa;
}
:deep(.p-datatable .p-datatable-tbody > tr) {
background-color: transparent;
}
</style>
@@ -0,0 +1,655 @@
<template>
<div class="subscription-management-container">
<div class="header">
<h2>구독 플랜 관리</h2>
<Button label="플랜 추가" icon="pi pi-plus" @click="openNewPlanDialog" />
</div>
<DataTable
:value="plans"
:loading="loading"
:paginator="true"
:rows="10"
stripedRows
responsiveLayout="scroll"
v-model:expandedRows="expandedRows"
dataKey="id"
@row-expand="onPlanExpand"
>
<Column :expander="true" style="width: 3%"/>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="플랜명" sortable style="width: 15%"></Column>
<Column field="description" header="설명" style="width: 25%"></Column>
<Column field="maxMembers" header="최대 회원수" sortable style="width: 10%">
<template #body="slotProps">
{{ slotProps.data.maxMembers }}
</template>
</Column>
<Column field="price" header="가격" sortable style="width: 10%">
<template #body="slotProps">
{{ formatCurrency(slotProps.data.price) }}
</template>
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="작업" style="width: 10%">
<template #body="slotProps">
<div class="action-buttons">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editPlan(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDeletePlan(slotProps.data)" />
</div>
</template>
</Column>
<template #expansion="slotProps">
<div class="plan-details">
<div class="plan-features">
<h4>포함된 기능</h4>
<div class="features-grid">
<div v-for="feature in slotProps.data.Features" :key="feature.id" class="feature-item">
<i class="pi pi-check-circle" style="color: var(--green-500)"></i>
<div class="feature-details">
<h5>{{ feature.name }}</h5>
<p>{{ feature.description }}</p>
</div>
</div>
</div>
</div>
<div class="plan-subscriptions">
<div class="subscriptions-header">
<h4>구독 중인 클럽</h4>
<Button label="클럽 구독 추가" icon="pi pi-plus" @click="openNewSubscriptionDialog(slotProps.data)" />
</div>
<DataTable :value="planSubscriptions[slotProps.data.id] || []" :loading="subscriptionsLoading[slotProps.data.id]">
<Column field="Club.name" header="클럽명"></Column>
<Column field="startDate" header="시작일">
<template #body="subProps">
{{ formatDate(subProps.data.startDate) }}
</template>
</Column>
<Column field="expiryDate" header="만료일">
<template #body="subProps">
{{ formatDate(subProps.data.expiryDate) }}
</template>
</Column>
<Column field="status" header="상태">
<template #body="subProps">
<Badge :value="getStatusName(subProps.data.status)" :severity="getStatusSeverity(subProps.data.status)" />
</template>
</Column>
<Column header="작업">
<template #body="subProps">
<div class="action-buttons">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editSubscription(subProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDeleteSubscription(subProps.data)" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</template>
</DataTable>
<!-- 구독 플랜 추가/수정 다이얼로그 -->
<Dialog v-model:visible="planDialog" :header="dialogTitle" :style="{ width: '600px' }" :modal="true" class="p-fluid">
<div class="field">
<label for="name">플랜명</label>
<InputText id="name" v-model="plan.name" :class="{'p-invalid': submitted && !plan.name}" />
<small class="p-error" v-if="submitted && !plan.name">플랜명은 필수입니다.</small>
</div>
<div class="field">
<label for="description">설명</label>
<Textarea id="description" v-model="plan.description" rows="3" />
</div>
<div class="field">
<label for="maxMembers">최대 회원수</label>
<InputNumber id="maxMembers" v-model="plan.maxMembers" :min="1" />
</div>
<div class="field">
<label for="price">가격</label>
<InputNumber id="price" v-model="plan.price" mode="currency" currency="KRW" locale="ko-KR" />
</div>
<div class="field">
<label for="features">기능</label>
<MultiSelect id="features" v-model="plan.features" :options="availableFeatures"
optionLabel="name" optionValue="id" display="chip" :class="{'p-invalid': submitted && !plan.features?.length}" />
<small class="p-error" v-if="submitted && !plan.features?.length">하나 이상의 기능을 선택해주세요.</small>
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="plan.status" :options="statusOptions" optionLabel="name" optionValue="value" />
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hidePlanDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="savePlan" />
</template>
</Dialog>
<!-- 클럽 구독 추가/수정 다이얼로그 -->
<Dialog v-model:visible="subscriptionDialog" :header="subscriptionDialogTitle" :style="{ width: '500px' }" :modal="true" class="p-fluid">
<div class="field">
<label for="club">클럽</label>
<Dropdown id="club" v-model="subscription.clubId" :options="availableClubs" optionLabel="name" optionValue="id"
:class="{'p-invalid': subscriptionSubmitted && !subscription.clubId}" placeholder="클럽 선택" />
<small class="p-error" v-if="subscriptionSubmitted && !subscription.clubId">클럽을 선택해주세요.</small>
</div>
<div class="field">
<label for="startDate">시작일</label>
<Calendar id="startDate" v-model="subscription.startDate" :showIcon="true" dateFormat="yy-mm-dd"
:class="{'p-invalid': subscriptionSubmitted && !subscription.startDate}" />
<small class="p-error" v-if="subscriptionSubmitted && !subscription.startDate">시작일을 선택해주세요.</small>
</div>
<div class="field">
<label for="expiryDate">만료일</label>
<Calendar id="expiryDate" v-model="subscription.expiryDate" :showIcon="true" dateFormat="yy-mm-dd"
:class="{'p-invalid': subscriptionSubmitted && !subscription.expiryDate}" :minDate="subscription.startDate" />
<small class="p-error" v-if="subscriptionSubmitted && !subscription.expiryDate">만료일을 선택해주세요.</small>
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="subscription.status" :options="subscriptionStatusOptions" optionLabel="name" optionValue="value" />
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideSubscriptionDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveSubscription" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deletePlanDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="plan"> 구독 플랜을 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deletePlanDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deletePlan" />
</template>
</Dialog>
<!-- 구독 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteSubscriptionDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="subscription"> 구독을 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteSubscriptionDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteSubscription" />
</template>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import axios from 'axios';
import adminService from '@/services/adminService';
// PrimeVue
import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber';
import Textarea from 'primevue/textarea';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dropdown from 'primevue/dropdown';
import Badge from 'primevue/badge';
import Toast from 'primevue/toast';
import Calendar from 'primevue/calendar';
const toast = useToast();
const API_URL = 'http://localhost:3030/api';
//
const plans = ref([]);
const clubs = ref([]);
const features = ref([]);
const subscriptions = ref([]);
const planSubscriptions = ref({});
const loading = ref(false);
const subscriptionsLoading = ref({});
//
const plan = ref({});
const planDialog = ref(false);
const deletePlanDialog = ref(false);
const submitted = ref(false);
const isNewPlan = ref(false);
const expandedRows = ref([]);
const availableFeatures = ref([]);
const subscription = ref({});
const subscriptionDialog = ref(false);
const deleteSubscriptionDialog = ref(false);
const subscriptionSubmitted = ref(false);
const availableClubs = ref([]);
const selectedPlan = ref(null);
//
const statusOptions = [
{ name: '활성', value: 'active' },
{ name: '비활성', value: 'inactive' }
];
const subscriptionStatusOptions = [
{ name: '활성', value: 'active' },
{ name: '만료', value: 'expired' },
{ name: '일시중지', value: 'paused' }
];
//
const dialogTitle = computed(() => {
return isNewPlan.value ? '구독 플랜 추가' : '구독 플랜 수정';
});
const subscriptionDialogTitle = computed(() => {
return !subscription.value.id ? '클럽 구독 추가' : '클럽 구독 수정';
});
//
const fetchSubscriptions = async () => {
loading.value = true;
try {
const response = await adminService.getSubscriptions();
subscriptions.value = response.data;
} catch (error) {
console.error('구독 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
//
const fetchPlanSubscriptions = async (planId) => {
subscriptionsLoading.value[planId] = true;
try {
const response = await adminService.getPlanSubscriptions(planId);
planSubscriptions.value[planId] = response.data;
} catch (error) {
console.error('구독 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
subscriptionsLoading.value[planId] = false;
}
};
//
onMounted(async () => {
await fetchPlans();
await fetchFeatures();
await fetchClubs();
await fetchSubscriptions();
});
//
const onPlanExpand = (event) => {
const planId = event.data.id;
fetchPlanSubscriptions(planId);
};
//
const fetchPlans = async () => {
loading.value = true;
try {
const response = await adminService.getPlans();
plans.value = response.data;
} catch (error) {
console.error('구독 플랜 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 플랜 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
//
const fetchFeatures = async () => {
try {
const response = await adminService.getFeatures();
availableFeatures.value = response.data;
} catch (error) {
console.error('기능 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '기능 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const fetchClubs = async () => {
try {
const response = await adminService.getClubs();
availableClubs.value = response.data;
} catch (error) {
console.error('클럽 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '클럽 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const openNewPlanDialog = () => {
plan.value = {
name: '',
description: '',
maxMembers: 30,
price: 30000,
features: [],
status: 'active'
};
submitted.value = false;
planDialog.value = true;
isNewPlan.value = true;
};
//
const editPlan = (planData) => {
plan.value = { ...planData };
plan.value.features = planData.Features.map(f => f.id);
submitted.value = false;
planDialog.value = true;
isNewPlan.value = false;
};
//
const hidePlanDialog = () => {
planDialog.value = false;
submitted.value = false;
};
//
const savePlan = async () => {
submitted.value = true;
if (!plan.value.name || !plan.value.features?.length) {
return;
}
try {
if (isNewPlan.value) {
await adminService.createPlan(plan.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독 플랜이 생성되었습니다.', life: 3000 });
} else {
await adminService.updatePlan(plan.value.id, plan.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독 플랜이 수정되었습니다.', life: 3000 });
}
planDialog.value = false;
await fetchPlans();
} catch (error) {
console.error('구독 플랜 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 플랜 저장 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const confirmDeletePlan = (planData) => {
plan.value = planData;
deletePlanDialog.value = true;
};
//
const deletePlan = async () => {
try {
await adminService.deletePlan(plan.value.id);
deletePlanDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '구독 플랜이 삭제되었습니다.', life: 3000 });
await fetchPlans();
} catch (error) {
console.error('구독 플랜 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 플랜 삭제 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const openNewSubscriptionDialog = (plan) => {
selectedPlan.value = plan;
subscription.value = {
planId: plan.id,
clubId: null,
startDate: new Date(),
expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
status: 'active'
};
subscriptionSubmitted.value = false;
subscriptionDialog.value = true;
};
//
const editSubscription = (subscriptionData) => {
subscription.value = {
id: subscriptionData.id,
planId: subscriptionData.planId,
clubId: subscriptionData.Club.id,
startDate: new Date(subscriptionData.startDate),
expiryDate: new Date(subscriptionData.expiryDate),
status: subscriptionData.status
};
subscriptionSubmitted.value = false;
subscriptionDialog.value = true;
};
//
const hideSubscriptionDialog = () => {
subscriptionDialog.value = false;
subscriptionSubmitted.value = false;
};
//
const saveSubscription = async () => {
subscriptionSubmitted.value = true;
if (!subscription.value.clubId || !subscription.value.startDate || !subscription.value.expiryDate) {
return;
}
try {
if (!subscription.value.id) {
await adminService.createSubscription(subscription.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독이 추가되었습니다.', life: 3000 });
} else {
await adminService.updateSubscription(subscription.value.id, subscription.value);
toast.add({ severity: 'success', summary: '성공', detail: '구독이 수정되었습니다.', life: 3000 });
}
subscriptionDialog.value = false;
await fetchPlanSubscriptions(subscription.value.planId);
} catch (error) {
console.error('구독 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독을 저장하는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const confirmDeleteSubscription = (subscriptionData) => {
subscription.value = subscriptionData;
deleteSubscriptionDialog.value = true;
};
//
const deleteSubscription = async () => {
try {
await adminService.deleteSubscription(subscription.value.id);
deleteSubscriptionDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '구독이 삭제되었습니다.', life: 3000 });
await fetchPlanSubscriptions(subscription.value.planId);
} catch (error) {
console.error('구독 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '구독 삭제 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const getStatusName = (status) => {
switch (status) {
case 'active':
return '활성';
case 'inactive':
return '비활성';
default:
return status;
}
};
//
const getStatusSeverity = (status) => {
switch (status) {
case 'active':
return 'success';
case 'inactive':
return 'danger';
default:
return 'info';
}
};
//
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(amount);
};
//
const formatDate = (date) => {
if (!date) return '-';
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
</script>
<style scoped>
.subscription-management-container {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h2 {
margin: 0;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.plan-features {
padding: 1rem;
background-color: var(--surface-ground);
border-radius: 4px;
}
.plan-features h4 {
margin: 0 0 1rem 0;
color: var(--text-color);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
background-color: var(--surface-card);
border-radius: 4px;
}
.feature-details h5 {
margin: 0 0 0.25rem 0;
color: var(--text-color);
}
.feature-details p {
margin: 0;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.plan-details {
padding: 1rem;
background-color: var(--surface-ground);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.plan-subscriptions {
background-color: var(--surface-card);
padding: 1rem;
border-radius: 4px;
}
.subscriptions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.subscriptions-header h4 {
margin: 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.confirmation-content {
display: flex;
align-items: center;
gap: 1rem;
}
.mr-3 {
margin-right: 0.75rem;
}
:deep(.p-inputnumber),
:deep(.p-calendar),
:deep(.p-dropdown),
:deep(.p-multiselect) {
width: 100%;
}
:deep(.p-badge) {
font-size: 0.8rem;
padding: 0.3rem 0.5rem;
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<template>
<div class="user-management-container">
<div class="header">
<h2>사용자 관리</h2>
<Button label="사용자 추가" icon="pi pi-plus" @click="openNewUserDialog" />
</div>
<DataTable
:value="users"
:loading="loading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="{first}에서 {last}까지 {totalRecords} 중"
responsiveLayout="scroll"
stripedRows
filterDisplay="menu"
v-model:filters="filters"
scrollable
scrollHeight="calc(100vh - 200px)"
>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="name" header="이름" sortable style="width: 15%">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="이름으로 검색" />
</template>
</Column>
<Column field="email" header="이메일" sortable style="width: 20%">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="이메일로 검색" />
</template>
</Column>
<Column field="role" header="역할" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getRoleName(slotProps.data.role)" :severity="getRoleSeverity(slotProps.data.role)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" class="p-column-filter" />
</template>
</Column>
<Column field="status" header="상태" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getStatusName(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" class="p-column-filter" />
</template>
</Column>
<Column field="createdAt" header="가입일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.createdAt) }}
</template>
</Column>
<Column field="lastLoginAt" header="최근 로그인" sortable style="width: 12%">
<template #body="slotProps">
{{ slotProps.data.lastLoginAt ? formatDate(slotProps.data.lastLoginAt) : '-' }}
</template>
</Column>
<Column header="작업" style="width: 8%">
<template #body="slotProps">
<div class="action-buttons">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editUser(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDeleteUser(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 사용자 추가/수정 다이얼로그 -->
<Dialog v-model:visible="userDialog" :header="dialogTitle" :style="{ width: '450px' }" :modal="true" class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="user.name" :class="{'p-invalid': submitted && !user.name}" required autofocus />
<small class="p-error" v-if="submitted && !user.name">이름은 필수입니다.</small>
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="user.email" :class="{'p-invalid': submitted && !user.email}" required />
<small class="p-error" v-if="submitted && !user.email">이메일은 필수입니다.</small>
</div>
<div class="field">
<label for="password">비밀번호</label>
<Password id="password" v-model="user.password" :class="{'p-invalid': submitted && isNewUser && !user.password}" toggleMask :feedback="isNewUser" />
<small class="p-error" v-if="submitted && isNewUser && !user.password">비밀번호는 필수입니다.</small>
<small v-if="!isNewUser">비밀번호를 변경하지 않으려면 비워두세요.</small>
</div>
<div class="field">
<label for="role">역할</label>
<Dropdown id="role" v-model="user.role" :options="roleOptions" optionLabel="name" optionValue="value" placeholder="역할 선택" required />
<small class="p-error" v-if="submitted && !user.role">역할은 필수입니다.</small>
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="user.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" required />
<small class="p-error" v-if="submitted && !user.status">상태는 필수입니다.</small>
</div>
<template #footer>
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveUser" />
</template>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteUserDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="user">{{ user.name }} 사용자를 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteUserDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteUser" />
</template>
</Dialog>
<Toast />
</div>
</template>
<script setup>
import { ref, onMounted, reactive, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { FilterMatchMode } from 'primevue/api';
import Badge from 'primevue/badge';
import dayjs from 'dayjs';
import adminService from '@/services/adminService';
const toast = useToast();
//
const users = ref([]);
const user = ref({});
const userDialog = ref(false);
const deleteUserDialog = ref(false);
const submitted = ref(false);
const loading = ref(false);
const isNewUser = ref(false);
//
const filters = ref({
name: { value: null, matchMode: FilterMatchMode.CONTAINS },
email: { value: null, matchMode: FilterMatchMode.CONTAINS },
role: { value: null, matchMode: FilterMatchMode.EQUALS },
status: { value: null, matchMode: FilterMatchMode.EQUALS }
});
//
const roleOptions = [
{ name: '관리자', value: 'admin' },
{ name: '클럽 관리자', value: 'clubadmin' },
{ name: '일반 사용자', value: 'user' }
];
//
const statusOptions = [
{ name: '활성', value: 'active' },
{ name: '비활성', value: 'inactive' },
{ name: '정지', value: 'suspended' }
];
//
const dialogTitle = computed(() => {
return isNewUser.value ? '사용자 추가' : '사용자 수정';
});
//
onMounted(async () => {
await fetchUsers();
});
//
const fetchUsers = async () => {
loading.value = true;
try {
const response = await adminService.getAllUsers();
users.value = response.data;
} catch (error) {
console.error('사용자 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
//
const openNewUserDialog = () => {
user.value = {
name: '',
email: '',
password: '',
role: 'user',
status: 'active'
};
submitted.value = false;
isNewUser.value = true;
userDialog.value = true;
};
//
const editUser = async (userData) => {
try {
const response = await adminService.getUserById(userData.id);
user.value = { ...response.data, password: '' };
isNewUser.value = false;
submitted.value = false;
userDialog.value = true;
} catch (error) {
console.error('사용자 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const hideDialog = () => {
userDialog.value = false;
submitted.value = false;
};
// ( )
const saveUser = async () => {
submitted.value = true;
if (!user.value.name || !user.value.email || !user.value.role || !user.value.status || (isNewUser.value && !user.value.password)) {
toast.add({ severity: 'warn', summary: '입력 오류', detail: '모든 필수 항목을 입력해주세요.', life: 3000 });
return;
}
try {
if (isNewUser.value) {
await adminService.createUser(user.value);
toast.add({ severity: 'success', summary: '성공', detail: '사용자가 생성되었습니다.', life: 3000 });
} else {
const userData = { ...user.value };
if (!userData.password) {
delete userData.password;
}
await adminService.updateUser(userData.id, userData);
toast.add({ severity: 'success', summary: '성공', detail: '사용자 정보가 수정되었습니다.', life: 3000 });
}
userDialog.value = false;
await fetchUsers();
} catch (error) {
console.error('사용자 저장 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 저장 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const confirmDeleteUser = (userData) => {
user.value = userData;
deleteUserDialog.value = true;
};
//
const deleteUser = async () => {
try {
await adminService.deleteUser(user.value.id);
deleteUserDialog.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '사용자가 삭제되었습니다.', life: 3000 });
await fetchUsers();
} catch (error) {
console.error('사용자 삭제 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 삭제 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const getRoleName = (role) => {
switch (role) {
case 'admin':
return '관리자';
case 'clubadmin':
return '클럽 관리자';
case 'user':
return '일반 사용자';
default:
return role;
}
};
//
const getRoleSeverity = (role) => {
switch (role) {
case 'admin':
return 'danger';
case 'clubadmin':
return 'warning';
case 'user':
return 'info';
default:
return 'info';
}
};
//
const getStatusName = (status) => {
switch (status) {
case 'active':
return '활성';
case 'inactive':
return '비활성';
case 'suspended':
return '정지';
default:
return status;
}
};
//
const getStatusSeverity = (status) => {
switch (status) {
case 'active':
return 'success';
case 'inactive':
return 'secondary';
case 'suspended':
return 'danger';
default:
return 'info';
}
};
//
const formatDate = (date) => {
return date ? dayjs(date).format('YYYY-MM-DD') : '-';
};
</script>
<style scoped>
.user-management-container {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
}
.mr-3 {
margin-right: 0.75rem;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="login-container">
<div class="login-card">
<h2>볼링 클럽 매니저 로그인</h2>
<form @submit.prevent="handleLogin" v-if="!showRegisterForm">
<div class="form-group">
<label for="email">이메일</label>
<InputText id="email" v-model="email" class="w-full" :class="{'p-invalid': emailError}" />
<small v-if="emailError" class="p-error">{{ emailError }}</small>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<InputText id="password" v-model="password" type="password" class="w-full" :class="{'p-invalid': passwordError}" />
<small v-if="passwordError" class="p-error">{{ passwordError }}</small>
</div>
<div class="form-options">
<div class="remember-me">
<Checkbox v-model="rememberMe" :binary="true" inputId="rememberMe" />
<label for="rememberMe" class="ml-2">로그인 상태 유지</label>
</div>
<a href="#" class="forgot-password">비밀번호 찾기</a>
</div>
<Button type="submit" label="로그인" class="w-full" :loading="loading" />
<div class="register-link">
<p>계정이 없으신가요? <a href="#" @click.prevent="showRegisterForm = true">회원가입</a></p>
</div>
</form>
<!-- 회원가입 -->
<form @submit.prevent="handleRegister" v-if="showRegisterForm">
<h3>회원가입</h3>
<div class="form-group">
<label for="registerName">이름</label>
<InputText id="registerName" v-model="registerName" class="w-full" :class="{'p-invalid': registerNameError}" />
<small v-if="registerNameError" class="p-error">{{ registerNameError }}</small>
</div>
<div class="form-group">
<label for="registerEmail">이메일</label>
<InputText id="registerEmail" v-model="registerEmail" class="w-full" :class="{'p-invalid': registerEmailError}" />
<small v-if="registerEmailError" class="p-error">{{ registerEmailError }}</small>
</div>
<div class="form-group">
<label for="registerPassword">비밀번호</label>
<Password id="registerPassword" v-model="registerPassword" :feedback="true" toggleMask
:class="{'p-invalid': registerPasswordError}"
:promptLabel="'비밀번호 강도'"
:weakLabel="'약함'"
:mediumLabel="'중간'"
:strongLabel="'강함'"
class="w-full" />
<small v-if="registerPasswordError" class="p-error">{{ registerPasswordError }}</small>
</div>
<div class="form-group">
<label for="registerPasswordConfirm">비밀번호 확인</label>
<Password id="registerPasswordConfirm" v-model="registerPasswordConfirm" toggleMask
:class="{'p-invalid': registerPasswordConfirmError}"
:feedback="false"
class="w-full" />
<small v-if="registerPasswordConfirmError" class="p-error">{{ registerPasswordConfirmError }}</small>
</div>
<div class="form-group">
<label for="registerPhone">전화번호 (선택)</label>
<InputText id="registerPhone" v-model="registerPhone" class="w-full" />
</div>
<Button type="submit" label="회원가입" class="w-full" :loading="loading" />
<div class="login-link">
<p>이미 계정이 있으신가요? <a href="#" @click.prevent="showRegisterForm = false">로그인</a></p>
</div>
</form>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, computed, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import authService from '@/services/authService';
const user = inject('user');
const router = useRouter();
const toast = useToast();
const email = ref('');
const password = ref('');
const rememberMe = ref(false);
const loading = ref(false);
const emailError = ref('');
const passwordError = ref('');
//
const showRegisterForm = ref(false);
const registerName = ref('');
const registerEmail = ref('');
const registerPassword = ref('');
const registerPasswordConfirm = ref('');
const registerPhone = ref('');
const registerNameError = ref('');
const registerEmailError = ref('');
const registerPasswordError = ref('');
const registerPasswordConfirmError = ref('');
//
const passwordHasMinLength = computed(() => registerPassword.value.length >= 8);
const passwordHasUppercase = computed(() => /[A-Z]/.test(registerPassword.value));
const passwordHasLowercase = computed(() => /[a-z]/.test(registerPassword.value));
const passwordHasNumber = computed(() => /[0-9]/.test(registerPassword.value));
const passwordHasSpecial = computed(() => /[!@#$%^&*(),.?":{}|<>]/.test(registerPassword.value));
const validateForm = () => {
let isValid = true;
//
if (!email.value) {
emailError.value = '이메일을 입력해주세요.';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
emailError.value = '유효한 이메일 주소를 입력해주세요.';
isValid = false;
} else {
emailError.value = '';
}
//
if (!password.value) {
passwordError.value = '비밀번호를 입력해주세요.';
isValid = false;
} else {
passwordError.value = '';
}
return isValid;
};
//
const validateRegisterForm = () => {
let isValid = true;
//
if (!registerName.value) {
registerNameError.value = '이름을 입력해주세요.';
isValid = false;
} else {
registerNameError.value = '';
}
//
if (!registerEmail.value) {
registerEmailError.value = '이메일을 입력해주세요.';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerEmail.value)) {
registerEmailError.value = '유효한 이메일 주소를 입력해주세요.';
isValid = false;
} else {
registerEmailError.value = '';
}
//
if (!registerPassword.value) {
registerPasswordError.value = '비밀번호를 입력해주세요.';
isValid = false;
} else if (registerPassword.value.length < 8) {
registerPasswordError.value = '비밀번호는 최소 8자 이상이어야 합니다.';
isValid = false;
} else if (!passwordHasUppercase.value || !passwordHasLowercase.value ||
!passwordHasNumber.value || !passwordHasSpecial.value) {
registerPasswordError.value = '비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다.';
isValid = false;
} else {
registerPasswordError.value = '';
}
//
if (!registerPasswordConfirm.value) {
registerPasswordConfirmError.value = '비밀번호 확인을 입력해주세요.';
isValid = false;
} else if (registerPassword.value !== registerPasswordConfirm.value) {
registerPasswordConfirmError.value = '비밀번호가 일치하지 않습니다.';
isValid = false;
} else {
registerPasswordConfirmError.value = '';
}
return isValid;
};
//
const handleLogin = async () => {
if (!validateForm()) return;
loading.value = true;
try {
const response = await authService.login({
email: email.value,
password: password.value
});
//
const { token, user: userData } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('userRole', userData.role);
localStorage.setItem('clubId', userData.clubId);
localStorage.setItem('userClubRole', userData.memberType || '');
//
user.value = userData;
//
toast.add({ severity: 'success', summary: '성공', detail: '로그인에 성공했습니다.', life: 3000 });
//
window.dispatchEvent(new CustomEvent('user-logged-in'));
//
if (userData.role === 'admin' || userData.role === 'superadmin') {
router.push('/admin');
} else {
router.push('/club');
}
} catch (error) {
console.error('로그인 오류:', error);
//
const errorMessage = error.response?.data?.message || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
//
const handleRegister = async () => {
if (!validateRegisterForm()) return;
loading.value = true;
try {
await authService.register({
name: registerName.value,
email: registerEmail.value,
password: registerPassword.value,
phone: registerPhone.value
});
//
toast.add({ severity: 'success', summary: '성공', detail: '회원가입에 성공했습니다. 로그인해주세요.', life: 3000 });
//
showRegisterForm.value = false;
//
email.value = registerEmail.value;
password.value = '';
//
registerName.value = '';
registerEmail.value = '';
registerPassword.value = '';
registerPasswordConfirm.value = '';
registerPhone.value = '';
} catch (error) {
console.error('회원가입 오류:', error);
//
const errorMessage = error.response?.data?.message || '회원가입에 실패했습니다. 다시 시도해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 80vh;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: white;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.remember-me {
display: flex;
align-items: center;
}
.forgot-password {
color: #2196F3;
text-decoration: none;
font-size: 0.875rem;
}
.forgot-password:hover {
text-decoration: underline;
}
.register-link, .login-link {
text-align: center;
margin-top: 1.5rem;
}
.register-link a, .login-link a {
color: #2196F3;
text-decoration: none;
font-weight: 500;
}
.register-link a:hover, .login-link a:hover {
text-decoration: underline;
}
.ml-2 {
margin-left: 0.5rem;
}
.w-full {
width: 100%;
}
:deep(.p-password-input) {
width: 100%;
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<template>
<div class="profile-container">
<div class="card">
<h2> 프로필</h2>
<div class="profile-content">
<div class="profile-header">
<div class="profile-avatar">
<Avatar :image="user.avatar || null" :label="getInitials(user.name)" size="xlarge" shape="circle" />
</div>
<div class="profile-info">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<p><Badge :value="user.role === 'admin' ? '관리자' : '일반 사용자'" :severity="user.role === 'admin' ? 'success' : 'info'" /></p>
</div>
</div>
<Divider />
<div class="profile-form">
<form @submit.prevent="updateProfile">
<div class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="form.name" :class="{'p-invalid': v$.name.$invalid && submitted}" />
<small v-if="v$.name.$invalid && submitted" class="p-error">{{ v$.name.$errors[0].$message }}</small>
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="form.email" :class="{'p-invalid': v$.email.$invalid && submitted}" />
<small v-if="v$.email.$invalid && submitted" class="p-error">{{ v$.email.$errors[0].$message }}</small>
</div>
<Divider />
<div class="field">
<label for="currentPassword">현재 비밀번호 (변경 시에만 입력)</label>
<Password id="currentPassword" v-model="form.currentPassword" :feedback="false" toggleMask />
</div>
<div class="field">
<label for="newPassword"> 비밀번호 (변경 시에만 입력)</label>
<Password id="newPassword" v-model="form.newPassword" :class="{'p-invalid': v$.newPassword.$invalid && passwordChangeAttempted}" toggleMask />
<small v-if="v$.newPassword.$invalid && passwordChangeAttempted" class="p-error">{{ v$.newPassword.$errors[0].$message }}</small>
</div>
<div class="field">
<label for="confirmPassword">비밀번호 확인</label>
<Password id="confirmPassword" v-model="form.confirmPassword" :class="{'p-invalid': v$.confirmPassword.$invalid && passwordChangeAttempted}" :feedback="false" toggleMask />
<small v-if="v$.confirmPassword.$invalid && passwordChangeAttempted" class="p-error">{{ v$.confirmPassword.$errors[0].$message }}</small>
</div>
</div>
<div class="button-container">
<Button type="submit" label="프로필 업데이트" :loading="loading" />
</div>
</form>
</div>
</div>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength, sameAs, helpers } from '@vuelidate/validators';
import axios from 'axios';
const toast = useToast();
const API_URL = 'http://localhost:3030/api';
//
const loading = ref(false);
const submitted = ref(false);
const passwordChangeAttempted = ref(false);
//
const user = reactive({
id: null,
name: '',
email: '',
role: '',
avatar: null
});
//
const form = reactive({
name: '',
email: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
//
const rules = computed(() => ({
name: { required: helpers.withMessage('이름을 입력해주세요.', required) },
email: {
required: helpers.withMessage('이메일을 입력해주세요.', required),
email: helpers.withMessage('유효한 이메일 주소를 입력해주세요.', email)
},
newPassword: {
minLength: helpers.withMessage('비밀번호는 최소 8자 이상이어야 합니다.', minLength(8))
},
confirmPassword: {
sameAsPassword: helpers.withMessage('비밀번호가 일치하지 않습니다.', sameAs(form.newPassword))
}
}));
const v$ = useVuelidate(rules, form);
//
onMounted(async () => {
await fetchUserProfile();
});
//
const fetchUserProfile = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(`${API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }
});
//
Object.assign(user, response.data);
//
form.name = user.name;
form.email = user.email;
} catch (error) {
console.error('사용자 프로필을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '사용자 프로필을 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
// ()
const getInitials = (name) => {
if (!name) return '';
return name.split(' ')
.map(part => part.charAt(0))
.join('')
.toUpperCase();
};
//
const updateProfile = async () => {
submitted.value = true;
const profileResult = await v$.value.$validate();
if (!profileResult) return;
loading.value = true;
try {
const token = localStorage.getItem('token');
const profileData = {
name: form.name,
email: form.email
};
//
if (form.currentPassword && form.newPassword) {
passwordChangeAttempted.value = true;
if (form.newPassword !== form.confirmPassword) {
toast.add({ severity: 'error', summary: '오류', detail: '새 비밀번호가 일치하지 않습니다.', life: 3000 });
return;
}
profileData.currentPassword = form.currentPassword;
profileData.password = form.newPassword;
}
await axios.put(`${API_URL}/auth/profile`, profileData, {
headers: { Authorization: `Bearer ${token}` }
});
//
Object.assign(user, {
name: profileData.name,
email: profileData.email
});
//
form.currentPassword = '';
form.newPassword = '';
form.confirmPassword = '';
passwordChangeAttempted.value = false;
toast.add({ severity: 'success', summary: '성공', detail: '프로필이 성공적으로 업데이트되었습니다.', life: 3000 });
} catch (error) {
console.error('프로필 업데이트 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: error.response?.data?.message || '프로필 업데이트 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.profile-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #ffffff;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
}
.profile-content {
margin-top: 2rem;
}
.profile-header {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.profile-info {
h3 {
margin: 0;
margin-bottom: 0.5rem;
}
p {
margin: 0;
margin-bottom: 0.5rem;
color: #666;
}
}
.profile-form {
margin-top: 2rem;
.field {
margin-bottom: 1.5rem;
}
}
.button-container {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="club-create-container">
<div class="club-create-card">
<h2>클럽 생성</h2>
<p class="info-text">볼링 클럽을 생성하여 회원들을 관리하고 활동을 시작해보세요.</p>
<form @submit.prevent="handleCreateClub">
<div class="form-group">
<label for="clubName">클럽 이름</label>
<InputText id="clubName" v-model="clubName" class="w-full" :class="{'p-invalid': clubNameError}" />
<small v-if="clubNameError" class="p-error">{{ clubNameError }}</small>
</div>
<div class="form-group">
<label for="clubDescription">클럽 설명</label>
<Textarea id="clubDescription" v-model="clubDescription" rows="4" class="w-full" :class="{'p-invalid': clubDescriptionError}" />
<small v-if="clubDescriptionError" class="p-error">{{ clubDescriptionError }}</small>
</div>
<div class="form-group">
<label for="clubLocation">활동 볼링장</label>
<InputText id="clubLocation" v-model="clubLocation" class="w-full" />
</div>
<div class="form-group">
<label for="clubContact">연락처</label>
<InputText id="clubContact" v-model="clubContact" class="w-full" />
</div>
<Button type="submit" label="클럽 생성하기" class="w-full" :loading="loading" />
</form>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import clubService from '@/services/clubService';
import { useAuthStore } from '@/stores/authStore';
import axios from 'axios'
const toast = useToast();
const authStore = useAuthStore();
const router = useRouter();
const clubName = ref('');
const clubDescription = ref('');
const clubLocation = ref('');
const clubContact = ref('');
const loading = ref(false);
const clubNameError = ref('');
const clubDescriptionError = ref('');
onMounted(() => {
authStore.loadUserInfo();
});
const validateForm = () => {
let isValid = true;
//
if (!clubName.value) {
clubNameError.value = '클럽 이름을 입력해주세요.';
isValid = false;
} else {
clubNameError.value = '';
}
//
if (!clubDescription.value) {
clubDescriptionError.value = '클럽 설명을 입력해주세요.';
isValid = false;
} else {
clubDescriptionError.value = '';
}
return isValid;
};
const handleCreateClub = async () => {
if (!validateForm()) return;
if (!authStore.user) {
toast.add({ severity: 'error', summary: '오류', detail: '로그인이 필요합니다.', life: 3000 });
return;
}
loading.value = true;
try {
const response = await clubService.createClub({
name: clubName.value,
description: clubDescription.value,
location: clubLocation.value,
contact: clubContact.value,
ownerEmail: authStore.user.email
});
// ID
const clubId = response.data.id;
localStorage.setItem('clubId', clubId);
//
toast.add({ severity: 'success', summary: '성공', detail: '클럽이 성공적으로 생성되었습니다.', life: 3000 });
//
router.push('/club');
} catch (error) {
console.error('클럽 생성 오류:', error);
//
const errorMessage = error.response?.data?.message || '클럽 생성에 실패했습니다. 다시 시도해주세요.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.club-create-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
padding: 2rem 0;
}
.club-create-card {
width: 100%;
max-width: 600px;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: white;
}
.info-text {
margin-bottom: 1.5rem;
color: #666;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.w-full {
width: 100%;
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<div class="club-dashboard">
<h2>클럽 대시보드</h2>
<div v-if="loading" class="card flex justify-content-center">
<ProgressSpinner />
</div>
<div v-else class="grid">
<!-- 회원 통계 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-users"></i>
<h3>회원 현황</h3>
</div>
<div class="card-content">
<p> 회원: {{ memberStats.totalCount }}</p>
<p>정회원: {{ memberStats.regularCount }}</p>
<p>준회원: {{ memberStats.associateCount }}</p>
</div>
<div class="card-footer">
<Button label="회원 관리" icon="pi pi-arrow-right" @click="navigateTo('/club/members')" />
</div>
</div>
</div>
<!-- 다음 모임 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-calendar"></i>
<h3>다음 모임</h3>
</div>
<div class="card-content" v-if="nextMeeting">
<p>일시: {{ formatDate(nextMeeting.date) }}</p>
<p>장소: {{ nextMeeting.location || '미정' }}</p>
<p>참가 예정: {{ nextMeeting.participantsCount }}</p>
</div>
<div class="card-content" v-else>
<p>예정된 모임이 없습니다.</p>
</div>
<div class="card-footer">
<Button label="모임 관리" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
</div>
</div>
</div>
<!-- 최근 모임 통계 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-chart-bar"></i>
<h3>최근 모임</h3>
</div>
<div class="card-content" v-if="lastMeeting">
<p>일시: {{ formatDate(lastMeeting.date) }}</p>
<p>참가자: {{ lastMeeting.participantsCount }}</p>
<p>게임 : {{ lastMeeting.games }}게임</p>
</div>
<div class="card-content" v-else>
<p>최근 모임 기록이 없습니다.</p>
</div>
<div class="card-footer">
<Button label="기록 보기" icon="pi pi-arrow-right" @click="navigateTo('/club/events')" />
</div>
</div>
</div>
<!-- 점수 통계 카드 -->
<div class="col-12 md:col-6 lg:col-3">
<div class="dashboard-card">
<div class="card-header">
<i class="pi pi-chart-line"></i>
<h3>점수 통계</h3>
</div>
<div class="card-content">
<p>최고 게임: {{ scoreStats.topGame || 0 }}</p>
<p>평균 점수: {{ scoreStats.averageScore || 0 }}</p>
<p>전체 게임: {{ scoreStats.totalGames || 0 }}게임</p>
</div>
<div class="card-footer">
<Button label="통계 보기" icon="pi pi-arrow-right" @click="navigateTo('/club/statistics')" />
</div>
</div>
</div>
</div>
<div class="grid mt-4">
<!-- 상위 회원 순위 -->
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<h3>에버리지 순위</h3>
</div>
<DataTable :value="topMembers" :rows="5" stripedRows responsiveLayout="scroll">
<Column field="rank" header="#" :body="rankTemplate" style="width: 10%"></Column>
<Column field="name" header="이름" style="width: 30%"></Column>
<Column field="average" header="에버리지" :body="averageTemplate" style="width: 20%"></Column>
<Column field="games" header="게임 수" style="width: 20%"></Column>
<Column field="handicap" header="핸디캡" style="width: 20%"></Column>
</DataTable>
</div>
</div>
<!-- 최근 모임 목록 -->
<div class="col-12 md:col-6">
<div class="dashboard-card">
<div class="card-header">
<h3>최근 모임</h3>
</div>
<DataTable :value="recentMeetings" :rows="5" stripedRows responsiveLayout="scroll">
<Column field="date" header="일시" :body="dateTemplate" style="width: 25%"></Column>
<Column field="title" header="제목" style="width: 40%"></Column>
<Column field="participants" header="참가자" style="width: 15%"></Column>
<Column field="games" header="게임 수" style="width: 20%"></Column>
</DataTable>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import apiClient from '@/services/api';
import dayjs from 'dayjs';
import { useToast } from 'primevue/usetoast';
const router = useRouter();
const toast = useToast();
//
const loading = ref(false);
const clubId = ref(null);
//
const memberStats = ref({
totalCount: 0,
regularCount: 0,
associateCount: 0
});
//
const nextMeeting = ref(null);
//
const lastMeeting = ref(null);
//
const scoreStats = ref({
topGame: 0,
averageScore: 0,
totalGames: 0
});
//
const topMembers = ref([]);
//
const recentMeetings = ref([]);
//
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm');
};
// 릿
const rankTemplate = (rowData, column) => {
return rowData.rank || topMembers.value.indexOf(rowData) + 1;
};
const averageTemplate = (rowData) => {
return rowData.average ? rowData.average.toFixed(1) : '0.0';
};
const dateTemplate = (rowData) => {
return formatDate(rowData.date);
};
//
const loadDashboardData = async () => {
if (!clubId.value) {
console.error('클럽 ID가 없습니다.');
return;
}
try {
loading.value = true;
//
const membersResponse = await apiClient.post('/api/club/members/stats', {
clubId: clubId.value
});
memberStats.value = membersResponse.data;
//
const nextMeetingResponse = await apiClient.post('/api/club/meetings/next', {
clubId: clubId.value
});
nextMeeting.value = nextMeetingResponse.data;
//
const lastMeetingResponse = await apiClient.post('/api/club/meetings/last', {
clubId: clubId.value
});
lastMeeting.value = lastMeetingResponse.data;
//
const scoreStatsResponse = await apiClient.post('/api/club/scores/stats', {
clubId: clubId.value
});
scoreStats.value = scoreStatsResponse.data;
//
const topMembersResponse = await apiClient.post('/api/club/members/top', {
clubId: clubId.value
});
topMembers.value = topMembersResponse.data;
//
const recentMeetingsResponse = await apiClient.post('/api/club/meetings/recent', {
clubId: clubId.value
});
recentMeetings.value = recentMeetingsResponse.data;
} catch (error) {
console.error('대시보드 데이터 로딩 오류:', error);
toast.add({
severity: 'error',
summary: '오류',
detail: '대시보드 데이터를 불러오는 중 오류가 발생했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
//
const navigateTo = (path) => {
router.push(path);
};
//
onMounted(async () => {
clubId.value = localStorage.getItem('clubId') || null;
await loadDashboardData();
});
</script>
<style scoped>
.club-dashboard {
padding: 1rem;
}
.dashboard-card {
background: #ffffff;
border-radius: 10px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
}
.card-header i {
font-size: 1.5rem;
color: var(--primary-color);
}
.card-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.2rem;
}
.card-content {
flex: 1;
margin-bottom: 1rem;
}
.card-content p {
margin: 0.5rem 0;
color: var(--text-color-secondary);
}
.card-footer {
margin-top: auto;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="event-calendar-container">
<div class="header">
<h2>이벤트 캘린더</h2>
<Button label="이벤트 관리" icon="pi pi-cog" @click="navigateToEventManagement" />
</div>
<FullCalendar
:options="calendarOptions"
class="calendar-wrapper"
/>
<!-- 이벤트 상세 다이얼로그 -->
<EventDetailsDialog
v-model:visible="eventDetailsDialog"
:event="selectedEvent"
:getEventTypeName="getEventTypeName"
:getEventTypeSeverity="getEventTypeSeverity"
:getStatusName="getStatusName"
:getStatusSeverity="getStatusSeverity"
:formatDate="formatDate"
@edit="navigateToEventManagement"
@remove-participant="removeParticipant"
/>
<Toast />
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import koLocale from '@fullcalendar/core/locales/ko';
import EventDetailsDialog from '@/components/event/EventDetailsDialog.vue';
import eventService from '@/services/eventService';
import {
getEventTypeName,
getEventTypeSeverity,
getStatusName,
getStatusSeverity,
formatDate
} from '@/utils/eventUtils';
const router = useRouter();
const toast = useToast();
//
const events = ref([]);
const selectedEvent = ref({});
const eventDetailsDialog = ref(false);
const loading = ref(false);
const clubId = ref(localStorage.getItem('clubId') || null);
//
onMounted(async () => {
await fetchEvents();
});
//
const fetchEvents = async () => {
loading.value = true;
try {
const eventData = await eventService.getEvents(clubId.value);
events.value = eventData.map(event => ({
id: event.id,
title: event.title,
start: event.startDate,
end: event.endDate,
extendedProps: {
...event
},
backgroundColor: getEventColor(event.eventType),
borderColor: getEventColor(event.eventType),
textColor: '#ffffff'
}));
} catch (error) {
console.error('이벤트 목록을 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '이벤트 목록을 가져오는 중 오류가 발생했습니다.', life: 3000 });
} finally {
loading.value = false;
}
};
//
const getEventColor = (eventType) => {
switch (eventType) {
case '정기전':
return '#FF5722'; //
case '번개':
return '#2196F3'; //
case '연습':
return '#4CAF50'; //
case '교류전':
return '#9C27B0'; //
case '이벤트':
return '#FFC107'; //
case '기타':
return '#607D8B'; //
default:
return '#3f51b5'; //
}
};
//
const handleEventClick = (info) => {
const eventId = info.event.id;
viewEventDetails(eventId);
};
//
const viewEventDetails = async (eventId) => {
try {
selectedEvent.value = await eventService.getEventById(eventId);
eventDetailsDialog.value = true;
} catch (error) {
console.error('이벤트 상세 정보를 가져오는 중 오류 발생:', error);
toast.add({ severity: 'error', summary: '오류', detail: '이벤트 상세 정보를 가져오는 중 오류가 발생했습니다.', life: 3000 });
}
};
//
const removeParticipant = async (participant) => {
try {
await eventService.removeParticipant(selectedEvent.value.id, participant.id);
//
selectedEvent.value.participants = selectedEvent.value.participants.filter(p => p.id !== participant.id);
toast.add({ severity: 'success', summary: '성공', detail: '참가자가 제거되었습니다.', life: 3000 });
} catch (error) {
console.error('참가자 제거 중 오류 발생:', error);
const errorMessage = error.response?.data?.message || '참가자 제거 중 오류가 발생했습니다.';
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
}
};
//
const navigateToEventManagement = () => {
router.push('/club/events');
};
//
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
events: events.value,
eventClick: handleEventClick,
locale: 'ko',
height: 'auto',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
meridiem: false,
hour12: false
},
dayMaxEvents: true
}));
</script>
<style scoped>
.event-calendar-container {
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.calendar-wrapper {
height: calc(100vh - 200px);
margin-bottom: 2rem;
}
:deep(.fc-event) {
cursor: pointer;
}
:deep(.fc-toolbar-title) {
font-size: 1.5rem !important;
}
:deep(.fc-daygrid-day-number) {
font-size: 1rem;
}
:deep(.fc-col-header-cell-cushion) {
font-weight: 600;
}
</style>
+480
View File
@@ -0,0 +1,480 @@
<template>
<div class="event-management-container">
<div class="header">
<h2>이벤트 관리</h2>
<div class="header-buttons">
<Button label="캘린더 보기" icon="pi pi-calendar" class="p-button-outlined mr-2" @click="navigateToCalendar" />
<Button label="이벤트 추가" icon="pi pi-plus" @click="openNewEventDialog" />
</div>
</div>
<!-- 이벤트 목록 -->
<DataTable
:value="events"
:loading="loading"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="{first}에서 {last}까지 {totalRecords} 중"
responsiveLayout="scroll"
stripedRows
filterDisplay="menu"
v-model:filters="filters"
class="mt-4"
>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="title" header="제목" sortable style="width: 20%">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="제목으로 검색" />
</template>
</Column>
<Column field="eventType" header="유형" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getEventTypeName(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
</template>
</Column>
<Column field="startDate" header="시작일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.startDate) }}
</template>
<template #filter="{ filterModel, filterCallback }">
<Calendar v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
</template>
</Column>
<Column field="endDate" header="종료일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.endDate) }}
</template>
</Column>
<Column field="location" header="장소" sortable style="width: 15%"></Column>
<Column field="maxParticipants" header="최대 인원" sortable style="width: 8%"></Column>
<Column field="status" header="상태" sortable style="width: 8%">
<template #body="slotProps">
<Badge :value="slotProps.data.status" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="작업" style="width: 10%">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-eye" class="p-button-rounded p-button-text" @click="viewEventDetails(slotProps.data)" />
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text" @click="editEvent(slotProps.data)" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger" @click="confirmDeleteEvent(slotProps.data)" />
</div>
</template>
</Column>
</DataTable>
<!-- 이벤트 다이얼로그 -->
<EventDialog
v-model:visible="eventDialog"
:event="event || {}"
:isNew="!event?.id"
:submitted="submitted"
@save="saveEvent"
@hide="hideDialog"
/>
<!-- 이벤트 상세 정보 다이얼로그 -->
<EventDetailsDialog
v-model:visible="detailsDialog"
:event="event || {}"
:getEventTypeName="getEventTypeName"
:getEventTypeSeverity="getEventTypeSeverity"
:getStatusName="getStatusName"
:getStatusSeverity="getStatusSeverity"
:formatDate="formatDate"
:availableMembers="clubMembers"
@edit="editFromDetails"
@hide="hideDialog"
@participant-updated="fetchEvents"
@score-updated="fetchEvents"
/>
<!-- 삭제 확인 다이얼로그 -->
<Dialog v-model:visible="deleteEventDialog" :style="{ width: '450px' }" header="확인" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span v-if="event">{{ event.title }} 이벤트를 삭제하시겠습니까?</span>
</div>
<template #footer>
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteEventDialog = false" />
<Button label="예" icon="pi pi-check" class="p-button-danger" @click="deleteEvent" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useRoute } from 'vue-router';
import { useClubStore } from '@/stores/clubStore';
import eventService from '@/services/eventService';
import clubService from '@/services/clubService';
import ScoreManagement from '@/components/event/ScoreManagement.vue';
import ParticipantManagement from '@/components/event/ParticipantManagement.vue';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import EventDialog from '@/components/event/EventDialog.vue';
import EventDetailsDialog from '@/components/event/EventDetailsDialog.vue';
const toast = useToast();
const route = useRoute();
const clubStore = useClubStore();
//
const events = ref([]);
const event = ref({});
const eventDialog = ref(false);
const deleteEventDialog = ref(false);
const detailsDialog = ref(false);
const loading = ref(false);
const submitted = ref(false);
const clubId = ref(localStorage.getItem('clubId') || null);
//
const filters = ref({
title: { value: null, matchMode: 'contains' },
eventType: { value: null, matchMode: 'equals' },
startDate: { value: null, matchMode: 'equals' }
});
//
const eventTypeOptions = [
{ name: '정기전', value: '정기전' },
{ name: '토너먼트', value: '토너먼트' },
{ name: '연습', value: '연습' },
{ name: '친선전', value: '친선전' },
{ name: '기타', value: '기타' }
];
//
const clubMembers = ref([]);
//
const fetchEvents = async () => {
loading.value = true;
try {
const response = await eventService.getEvents(clubId.value);
events.value = response;
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 목록을 가져오는데 실패했습니다.',
life: 3000
});
} finally {
loading.value = false;
}
};
//
const fetchClubMembers = async () => {
try {
const response = await clubService.getClubMembers(clubId.value);
clubMembers.value = response.data.map(member => ({
id: member.memberId || member.id, // memberId id
name: member.name,
memberType: member.memberType
}));
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '클럽 멤버 목록을 가져오는 중 오류가 발생했습니다.',
life: 3000
});
}
};
//
onMounted(async () => {
if (!clubId.value) {
toast.add({
severity: 'error',
summary: '오류',
detail: '클럽 정보가 없습니다.',
life: 3000
});
return;
}
await Promise.all([
fetchEvents(),
fetchClubMembers()
]);
});
//
const formatDate = (date) => {
if (!date) return '';
const d = new Date(date);
return d.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// Badge
const getEventTypeSeverity = (type) => {
switch (type) {
case '정기전': return 'success';
case '토너먼트': return 'warning';
case '연습': return 'info';
case '친선전': return 'primary';
default: return 'secondary';
}
};
//
const getEventTypeName = (type) => {
return type || '기타';
};
//
const getStatusName = (status) => {
switch (status) {
case '준비': return '준비';
case '진행중': return '진행중';
case '완료': return '완료';
case '취소': return '취소';
default: return '알 수 없음';
}
};
// Badge
const getStatusSeverity = (status) => {
switch (status) {
case '준비': return 'info';
case '진행중': return 'warning';
case '완료': return 'success';
case '취소': return 'danger';
default: return 'secondary';
}
};
//
const openNewEventDialog = () => {
event.value = {
clubId: clubId.value,
status: '준비',
eventType: '정기전',
title: '',
description: '',
startDate: null,
endDate: null,
location: '',
maxParticipants: 0,
teamCount: 1,
gameCount: 1,
laneCount: 0,
participantFee: 0
};
submitted.value = false;
eventDialog.value = true;
};
//
const editEvent = (eventData) => {
event.value = { ...eventData };
submitted.value = false;
eventDialog.value = true;
};
//
const viewEventDetails = async (eventData) => {
try {
const response = await eventService.getEventById(eventData.id);
event.value = response;
detailsDialog.value = true;
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 상세 정보를 가져오는데 실패했습니다.',
life: 3000
});
}
};
//
const editFromDetails = () => {
detailsDialog.value = false;
eventDialog.value = true;
};
//
const hideDialog = () => {
eventDialog.value = false;
detailsDialog.value = false;
deleteEventDialog.value = false;
event.value = {};
submitted.value = false;
};
//
const confirmDeleteEvent = (eventData) => {
event.value = eventData;
deleteEventDialog.value = true;
};
//
const saveEvent = async (eventData) => {
try {
if (eventData.id) {
//
const response = await eventService.updateEvent({
...eventData,
clubId: clubId.value
});
const updatedEvent = response;
const index = events.value.findIndex(e => e.id === updatedEvent.id);
if (index !== -1) {
events.value[index] = updatedEvent;
}
} else {
//
const response = await eventService.createEvent({
...eventData,
clubId: clubId.value
});
events.value.push(response);
}
toast.add({
severity: 'success',
summary: '성공',
detail: eventData.id ? '이벤트가 수정되었습니다.' : '이벤트가 생성되었습니다.',
life: 3000
});
hideDialog();
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: eventData.id ? '이벤트 수정에 실패했습니다.' : '이벤트 생성에 실패했습니다.',
life: 3000
});
}
};
//
const deleteEvent = async () => {
try {
await eventService.deleteEvent(clubId.value, event.value.id);
toast.add({
severity: 'success',
summary: '성공',
detail: '이벤트가 삭제되었습니다.',
life: 3000
});
deleteEventDialog.value = false;
const index = events.value.findIndex(e => e.id === event.value.id);
if (index > -1) {
events.value.splice(index, 1);
}
event.value = {};
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 삭제에 실패했습니다.',
life: 3000
});
}
};
//
const removeParticipant = async (participant) => {
try {
await eventService.removeParticipant(event.value.id, participant.id);
toast.add({
severity: 'success',
summary: '성공',
detail: '참가자가 제거되었습니다.',
life: 3000
});
//
const index = event.value.participants.findIndex(p => p.id === participant.id);
if (index > -1) {
event.value.participants.splice(index, 1);
}
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '참가자 제거에 실패했습니다.',
life: 3000
});
}
};
//
const goToScoreEntry = () => {
const url = `/club/events/${event.value.id}/scores?clubId=${clubId.value}`;
window.location.href = url;
};
//
const navigateToCalendar = () => {
const url = `/club/calendar?clubId=${clubId.value}`;
window.location.href = url;
};
</script>
<style scoped>
.event-management-container {
padding: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header h2 {
margin: 0;
}
.header-buttons {
display: flex;
gap: 0.5rem;
}
.confirmation-content {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.flex {
display: flex;
}
.gap-2 {
gap: 0.5rem;
}
:deep(.p-datatable) {
margin-top: 1rem;
}
:deep(.p-column-filter) {
width: 100%;
}
</style>

Some files were not shown because too many files have changed in this diff Show More