팀 생성 임시 완료
This commit is contained in:
@@ -48,6 +48,15 @@ io.on('connection', (socket) => {
|
|||||||
// 전역 Socket.IO 인스턴스 설정
|
// 전역 Socket.IO 인스턴스 설정
|
||||||
app.set('io', io);
|
app.set('io', io);
|
||||||
|
|
||||||
|
// app.js 또는 서버 초기화 파일
|
||||||
|
const session = require('express-session');
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.JWT_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { secure: process.env.NODE_ENV === 'production' } // https 환경이면 true, 개발환경은 false
|
||||||
|
}));
|
||||||
|
|
||||||
// 데이터베이스 연결 및 모델 동기화 실행
|
// 데이터베이스 연결 및 모델 동기화 실행
|
||||||
sequelize.authenticate()
|
sequelize.authenticate()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -16,6 +16,48 @@ const isAdmin = (user) => {
|
|||||||
return user && (user.role === 'admin' || user.role === 'superadmin');
|
return user && (user.role === 'admin' || user.role === 'superadmin');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 클럽 선택
|
||||||
|
const allowedMemberTypes = ['owner', 'manager'];
|
||||||
|
|
||||||
|
exports.selectClub = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { clubId } = req.body;
|
||||||
|
const userId = req.user && req.user.id;
|
||||||
|
|
||||||
|
// 클럽 존재 여부 확인
|
||||||
|
const club = await Club.findByPk(clubId);
|
||||||
|
if (!club) {
|
||||||
|
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리자 여부 체크
|
||||||
|
const isAdmin = req.user && (req.user.role === 'admin' || req.user.role === 'superadmin');
|
||||||
|
let isClubManager = false;
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
// Members 테이블에서 모임장/운영진 여부 확인
|
||||||
|
const member = await Member.findOne({
|
||||||
|
where: {
|
||||||
|
clubId,
|
||||||
|
userId,
|
||||||
|
memberType: ['모임장', '운영진']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
isClubManager = !!member;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin && !isClubManager) {
|
||||||
|
return res.status(403).json({ message: '해당 클럽에 대한 권한이 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.clubId = clubId;
|
||||||
|
res.status(200).json({ message: '클럽을 선택했습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('클럽 선택 오류:', error);
|
||||||
|
res.status(500).json({ message: '클럽을 선택하는 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 모든 클럽 조회
|
// 모든 클럽 조회
|
||||||
exports.getAllClubs = async (req, res) => {
|
exports.getAllClubs = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +99,7 @@ exports.getAllClubs = async (req, res) => {
|
|||||||
exports.getClubById = async (req, res) => {
|
exports.getClubById = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { clubId } = req.body;
|
const { clubId } = req.body;
|
||||||
console.log(`clubId: ${clubId}`);
|
// console.log(`clubId: ${clubId}`);
|
||||||
if (!clubId) {
|
if (!clubId) {
|
||||||
return res.status(400).json({ message: "유효하지 않은 클럽 ID입니다." });
|
return res.status(400).json({ message: "유효하지 않은 클럽 ID입니다." });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 이벤트 컨트롤러
|
* 이벤트 컨트롤러
|
||||||
* 이벤트 관련 기능을 처리하는 컨트롤러
|
* 이벤트 관련 기능을 처리하는 컨트롤러
|
||||||
*/
|
*/
|
||||||
const { Event, EventParticipant, Member, Club, EventScore, ClubMember } = require('../models');
|
const { Event, EventParticipant, Member, Club, EventScore, ClubMember, EventTeam, EventTeamMember } = require('../models');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const { sequelize } = require('../models');
|
const { sequelize } = require('../models');
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ exports.getEventById = async (req, res) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: EventParticipant,
|
model: EventParticipant,
|
||||||
attributes: ['id', 'status', 'paymentStatus', 'teamNumber'],
|
attributes: ['id', 'status', 'paymentStatus'],
|
||||||
where: { status: { [Op.ne]: '취소' } },
|
where: { status: { [Op.ne]: '취소' } },
|
||||||
required: false,
|
required: false,
|
||||||
include: [
|
include: [
|
||||||
@@ -538,7 +538,6 @@ exports.updateEventScore = async (req, res) => {
|
|||||||
const newScore = await EventScore.create({
|
const newScore = await EventScore.create({
|
||||||
eventId,
|
eventId,
|
||||||
participantId: existingScore.participantId,
|
participantId: existingScore.participantId,
|
||||||
teamNumber: existingScore.teamNumber,
|
|
||||||
gameNumber,
|
gameNumber,
|
||||||
score,
|
score,
|
||||||
handicap,
|
handicap,
|
||||||
@@ -1004,9 +1003,8 @@ exports.addEventParticipant = async (req, res) => {
|
|||||||
const {
|
const {
|
||||||
clubId,
|
clubId,
|
||||||
memberId,
|
memberId,
|
||||||
teamNumber,
|
status,
|
||||||
paymentStatus,
|
paymentStatus
|
||||||
paymentAmount
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const eventId = req.params.id;
|
const eventId = req.params.id;
|
||||||
if (!eventId || !clubId || !memberId) {
|
if (!eventId || !clubId || !memberId) {
|
||||||
@@ -1071,12 +1069,10 @@ exports.addEventParticipant = async (req, res) => {
|
|||||||
const participant = await EventParticipant.create({
|
const participant = await EventParticipant.create({
|
||||||
eventId,
|
eventId,
|
||||||
memberId,
|
memberId,
|
||||||
teamNumber: teamNumber || 1,
|
status: status || '참가예정',
|
||||||
status: req.body.status || '참가예정',
|
paymentStatus: paymentStatus || '미납',
|
||||||
attendance: false,
|
createdAt: new Date(),
|
||||||
paymentStatus: req.body.paymentStatus || '미납',
|
updatedAt: new Date()
|
||||||
paymentAmount: paymentAmount || event.entryFee || 0,
|
|
||||||
registeredAt: new Date()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 참가자 정보 조회
|
// 참가자 정보 조회
|
||||||
@@ -1166,6 +1162,96 @@ exports.updateEventParticipant = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [팀 생성 기능] 이벤트별 팀 목록 조회
|
||||||
|
exports.getEventTeams = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const eventId = req.params.id;
|
||||||
|
if (!eventId) {
|
||||||
|
return res.status(400).json({ message: '이벤트 ID가 필요합니다.' });
|
||||||
|
}
|
||||||
|
const teams = await EventTeam.findAll({
|
||||||
|
where: { eventId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: EventTeamMember,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: EventParticipant,
|
||||||
|
include: [{ model: Member }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['teamNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
const result = teams.map(team => ({
|
||||||
|
id: team.id,
|
||||||
|
teamNumber: team.teamNumber,
|
||||||
|
handicap: team.handicap,
|
||||||
|
members: (team.EventTeamMembers || [])
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map(tm => ({
|
||||||
|
id: tm.eventParticipantId,
|
||||||
|
name: tm.EventParticipant?.Member?.name || `참가자 ${tm.eventParticipantId}`,
|
||||||
|
order: tm.order
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
res.json({ teams: result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('팀 목록 조회 오류:', error);
|
||||||
|
res.status(500).json({ message: '팀 목록을 가져오는 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// [팀 생성 기능] 이벤트별 팀 배정 저장
|
||||||
|
exports.createOrUpdateEventTeams = async (req, res) => {
|
||||||
|
const eventId = req.params.id;
|
||||||
|
const { teams } = req.body; // [{teamNumber, handicap, members:[id]}]
|
||||||
|
|
||||||
|
if (!eventId || !Array.isArray(teams)) {
|
||||||
|
return res.status(400).json({ message: '이벤트 ID와 팀 정보가 필요합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sequelize.transaction(async (t) => {
|
||||||
|
// 기존 팀 및 팀멤버 데이터 삭제
|
||||||
|
await EventTeam.destroy({ where: { eventId }, transaction: t });
|
||||||
|
await EventTeamMember.destroy({
|
||||||
|
where: {}, // eventId로만 삭제하려면, eventTeamId로 팀 id 목록을 먼저 조회해서 삭제
|
||||||
|
transaction: t
|
||||||
|
});
|
||||||
|
|
||||||
|
// 새 팀 및 팀멤버 데이터 저장
|
||||||
|
for (const team of teams) {
|
||||||
|
// 팀 생성
|
||||||
|
const createdTeam = await EventTeam.create({
|
||||||
|
eventId,
|
||||||
|
teamNumber: team.teamNumber,
|
||||||
|
handicap: team.handicap || 0
|
||||||
|
}, { transaction: t });
|
||||||
|
|
||||||
|
// 팀 멤버(순서 포함) 저장
|
||||||
|
if (Array.isArray(team.members) && team.members.length > 0) {
|
||||||
|
const teamMemberRows = team.members
|
||||||
|
.filter(m => m.id != null)
|
||||||
|
.map(m => ({
|
||||||
|
eventTeamId: createdTeam.id,
|
||||||
|
eventParticipantId: m.id,
|
||||||
|
order: m.order ?? 1
|
||||||
|
}));
|
||||||
|
if (teamMemberRows.length > 0) {
|
||||||
|
await EventTeamMember.bulkCreate(teamMemberRows, { transaction: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ message: '팀 배정이 저장되었습니다.' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('팀 배정 저장 오류:', error);
|
||||||
|
res.status(500).json({ message: '팀 배정 저장 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 참가자 삭제 (소프트 삭제)
|
// 참가자 삭제 (소프트 삭제)
|
||||||
exports.deleteEventParticipant = async (req, res) => {
|
exports.deleteEventParticipant = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ const { clubHasFeature } = require('../utils/featureAccess');
|
|||||||
function requireFeature(featureCode) {
|
function requireFeature(featureCode) {
|
||||||
return async function(req, res, next) {
|
return async function(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const clubId = req.clubId || req.body.clubId || req.params.clubId;
|
const clubId = req.session.clubId || req.clubId || req.body.clubId || req.params.clubId;
|
||||||
|
console.log('backendClubId:', clubId);
|
||||||
if (!clubId) {
|
if (!clubId) {
|
||||||
return res.status(400).json({ error: '클럽 정보가 필요합니다.' });
|
return res.status(400).json({ error: '클럽 정보가 필요합니다.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const EventTeam = sequelize.define('EventTeam', {
|
|||||||
EventTeam.associate = (models) => {
|
EventTeam.associate = (models) => {
|
||||||
EventTeam.belongsTo(models.Event, { foreignKey: 'eventId' });
|
EventTeam.belongsTo(models.Event, { foreignKey: 'eventId' });
|
||||||
EventTeam.hasMany(models.EventParticipant, { foreignKey: 'teamId' });
|
EventTeam.hasMany(models.EventParticipant, { foreignKey: 'teamId' });
|
||||||
|
EventTeam.hasMany(models.EventTeamMember, { foreignKey: 'eventTeamId' });
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = EventTeam;
|
module.exports = EventTeam;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
|
const EventTeamMember = sequelize.define('EventTeamMember', {
|
||||||
|
eventTeamId: {
|
||||||
|
type: DataTypes.INTEGER.UNSIGNED,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
eventParticipantId: {
|
||||||
|
type: DataTypes.INTEGER.UNSIGNED,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
type: DataTypes.INTEGER.UNSIGNED,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'EventTeamMembers',
|
||||||
|
timestamps: false
|
||||||
|
});
|
||||||
|
|
||||||
|
EventTeamMember.associate = (models) => {
|
||||||
|
EventTeamMember.belongsTo(models.EventTeam, { foreignKey: 'eventTeamId' });
|
||||||
|
EventTeamMember.belongsTo(models.EventParticipant, { foreignKey: 'eventParticipantId' });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = EventTeamMember;
|
||||||
@@ -15,6 +15,7 @@ const SubscriptionPlan = require('./SubscriptionPlan');
|
|||||||
const SubscriptionPlanFeature = require('./SubscriptionPlanFeature');
|
const SubscriptionPlanFeature = require('./SubscriptionPlanFeature');
|
||||||
const ClubSubscription = require('./ClubSubscription');
|
const ClubSubscription = require('./ClubSubscription');
|
||||||
const ActivityLog = require('./activityLog');
|
const ActivityLog = require('./activityLog');
|
||||||
|
const EventTeamMember = require('./EventTeamMember');
|
||||||
|
|
||||||
// 모델 동기화 함수
|
// 모델 동기화 함수
|
||||||
const syncModels = async () => {
|
const syncModels = async () => {
|
||||||
@@ -50,6 +51,7 @@ const db = {
|
|||||||
SubscriptionPlanFeature,
|
SubscriptionPlanFeature,
|
||||||
ClubSubscription,
|
ClubSubscription,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
|
EventTeamMember,
|
||||||
};
|
};
|
||||||
Object.values(db).forEach(model => {
|
Object.values(db).forEach(model => {
|
||||||
if (model.associate) model.associate(db);
|
if (model.associate) model.associate(db);
|
||||||
@@ -72,5 +74,6 @@ module.exports = {
|
|||||||
SubscriptionPlanFeature,
|
SubscriptionPlanFeature,
|
||||||
ClubSubscription,
|
ClubSubscription,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
|
EventTeamMember,
|
||||||
syncModels
|
syncModels
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+56
@@ -15,6 +15,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
"express-validator": "^7.2.1",
|
"express-validator": "^7.2.1",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -1204,6 +1205,40 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-session": {
|
||||||
|
"version": "1.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
|
||||||
|
"integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.7",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-headers": "~1.0.2",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"uid-safe": "~2.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/express-validator": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.2.1",
|
"version": "7.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
||||||
@@ -2721,6 +2756,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/random-bytes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -3474,6 +3518,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid-safe": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"random-bytes": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/umzug": {
|
"node_modules/umzug": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
"express-validator": "^7.2.1",
|
"express-validator": "^7.2.1",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const clubController = require('../controllers/clubController');
|
|||||||
const { authenticateJWT } = require('../middleware/authMiddleware');
|
const { authenticateJWT } = require('../middleware/authMiddleware');
|
||||||
const requireFeature = require('../middleware/requireFeature');
|
const requireFeature = require('../middleware/requireFeature');
|
||||||
|
|
||||||
|
// 클럽 선택
|
||||||
|
router.post('/select-club', authenticateJWT, clubController.selectClub);
|
||||||
|
|
||||||
// 모든 클럽 조회
|
// 모든 클럽 조회
|
||||||
router.get('/', clubController.getAllClubs);
|
router.get('/', clubController.getAllClubs);
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ router.post('/:id/participants', authenticateJWT, requireFeature('event_manageme
|
|||||||
router.put('/:id/participants', authenticateJWT, requireFeature('event_management'), eventController.updateEventParticipant);
|
router.put('/:id/participants', authenticateJWT, requireFeature('event_management'), eventController.updateEventParticipant);
|
||||||
router.delete('/:id/participants', authenticateJWT, requireFeature('event_management'), eventController.deleteEventParticipant);
|
router.delete('/:id/participants', authenticateJWT, requireFeature('event_management'), eventController.deleteEventParticipant);
|
||||||
|
|
||||||
|
// [팀 생성 기능] 이벤트별 팀 배정 저장
|
||||||
|
router.post('/:id/teams', authenticateJWT, requireFeature('team_create'), eventController.createOrUpdateEventTeams);
|
||||||
|
router.get('/:id/teams', authenticateJWT, requireFeature('team_create'), eventController.getEventTeams);
|
||||||
|
|
||||||
// 이벤트 점수 관리
|
// 이벤트 점수 관리
|
||||||
router.get('/:id/scores', authenticateJWT, requireFeature('event_management'), eventController.getEventScores);
|
router.get('/:id/scores', authenticateJWT, requireFeature('event_management'), eventController.getEventScores);
|
||||||
router.post('/:id/scores', authenticateJWT, requireFeature('event_management'), eventController.addEventScore);
|
router.post('/:id/scores', authenticateJWT, requireFeature('event_management'), eventController.addEventScore);
|
||||||
|
|||||||
Generated
+19
@@ -28,6 +28,7 @@
|
|||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
"yup": "^1.3.3"
|
"yup": "^1.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3050,6 +3051,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3435,6 +3442,18 @@
|
|||||||
"vue": "^3.2.0"
|
"vue": "^3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vuedraggable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "1.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
|
"vuedraggable": "^4.1.0",
|
||||||
"yup": "^1.3.3"
|
"yup": "^1.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+26
-6
@@ -171,9 +171,9 @@ const fetchClubs = async () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 첫 번째 클럽의 memberType을 localStorage에 저장
|
// 첫 번째 클럽의 memberType을 localStorage에 저장
|
||||||
if (clubs.value.length > 0) {
|
if (clubs.value.length > 0 && !selectedClubId.value) {
|
||||||
localStorage.setItem('userClubRole', clubs.value[0].memberType || '');
|
selectedClubId.value = clubs.value[0].id;
|
||||||
localStorage.setItem('clubId', clubs.value[0].id);
|
await setSelectedClub(clubs.value[0]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('클럽 목록 로드 실패:', error);
|
console.error('클럽 목록 로드 실패:', error);
|
||||||
@@ -199,15 +199,30 @@ const fetchClubInfo = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 클럽 선택
|
||||||
|
const setSelectedClub = async (club) => {
|
||||||
|
// 1. localStorage에 저장
|
||||||
|
localStorage.setItem('clubId', club.id);
|
||||||
|
localStorage.setItem('userClubRole', club.memberType || '');
|
||||||
|
|
||||||
|
// 2. 세션에도 저장 (백엔드에 clubId 전달)
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/club/select-club', { clubId: club.id });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('클럽 ID 세션 저장 실패:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 프론트엔드 상태도 변경
|
||||||
|
selectedClubId.value = club.id;
|
||||||
|
};
|
||||||
|
|
||||||
// 클럽 변경 시
|
// 클럽 변경 시
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const onClubChange = async () => {
|
const onClubChange = async () => {
|
||||||
try {
|
try {
|
||||||
// 선택된 클럽의 memberType 찾기
|
|
||||||
const selectedClub = clubs.value.find(club => club.id === selectedClubId.value);
|
const selectedClub = clubs.value.find(club => club.id === selectedClubId.value);
|
||||||
if (selectedClub) {
|
if (selectedClub) {
|
||||||
localStorage.setItem('userClubRole', selectedClub.memberType || '');
|
await setSelectedClub(selectedClub);
|
||||||
localStorage.setItem('clubId', selectedClub.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchClubInfo();
|
await fetchClubInfo();
|
||||||
@@ -310,6 +325,11 @@ const handleUserLoggedIn = async () => {
|
|||||||
// 컴포넌트 마운트 시 데이터 로드
|
// 컴포넌트 마운트 시 데이터 로드
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadUserData();
|
await loadUserData();
|
||||||
|
const clubId = localStorage.getItem('clubId');
|
||||||
|
if (clubId) {
|
||||||
|
// 서버 세션에 clubId를 동기화
|
||||||
|
apiClient.post('/api/club/select-club', { clubId });
|
||||||
|
}
|
||||||
if (isLoggedIn.value) {
|
if (isLoggedIn.value) {
|
||||||
await fetchClubs();
|
await fetchClubs();
|
||||||
await fetchClubInfo();
|
await fetchClubInfo();
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<ParticipantManagement
|
<ParticipantManagement
|
||||||
:eventId="eventModel.id"
|
:eventId="eventModel.id"
|
||||||
:availableMembers="availableMembers"
|
:availableMembers="availableMembers"
|
||||||
@participant-updated="$emit('participant-updated')"
|
@participant-updated="handleParticipantUpdated"
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -60,49 +60,7 @@
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
<!-- 팀 편성 탭 -->
|
<!-- 팀 편성 탭 -->
|
||||||
<TabPanel header="팀 편성">
|
<TabPanel header="팀 편성">
|
||||||
<div class="team-generation-section">
|
<TeamGeneration :participants="confirmedParticipants" :teams="teams" @update:teams="teams = $event" @teams-generated="teams = $event" @save-teams="handleSaveTeams" />
|
||||||
<div class="flex align-items-center gap-2 mb-3">
|
|
||||||
<label>
|
|
||||||
<input type="radio" value="byMemberCount" v-model="teamOption" /> 팀 인원수로 나누기
|
|
||||||
</label>
|
|
||||||
<InputNumber v-if="teamOption==='byMemberCount'" v-model="teamOptionValue" :min="2" :max="eventModel.participants?.length || 2" placeholder="팀 인원수" style="width:120px;" />
|
|
||||||
<label class="ml-4">
|
|
||||||
<input type="radio" value="byTeamCount" v-model="teamOption" /> 팀 개수로 나누기
|
|
||||||
</label>
|
|
||||||
<InputNumber v-if="teamOption==='byTeamCount'" v-model="teamOptionValue" :min="2" :max="eventModel.participants?.length || 2" placeholder="팀 개수" style="width:120px;" />
|
|
||||||
<Button label="팀 생성" icon="pi pi-users" class="ml-3" :disabled="!canGenerateTeams" @click="generateTeams" />
|
|
||||||
</div>
|
|
||||||
<div v-if="teams.length > 0">
|
|
||||||
<div v-for="(team, idx) in teams" :key="idx" class="team-box mb-3 p-3">
|
|
||||||
<div class="flex align-items-center gap-2 mb-2">
|
|
||||||
<strong>팀 {{ idx + 1 }}</strong>
|
|
||||||
<span class="ml-2">(인원: {{ team.members.length }}명)</span>
|
|
||||||
<span class="ml-3">팀 에버리지: <span class="text-primary">{{ getTeamAverage(team).toFixed(1) }}</span></span>
|
|
||||||
<span class="ml-3">핸디캡:</span>
|
|
||||||
<InputNumber v-model="team.handicap" :min="-100" :max="100" style="width:80px;" />
|
|
||||||
</div>
|
|
||||||
<ul class="pl-3">
|
|
||||||
<li v-for="(member, mIdx) in team.members" :key="member.id || mIdx" class="flex align-items-center gap-2 mb-1">
|
|
||||||
<InputText v-model="member.name" style="width: 140px;" placeholder="이름 입력" />
|
|
||||||
<span v-if="getMemberAverage(member) !== null" class="text-xs text-gray-700 ml-1">({{ getMemberAverage(member) }})</span>
|
|
||||||
<Button icon="pi pi-times" class="p-button-text p-button-danger p-0" @click="team.members.splice(mIdx,1)" v-if="team.members.length > 1" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="flex align-items-center gap-2 mt-2">
|
|
||||||
<Dropdown
|
|
||||||
v-model="team._selectedMemberId"
|
|
||||||
:options="availableMembersForTeam(idx)"
|
|
||||||
optionLabel="name"
|
|
||||||
optionValue="id"
|
|
||||||
placeholder="팀원 선택"
|
|
||||||
style="width: 140px;"
|
|
||||||
/>
|
|
||||||
<Button icon="pi pi-plus" class="p-button-text p-0" :disabled="!team._selectedMemberId" @click="addMemberToTeam(idx)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-gray-500 mt-3">팀을 생성하려면 옵션을 선택 후 '팀 생성'을 눌러주세요.</div>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabView>
|
</TabView>
|
||||||
|
|
||||||
@@ -115,7 +73,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import TeamGeneration from './TeamGeneration.vue';
|
||||||
import Dialog from 'primevue/dialog';
|
import Dialog from 'primevue/dialog';
|
||||||
import TabView from 'primevue/tabview';
|
import TabView from 'primevue/tabview';
|
||||||
import TabPanel from 'primevue/tabpanel';
|
import TabPanel from 'primevue/tabpanel';
|
||||||
@@ -147,107 +106,68 @@ const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDat
|
|||||||
const hideDialog = () => {
|
const hideDialog = () => {
|
||||||
emit('hide');
|
emit('hide');
|
||||||
};
|
};
|
||||||
console.log("props", props);
|
// 팀 생성 결과 저장
|
||||||
console.log("eventModel", eventModel.value);
|
|
||||||
console.log("참가자", JSON.stringify(eventModel.value.participants, null, 2));
|
|
||||||
// 팀 생성 관련 상태
|
|
||||||
const teamOption = ref('byMemberCount');
|
|
||||||
const teamOptionValue = ref(2);
|
|
||||||
const teams = ref([]);
|
const teams = ref([]);
|
||||||
|
|
||||||
const canGenerateTeams = computed(() => {
|
// 이벤트 상세 다이얼로그가 열릴 때마다 팀 목록 불러오기
|
||||||
const n = eventModel.value.participants?.length || 0;
|
watch(() => eventModel.value.id, async (eventId) => {
|
||||||
if (n < 2) return false;
|
if (eventId) {
|
||||||
if (teamOption.value === 'byMemberCount') {
|
const latestTeams = await teamService.getTeams(eventId);
|
||||||
return teamOptionValue.value >= 2 && teamOptionValue.value <= n;
|
teams.value = latestTeams;
|
||||||
} else {
|
|
||||||
return teamOptionValue.value >= 2 && teamOptionValue.value <= n;
|
|
||||||
}
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 참가확정 참가자 목록을 계산하는 computed 속성
|
||||||
|
const confirmedParticipants = computed(() => {
|
||||||
|
const arr = eventModel.value.EventParticipants?.filter(p => p.status === '참가확정') || [];
|
||||||
|
console.log('[상위] 참가확정:', arr);
|
||||||
|
return arr;
|
||||||
});
|
});
|
||||||
|
|
||||||
function shuffle(array) {
|
// 참가확정 인원 수
|
||||||
// Fisher-Yates shuffle
|
const confirmedCount = computed(() => confirmedParticipants.value.length);
|
||||||
let arr = array.slice();
|
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
// 팀 배정 저장 핸들러
|
||||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
import teamService from '@/services/teamService';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
async function handleSaveTeams(savedTeams) {
|
||||||
|
const eventId = eventModel.value.id;
|
||||||
|
try {
|
||||||
|
await teamService.saveTeams(eventId, savedTeams);
|
||||||
|
toast.add({ severity: 'success', summary: '저장 완료', detail: '팀 배정이 저장되었습니다.', life: 2000 });
|
||||||
|
// 저장 후 최신 팀 목록 불러오기
|
||||||
|
const latestTeams = await teamService.getTeams(eventId);
|
||||||
|
teams.value = latestTeams;
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: '저장 실패', detail: '팀 저장 중 오류가 발생했습니다.', life: 3000 });
|
||||||
}
|
}
|
||||||
return arr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateTeams() {
|
// 참가자 상태 변경 처리 함수
|
||||||
const participants = (eventModel.value.participants || []).map(p => ({ id: p.id, name: p.name }));
|
function handleParticipantUpdated(data) {
|
||||||
const shuffled = shuffle(participants);
|
// 부모 컴포넌트에 이벤트 전달
|
||||||
let result = [];
|
emit('participant-updated', data);
|
||||||
if (teamOption.value === 'byMemberCount') {
|
|
||||||
const size = teamOptionValue.value;
|
// 전체 참가자 목록이 있는 경우 직접 업데이트
|
||||||
const teamCount = Math.ceil(shuffled.length / size);
|
if (data.allParticipants) {
|
||||||
for (let i = 0; i < teamCount; i++) {
|
// EventParticipants 속성을 완전히 대체
|
||||||
result.push({ members: [], handicap: 0, _selectedMemberId: null });
|
eventModel.value.EventParticipants = [...data.allParticipants];
|
||||||
}
|
|
||||||
shuffled.forEach((member, idx) => {
|
|
||||||
result[Math.floor(idx / size)].members.push(member);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const teamCount = teamOptionValue.value;
|
|
||||||
for (let i = 0; i < teamCount; i++) {
|
|
||||||
result.push({ members: [], handicap: 0, _selectedMemberId: null });
|
|
||||||
}
|
|
||||||
shuffled.forEach((member, idx) => {
|
|
||||||
result[idx % teamCount].members.push(member);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
teams.value = result;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 팀별로 아직 배정되지 않은 참가자만 선택지로 제공
|
|
||||||
function availableMembersForTeam(teamIdx) {
|
|
||||||
const usedIds = new Set();
|
|
||||||
teams.value.forEach((team, idx) => {
|
|
||||||
if (idx === teamIdx) return;
|
|
||||||
team.members.forEach(m => usedIds.add(m.id));
|
|
||||||
});
|
|
||||||
// 팀 내 중복도 막기 위해 현재 팀 내 id도 추가
|
|
||||||
teams.value[teamIdx]?.members.forEach((m, i, arr) => {
|
|
||||||
if (arr.findIndex(x => x.id === m.id) !== i) usedIds.add(m.id);
|
|
||||||
});
|
|
||||||
return (eventModel.value.participants || [])
|
|
||||||
.filter(p => p.id && p.Member && p.Member.name &&
|
|
||||||
!usedIds.has(p.id) &&
|
|
||||||
!teams.value[teamIdx].members.some(m => m.id === p.id)
|
|
||||||
)
|
|
||||||
.map(p => ({ id: p.id, name: p.Member.name }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMemberToTeam(teamIdx) {
|
|
||||||
const team = teams.value[teamIdx];
|
|
||||||
const memberId = team._selectedMemberId;
|
|
||||||
if (!memberId) return;
|
|
||||||
const participant = (eventModel.value.participants || []).find(p => p.id === memberId);
|
|
||||||
if (participant && !team.members.some(m => m.id === participant.id)) {
|
|
||||||
team.members.push({ id: participant.id, name: participant.Member?.name });
|
|
||||||
}
|
|
||||||
team._selectedMemberId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemberAverage(member) {
|
|
||||||
// 참가자 정보에서 Member.average 필드 사용
|
|
||||||
if (!member.id) return null;
|
|
||||||
const p = (eventModel.value.participants || []).find(x => x.id === member.id);
|
|
||||||
if (!p) return null;
|
|
||||||
if (p.Member && typeof p.Member.average === 'number') return p.Member.average;
|
|
||||||
if (typeof p.average === 'number') return p.average; // 혹시 백엔드에서 이미 넣어주는 경우도 대비
|
|
||||||
if (typeof p.averageScore === 'number') return p.averageScore;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTeamAverage(team) {
|
|
||||||
const avgs = team.members.map(getMemberAverage).filter(v => typeof v === 'number');
|
|
||||||
if (!avgs.length) return 0;
|
|
||||||
return avgs.reduce((a, b) => a + b, 0) / avgs.length;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.mini-inputnumber .p-inputnumber-input) {
|
||||||
|
width: 60px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.team-generation-section {
|
.team-generation-section {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
|||||||
@@ -238,7 +238,15 @@ const saveParticipant = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 참가자 데이터 다시 가져오기
|
||||||
await fetchParticipants();
|
await fetchParticipants();
|
||||||
|
|
||||||
|
// 참가자 변경 이벤트 발생 - 변경된 참가자와 전체 참가자 목록 함께 전달
|
||||||
|
emit('participant-updated', {
|
||||||
|
updatedParticipant: editingParticipant.value,
|
||||||
|
allParticipants: participants.value
|
||||||
|
});
|
||||||
|
|
||||||
hideDialog();
|
hideDialog();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('참가자 저장 오류:', error.message);
|
console.error('참가자 저장 오류:', error.message);
|
||||||
|
|||||||
@@ -14,11 +14,6 @@
|
|||||||
{{ slotProps.data.participant?.name }}
|
{{ slotProps.data.participant?.name }}
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</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">
|
<template v-for="i in maxGames" :key="i">
|
||||||
<Column :field="'games.' + (i-1) + '.score'" :header="i + '게임'" sortable>
|
<Column :field="'games.' + (i-1) + '.score'" :header="i + '게임'" sortable>
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
@@ -65,17 +60,6 @@
|
|||||||
<small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small>
|
<small v-if="submitted && !editingScore.participantId" class="p-error">참가자를 선택해주세요.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="teamNumber">팀</label>
|
|
||||||
<InputNumber
|
|
||||||
id="teamNumber"
|
|
||||||
v-model="editingScore.teamNumber"
|
|
||||||
:min="1"
|
|
||||||
:max="10"
|
|
||||||
placeholder="팀 번호"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<template v-for="game in editingScore.games" :key="game.gameNumber">
|
<template v-for="game in editingScore.games" :key="game.gameNumber">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<template>
|
||||||
|
<div class="team-generation-section">
|
||||||
|
<div class="flex align-items-center gap-2 mb-3">
|
||||||
|
<label>
|
||||||
|
<input type="radio" value="byMemberCount" v-model="teamOption" /> 팀 인원수로 나누기
|
||||||
|
</label>
|
||||||
|
<InputNumber v-if="teamOption==='byMemberCount'" v-model="teamOptionValue" :min="2" :max="participants.length" placeholder="팀 인원수" class="mini-inputnumber" />
|
||||||
|
<label class="ml-4">
|
||||||
|
<input type="radio" value="byTeamCount" v-model="teamOption" /> 팀 개수로 나누기
|
||||||
|
</label>
|
||||||
|
<InputNumber v-if="teamOption==='byTeamCount'" v-model="teamOptionValue" :min="2" :max="participants.length" placeholder="팀 개수" class="mini-inputnumber" />
|
||||||
|
<Button label="팀 생성" icon="pi pi-users" class="ml-3" :disabled="!canGenerateTeams" @click="generateTeams" />
|
||||||
|
<Button label="팀 배정 저장" icon="pi pi-save" :disabled="teams.length === 0" @click="saveTeams" />
|
||||||
|
</div>
|
||||||
|
<!-- 미배정 인원 표시: 팀 리스트 위에 항상 노출 -->
|
||||||
|
<div class="mt-3 mb-4">
|
||||||
|
<strong>미배정 인원:</strong>
|
||||||
|
<span v-if="unassignedMembers.length === 0">없음</span>
|
||||||
|
<span v-else>
|
||||||
|
<span v-for="member in unassignedMembers" :key="member.id" class="badge bg-gray-200 text-gray-800 mr-2">{{ member.name }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="teams.length > 0">
|
||||||
|
<div v-for="(team, idx) in teams" :key="idx" class="team-box mb-3 p-3">
|
||||||
|
<div class="flex align-items-center gap-2 mb-2">
|
||||||
|
<strong>팀 {{ idx + 1 }}</strong>
|
||||||
|
<span class="ml-2">(인원: {{ team.members.length }}명)</span>
|
||||||
|
<span class="ml-3">팀 에버리지: <span class="text-primary">{{ getTeamAverage(team).toFixed(1) }}</span></span>
|
||||||
|
<span class="ml-3">핸디캡:</span>
|
||||||
|
<InputNumber v-model="team.handicap" style="width:80px;" />
|
||||||
|
</div>
|
||||||
|
<!-- 팀 멤버 드래그&드롭 정렬: ul로 감싸고, 각 아이템은 li로 시멘틱하게 구성 -->
|
||||||
|
<ul class="pl-3">
|
||||||
|
<draggable
|
||||||
|
v-model="team.members"
|
||||||
|
:group="'team-members-' + idx"
|
||||||
|
item-key="id"
|
||||||
|
handle=".drag-handle"
|
||||||
|
@end="updateMemberOrder(idx)"
|
||||||
|
>
|
||||||
|
<template #item="{ element: member, index: mIdx }">
|
||||||
|
<li class="flex align-items-center gap-2 mb-1 draggable-item">
|
||||||
|
<span class="drag-handle" style="cursor:grab; color:#888"><i class="pi pi-bars"></i></span>
|
||||||
|
<span class="badge bg-primary text-white mr-1">{{ mIdx + 1 }}</span>
|
||||||
|
<span>{{ member.name }}</span>
|
||||||
|
<span v-if="getMemberAverage(member) !== null" class="text-xs text-gray-700 ml-1">({{ getMemberAverage(member) }})</span>
|
||||||
|
<Button icon="pi pi-times" class="p-button-text p-button-danger p-0" @click="removeMemberFromTeam(idx, mIdx)" v-if="team.members.length > 1" />
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</ul>
|
||||||
|
<div class="flex align-items-center gap-2 mt-2">
|
||||||
|
<Dropdown
|
||||||
|
v-model="team._selectedMember"
|
||||||
|
:options="availableMembersForTeam(idx)"
|
||||||
|
optionLabel="name"
|
||||||
|
placeholder="팀원 선택"
|
||||||
|
style="width: 180px;"
|
||||||
|
>
|
||||||
|
<template #option="slotProps">
|
||||||
|
<span>{{ slotProps.option.name }}</span>
|
||||||
|
<span v-if="getMemberAverage(slotProps.option) !== null" class="text-xs text-gray-700 ml-1">
|
||||||
|
({{ getMemberAverage(slotProps.option) }})
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #value="slotProps">
|
||||||
|
<span v-if="slotProps.value">
|
||||||
|
{{ slotProps.value.name }}
|
||||||
|
<span v-if="getMemberAverage(slotProps.value) !== null" class="text-xs text-gray-700 ml-1">
|
||||||
|
({{ getMemberAverage(slotProps.value) }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
<Button icon="pi pi-plus" class="p-button-text p-0" :disabled="!team._selectedMember" @click="addMemberToTeam(idx)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<strong>미배정 인원:</strong>
|
||||||
|
<span v-if="unassignedMembers.length === 0">없음</span>
|
||||||
|
<span v-else>
|
||||||
|
<span v-for="member in unassignedMembers" :key="member.id" class="badge bg-gray-200 text-gray-800 mr-2">{{ member.name }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-500 mt-3">팀을 생성하려면 옵션을 선택 후 '팀 생성'을 눌러주세요.</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import draggable from 'vuedraggable'; // npm install vuedraggable@next 필요 (vue3)
|
||||||
|
|
||||||
|
import { ref, computed, watch, unref } from 'vue';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
|
||||||
|
// 참가자 에버리지 구하기
|
||||||
|
function getMemberAverage(member) {
|
||||||
|
if (!member.id) return null;
|
||||||
|
// 원본 참가자 객체에서 id로 찾아서 평균값 추출
|
||||||
|
const arr = unref(props.participants);
|
||||||
|
const p = arr.find(x => x.id === member.id);
|
||||||
|
if (!p) return null;
|
||||||
|
if (p.Member && typeof p.Member.average === 'number') return p.Member.average;
|
||||||
|
if (typeof p.average === 'number') return p.average;
|
||||||
|
if (typeof p.averageScore === 'number') return p.averageScore;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 팀 에버리지(평균)
|
||||||
|
function getTeamAverage(team) {
|
||||||
|
const avgs = team.members.map(getMemberAverage).filter(v => typeof v === 'number');
|
||||||
|
if (!avgs.length) return 0;
|
||||||
|
return avgs.reduce((a, b) => a + b, 0) / avgs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
participants: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['teams-generated', 'save-teams', 'update:teams']);
|
||||||
|
|
||||||
|
const teamOption = ref('byMemberCount');
|
||||||
|
const teamOptionValue = ref(2);
|
||||||
|
const teams = ref(Array.isArray(props.teams) ? JSON.parse(JSON.stringify(props.teams)) : []);
|
||||||
|
|
||||||
|
// props.teams가 바뀌면 내부 상태 동기화
|
||||||
|
watch(() => props.teams, (newVal) => {
|
||||||
|
teams.value = Array.isArray(newVal) ? JSON.parse(JSON.stringify(newVal)) : [];
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
// teams가 내부에서 바뀌면 부모로 emit
|
||||||
|
watch(teams, (val) => {
|
||||||
|
emit('update:teams', val);
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// 팀별로 아직 배정되지 않은 참가자만 선택지로 제공
|
||||||
|
function availableMembersForTeam(teamIdx) {
|
||||||
|
const arr = unref(props.participants);
|
||||||
|
// 이미 다른 팀에 배정된 인원은 제외
|
||||||
|
const assignedIds = new Set();
|
||||||
|
teams.value.forEach((team, idx) => {
|
||||||
|
if (idx === teamIdx) return;
|
||||||
|
team.members.forEach(m => assignedIds.add(m.id));
|
||||||
|
});
|
||||||
|
// 현재 팀 내 중복도 막기 위해 현재 팀 내 id도 추가
|
||||||
|
teams.value[teamIdx]?.members.forEach((m, i, arr) => {
|
||||||
|
if (arr.findIndex(x => x.id === m.id) !== i) assignedIds.add(m.id);
|
||||||
|
});
|
||||||
|
return arr.filter(p => !assignedIds.has(p.id) && !teams.value[teamIdx].members.some(m => m.id === p.id)).map(p => ({ id: p.id, name: p.Member?.name || p.name || `참가자 ${p.id}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMemberToTeam(teamIdx) {
|
||||||
|
const team = teams.value[teamIdx];
|
||||||
|
const member = team._selectedMember;
|
||||||
|
if (!member) return;
|
||||||
|
// 구조 통일: 항상 {id, name}만 저장
|
||||||
|
if (!team.members.some(m => m.id === member.id)) {
|
||||||
|
team.members.push({ id: member.id, name: member.name });
|
||||||
|
}
|
||||||
|
team._selectedMember = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMemberFromTeam(teamIdx, memberIdx) {
|
||||||
|
teams.value[teamIdx].members.splice(memberIdx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미배정 인원 계산
|
||||||
|
const unassignedMembers = computed(() => {
|
||||||
|
const arr = unref(props.participants);
|
||||||
|
const assignedIds = new Set();
|
||||||
|
teams.value.forEach(team => {
|
||||||
|
team.members.forEach(m => assignedIds.add(String(m.id)));
|
||||||
|
});
|
||||||
|
// name 보정: 항상 name 필드가 있도록!
|
||||||
|
return arr
|
||||||
|
.filter(p => !assignedIds.has(String(p.id)))
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.Member?.name || p.name || `참가자 ${p.id}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 팀 생성/편집 다이얼로그 상태
|
||||||
|
const editingTeam = ref({
|
||||||
|
teamNumber: null,
|
||||||
|
handicap: 0,
|
||||||
|
members: []
|
||||||
|
});
|
||||||
|
const teamDialog = ref(false);
|
||||||
|
const submitted = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// 신규 팀 생성 다이얼로그 열기
|
||||||
|
function openNewTeamDialog() {
|
||||||
|
editingTeam.value = {
|
||||||
|
teamNumber: null,
|
||||||
|
handicap: 0,
|
||||||
|
members: []
|
||||||
|
};
|
||||||
|
submitted.value = false;
|
||||||
|
teamDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 팀 편집 다이얼로그 열기
|
||||||
|
function editTeam(team) {
|
||||||
|
editingTeam.value = {
|
||||||
|
teamNumber: team.teamNumber,
|
||||||
|
handicap: team.handicap,
|
||||||
|
members: [...team.members]
|
||||||
|
};
|
||||||
|
submitted.value = false;
|
||||||
|
teamDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 팀 저장(생성/수정)
|
||||||
|
async function saveTeam() {
|
||||||
|
submitted.value = true;
|
||||||
|
if (!editingTeam.value.teamNumber || editingTeam.value.members.length === 0) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// 신규 생성 또는 기존 수정 구분(여기서는 teamNumber 기준)
|
||||||
|
const idx = teams.value.findIndex(t => t.teamNumber === editingTeam.value.teamNumber);
|
||||||
|
if (idx >= 0) {
|
||||||
|
teams.value[idx] = { ...editingTeam.value };
|
||||||
|
} else {
|
||||||
|
teams.value.push({ ...editingTeam.value });
|
||||||
|
}
|
||||||
|
teamDialog.value = false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여러 팀 저장(상위로 emit)
|
||||||
|
function saveTeams() {
|
||||||
|
// 저장 시 members: [{id, order}], teamNumber: 1부터 순서대로 부여
|
||||||
|
const payload = teams.value.map((team, idx) => ({
|
||||||
|
...team,
|
||||||
|
teamNumber: idx + 1,
|
||||||
|
members: team.members.map((m, mIdx) => ({ id: m.id, order: mIdx + 1 }))
|
||||||
|
}));
|
||||||
|
emit('save-teams', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드래그 후 멤버 순서(order) 갱신
|
||||||
|
function updateMemberOrder(teamIdx) {
|
||||||
|
teams.value[teamIdx].members.forEach((m, i) => {
|
||||||
|
m.order = i + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGenerateTeams = computed(() => {
|
||||||
|
const arr = unref(props.participants);
|
||||||
|
const n = arr.length;
|
||||||
|
if (n < 2) return false;
|
||||||
|
return teamOptionValue.value >= 2 && teamOptionValue.value <= n;
|
||||||
|
});
|
||||||
|
|
||||||
|
function shuffle(array) {
|
||||||
|
let arr = array.slice();
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTeams() {
|
||||||
|
const arr = unref(props.participants);
|
||||||
|
const participants = arr.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.Member?.name || p.name || `참가자 ${p.id}`
|
||||||
|
}));
|
||||||
|
const shuffled = shuffle(participants);
|
||||||
|
let result = [];
|
||||||
|
if (teamOption.value === 'byMemberCount') {
|
||||||
|
const size = teamOptionValue.value;
|
||||||
|
const teamCount = Math.ceil(shuffled.length / size);
|
||||||
|
for (let i = 0; i < teamCount; i++) {
|
||||||
|
result.push({ members: [] });
|
||||||
|
}
|
||||||
|
shuffled.forEach((member, idx) => {
|
||||||
|
result[Math.floor(idx / size)].members.push(member);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const teamCount = teamOptionValue.value;
|
||||||
|
for (let i = 0; i < teamCount; i++) {
|
||||||
|
result.push({ members: [] });
|
||||||
|
}
|
||||||
|
shuffled.forEach((member, idx) => {
|
||||||
|
result[idx % teamCount].members.push(member);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
teams.value = result;
|
||||||
|
emit('teams-generated', result);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.participants, () => {
|
||||||
|
teams.value = [];
|
||||||
|
}, { deep: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.team-generation-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.team-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.draggable-item {
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.draggable-item.dragging {
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.8em;
|
||||||
|
padding: 0.2em 0.6em;
|
||||||
|
font-size: 90%;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0.7em;
|
||||||
|
background: #1976d2;
|
||||||
|
color: #fff;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import apiClient from './api';
|
||||||
|
|
||||||
|
const teamService = {
|
||||||
|
/**
|
||||||
|
* 이벤트별 팀 목록 조회
|
||||||
|
* @param {number|string} eventId
|
||||||
|
* @returns {Promise<Array>} 팀 배열
|
||||||
|
*/
|
||||||
|
async getTeams(eventId) {
|
||||||
|
const response = await apiClient.get(`/api/club/events/${eventId}/teams`);
|
||||||
|
return response.data.teams;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 이벤트별 팀 저장
|
||||||
|
* @param {number|string} eventId
|
||||||
|
* @param {Array} teams - [{ teamNumber, handicap, members: [id, ...] }]
|
||||||
|
*/
|
||||||
|
async saveTeams(eventId, teams) {
|
||||||
|
const payload = teams.map((team, idx) => ({
|
||||||
|
teamNumber: team.teamNumber ?? idx + 1,
|
||||||
|
handicap: team.handicap || 0,
|
||||||
|
members: team.members
|
||||||
|
}));
|
||||||
|
return apiClient.post(`/api/club/events/${eventId}/teams`, { teams: payload });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 추후: 팀 불러오기, 수정, 삭제 등 추가 가능
|
||||||
|
};
|
||||||
|
|
||||||
|
export default teamService;
|
||||||
@@ -164,7 +164,6 @@ const fetchEvents = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await eventService.getEvents(clubId.value);
|
const response = await eventService.getEvents(clubId.value);
|
||||||
console.log('eventData', response);
|
|
||||||
events.value = response;
|
events.value = response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({
|
toast.add({
|
||||||
|
|||||||
Reference in New Issue
Block a user