diff --git a/backend/app.js b/backend/app.js index a947843..e084ba7 100644 --- a/backend/app.js +++ b/backend/app.js @@ -48,6 +48,15 @@ io.on('connection', (socket) => { // 전역 Socket.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() .then(() => { diff --git a/backend/controllers/clubController.js b/backend/controllers/clubController.js index 57cece3..d03b69c 100644 --- a/backend/controllers/clubController.js +++ b/backend/controllers/clubController.js @@ -16,6 +16,48 @@ const isAdmin = (user) => { 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) => { try { @@ -57,7 +99,7 @@ exports.getAllClubs = async (req, res) => { exports.getClubById = async (req, res) => { try { const { clubId } = req.body; - console.log(`clubId: ${clubId}`); + // console.log(`clubId: ${clubId}`); if (!clubId) { return res.status(400).json({ message: "유효하지 않은 클럽 ID입니다." }); } diff --git a/backend/controllers/eventController.js b/backend/controllers/eventController.js index 5a476c6..f2f62df 100644 --- a/backend/controllers/eventController.js +++ b/backend/controllers/eventController.js @@ -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 { sequelize } = require('../models'); @@ -78,7 +78,7 @@ exports.getEventById = async (req, res) => { }, { model: EventParticipant, - attributes: ['id', 'status', 'paymentStatus', 'teamNumber'], + attributes: ['id', 'status', 'paymentStatus'], where: { status: { [Op.ne]: '취소' } }, required: false, include: [ @@ -538,7 +538,6 @@ exports.updateEventScore = async (req, res) => { const newScore = await EventScore.create({ eventId, participantId: existingScore.participantId, - teamNumber: existingScore.teamNumber, gameNumber, score, handicap, @@ -1004,9 +1003,8 @@ exports.addEventParticipant = async (req, res) => { const { clubId, memberId, - teamNumber, - paymentStatus, - paymentAmount + status, + paymentStatus } = req.body; const eventId = req.params.id; if (!eventId || !clubId || !memberId) { @@ -1071,12 +1069,10 @@ exports.addEventParticipant = async (req, res) => { const participant = await EventParticipant.create({ eventId, memberId, - teamNumber: teamNumber || 1, - status: req.body.status || '참가예정', - attendance: false, - paymentStatus: req.body.paymentStatus || '미납', - paymentAmount: paymentAmount || event.entryFee || 0, - registeredAt: new Date() + status: status || '참가예정', + paymentStatus: paymentStatus || '미납', + createdAt: new Date(), + updatedAt: 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) => { try { diff --git a/backend/middleware/requireFeature.js b/backend/middleware/requireFeature.js index 4e321d7..7e300db 100644 --- a/backend/middleware/requireFeature.js +++ b/backend/middleware/requireFeature.js @@ -4,7 +4,8 @@ const { clubHasFeature } = require('../utils/featureAccess'); function requireFeature(featureCode) { return async function(req, res, next) { 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) { return res.status(400).json({ error: '클럽 정보가 필요합니다.' }); } diff --git a/backend/models/EventTeam.js b/backend/models/EventTeam.js index 86c2ec2..85e4b0f 100644 --- a/backend/models/EventTeam.js +++ b/backend/models/EventTeam.js @@ -31,6 +31,7 @@ const EventTeam = sequelize.define('EventTeam', { EventTeam.associate = (models) => { EventTeam.belongsTo(models.Event, { foreignKey: 'eventId' }); EventTeam.hasMany(models.EventParticipant, { foreignKey: 'teamId' }); + EventTeam.hasMany(models.EventTeamMember, { foreignKey: 'eventTeamId' }); }; module.exports = EventTeam; \ No newline at end of file diff --git a/backend/models/EventTeamMember.js b/backend/models/EventTeamMember.js new file mode 100644 index 0000000..b16fded --- /dev/null +++ b/backend/models/EventTeamMember.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index bf32edc..717116f 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -15,6 +15,7 @@ const SubscriptionPlan = require('./SubscriptionPlan'); const SubscriptionPlanFeature = require('./SubscriptionPlanFeature'); const ClubSubscription = require('./ClubSubscription'); const ActivityLog = require('./activityLog'); +const EventTeamMember = require('./EventTeamMember'); // 모델 동기화 함수 const syncModels = async () => { @@ -50,6 +51,7 @@ const db = { SubscriptionPlanFeature, ClubSubscription, ActivityLog, + EventTeamMember, }; Object.values(db).forEach(model => { if (model.associate) model.associate(db); @@ -72,5 +74,6 @@ module.exports = { SubscriptionPlanFeature, ClubSubscription, ActivityLog, + EventTeamMember, syncModels }; diff --git a/backend/package-lock.json b/backend/package-lock.json index d9308ed..4c9931c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-session": "^1.18.1", "express-validator": "^7.2.1", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", @@ -1204,6 +1205,40 @@ "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": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", @@ -2721,6 +2756,15 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3474,6 +3518,18 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 9773c68..07a8862 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "express-session": "^1.18.1", "express-validator": "^7.2.1", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", diff --git a/backend/routes/clubRoutes.js b/backend/routes/clubRoutes.js index 2a57178..3a5ac6e 100644 --- a/backend/routes/clubRoutes.js +++ b/backend/routes/clubRoutes.js @@ -5,6 +5,8 @@ const clubController = require('../controllers/clubController'); const { authenticateJWT } = require('../middleware/authMiddleware'); const requireFeature = require('../middleware/requireFeature'); +// 클럽 선택 +router.post('/select-club', authenticateJWT, clubController.selectClub); // 모든 클럽 조회 router.get('/', clubController.getAllClubs); diff --git a/backend/routes/eventRoutes.js b/backend/routes/eventRoutes.js index c2430be..9de3cfc 100644 --- a/backend/routes/eventRoutes.js +++ b/backend/routes/eventRoutes.js @@ -17,6 +17,10 @@ router.post('/:id/participants', authenticateJWT, requireFeature('event_manageme router.put('/:id/participants', authenticateJWT, requireFeature('event_management'), eventController.updateEventParticipant); 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.post('/:id/scores', authenticateJWT, requireFeature('event_management'), eventController.addEventScore); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ddb03c4..0da3eb8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "vue": "^3.5.13", "vue-chartjs": "^5.3.2", "vue-router": "^4.5.0", + "vuedraggable": "^4.1.0", "yup": "^1.3.3" }, "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3435,6 +3442,18 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7516681..e1a9c91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "vue": "^3.5.13", "vue-chartjs": "^5.3.2", "vue-router": "^4.5.0", + "vuedraggable": "^4.1.0", "yup": "^1.3.3" }, "devDependencies": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c2d123f..ae5c245 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -171,9 +171,9 @@ const fetchClubs = async () => { })); // 첫 번째 클럽의 memberType을 localStorage에 저장 - if (clubs.value.length > 0) { - localStorage.setItem('userClubRole', clubs.value[0].memberType || ''); - localStorage.setItem('clubId', clubs.value[0].id); + if (clubs.value.length > 0 && !selectedClubId.value) { + selectedClubId.value = clubs.value[0].id; + await setSelectedClub(clubs.value[0]); } } catch (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 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 setSelectedClub(selectedClub); } await fetchClubInfo(); @@ -310,6 +325,11 @@ const handleUserLoggedIn = async () => { // 컴포넌트 마운트 시 데이터 로드 onMounted(async () => { await loadUserData(); + const clubId = localStorage.getItem('clubId'); + if (clubId) { + // 서버 세션에 clubId를 동기화 + apiClient.post('/api/club/select-club', { clubId }); + } if (isLoggedIn.value) { await fetchClubs(); await fetchClubInfo(); diff --git a/frontend/src/components/event/EventDetailsDialog.vue b/frontend/src/components/event/EventDetailsDialog.vue index 9300fd1..562a043 100644 --- a/frontend/src/components/event/EventDetailsDialog.vue +++ b/frontend/src/components/event/EventDetailsDialog.vue @@ -45,7 +45,7 @@ @@ -60,49 +60,7 @@ -
-
- - - - -
-
-
-
- 팀 {{ idx + 1 }} - (인원: {{ team.members.length }}명) - 팀 에버리지: {{ getTeamAverage(team).toFixed(1) }} - 핸디캡: - -
-
    -
  • - - ({{ getMemberAverage(member) }}) -
  • -
-
- -
-
-
-
팀을 생성하려면 옵션을 선택 후 '팀 생성'을 눌러주세요.
-
+
@@ -115,7 +73,8 @@ + + diff --git a/frontend/src/services/teamService.js b/frontend/src/services/teamService.js new file mode 100644 index 0000000..f0fcf0c --- /dev/null +++ b/frontend/src/services/teamService.js @@ -0,0 +1,30 @@ +import apiClient from './api'; + +const teamService = { + /** + * 이벤트별 팀 목록 조회 + * @param {number|string} eventId + * @returns {Promise} 팀 배열 + */ + 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; diff --git a/frontend/src/views/club/EventManagement.vue b/frontend/src/views/club/EventManagement.vue index 6396516..8ff69b4 100644 --- a/frontend/src/views/club/EventManagement.vue +++ b/frontend/src/views/club/EventManagement.vue @@ -164,7 +164,6 @@ const fetchEvents = async () => { loading.value = true; try { const response = await eventService.getEvents(clubId.value); - console.log('eventData', response); events.value = response; } catch (error) { toast.add({