public 기능 완료

This commit is contained in:
2025-05-07 21:02:50 +09:00
parent 283c8a7f4a
commit 1684fbf5d1
28 changed files with 2522 additions and 194 deletions
+2
View File
@@ -7,6 +7,7 @@ const socketIo = require('socket.io');
const adminRoutes = require('./routes/adminRoutes');
const authRoutes = require('./routes/authRoutes');
const eventRoutes = require('./routes/eventRoutes');
const publicEventRoutes = require('./routes/publicEventRoutes');
const clubRoutes = require('./routes/clubRoutes');
const userRoutes = require('./routes/userRoutes');
const notificationRoutes = require('./routes/notificationRoutes');
@@ -75,6 +76,7 @@ app.use('/api/auth', authRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/club', clubRoutes);
app.use('/api/club/events', eventRoutes);
app.use('/api/public', publicEventRoutes);
app.use('/api/features', featureRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/users', userRoutes);
+1 -1
View File
@@ -18,7 +18,7 @@ const sequelize = new Sequelize(
host: process.env.DB_HOST,
port: process.env.DB_PORT,
dialect: 'mysql',
logging: process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화
logging: false, //process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화
pool: {
max: 5,
min: 0,
+267 -4
View File
@@ -141,6 +141,22 @@ exports.createEvent = async (req, res) => {
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
}
const endDateVal = endDate ? new Date(endDate) : null;
// 랜덤 해시 생성 함수
const generateRandomHash = async () => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let hash;
let exists = true;
while (exists) {
hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
// 중복 체크
exists = await Event.findOne({ where: { publicHash: hash } });
}
return hash;
};
const publicHash = await generateRandomHash();
const accessPassword = req.body.accessPassword || null;
const event = await Event.create({
clubId,
title,
@@ -153,7 +169,9 @@ exports.createEvent = async (req, res) => {
entryFee: entryFee || 0,
gameCount: gameCount || 3,
laneCount: laneCount || 1,
status: status || '활성'
status: status || '활성',
publicHash,
accessPassword
});
const createdEvent = await Event.findOne({
@@ -180,6 +198,248 @@ exports.createEvent = async (req, res) => {
}
};
// 공개 이벤트 조회 및 비밀번호 인증
exports.getEventByPublicHash = async (req, res) => {
const { publicHash } = req.params;
const { password } = req.body || {};
try {
// 1. 이벤트 조회
const event = await Event.findOne({
where: { publicHash },
include: [{
model: Club,
attributes: ['id', 'name']
}]
});
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
// 2. 비밀번호 필요 여부 및 인증
if (event.accessPassword) {
if (!password) return res.status(401).json({ needPassword: true, message: '비밀번호 필요' });
if (event.accessPassword !== password) return res.status(401).json({ needPassword: true, message: '비밀번호 불일치' });
}
// 3. 전체 회원 명단 추출 (clubId 사용, clubId는 응답에 포함 X)
const allMembers = await Member.findAll({
where: { clubId: event.clubId },
attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average']
});
// 4. 참가자 명단 추출 (참가자 테이블 + Member 조인)
const participants = await EventParticipant.findAll({
where: { eventId: event.id, status: { [Op.ne]: '취소' } },
include: [
{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] }
]
});
// 5. 참가 신청 가능 명단(아직 참가하지 않은 회원)
// Sequelize 인스턴스에서 dataValues 추출
const allMemberObjs = allMembers.map(m => m.dataValues);
// '취소'가 아닌 status가 하나라도 있으면 참가자로 간주 (중복 제거)
const participantMemberIds = [
...new Set(
participants
.filter(p => p.dataValues.status !== '취소')
.map(p => parseInt(p.dataValues.memberId))
)
];
const availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId)));
// 6. 팀 정보 등 추가 조회 (기존 코드 유지)
const teams = await EventTeam.findAll({
where: { eventId: event.id },
order: [['teamNumber', 'ASC']],
include: [
{
model: EventTeamMember,
include: [
{
model: EventParticipant,
include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }]
}
]
}
]
});
// 점수
const scores = await EventScore.findAll({ where: { eventId: event.id } });
// 팀별 그룹핑
let teamList = [];
if (teams.length > 0) {
teamList = teams.map(team => ({
teamId: team.id,
teamNumber: team.teamNumber,
handicap: team.handicap,
members: (team.EventTeamMembers || []).map(tm => {
const participant = tm.EventParticipant;
const member = participant?.Member;
const scoreObj = scores.find(s => s.participantId === participant?.id);
return {
participantId: participant?.id,
memberId: member?.memberId,
name: member?.name || `참가자 ${participant?.id}`,
gender: member?.gender,
memberType: member?.memberType,
average: member?.average || 0,
handicap: member?.handicap || 0,
status: participant?.status,
paymentStatus: participant?.paymentStatus,
comment: participant?.comment,
isGuest: member?.memberType === '게스트',
score: scoreObj ? scoreObj.score : null
};
})
}));
}
// 참가자 리스트(팀 없는 경우)
let participantList = [];
participantList = participants.map(p => {
const member = p.Member;
const scoreObj = scores.find(s => s.participantId === p.id);
return {
participantId: p.id,
memberId: p.memberId,
name: member?.name || `참가자 ${p.id}`,
gender: member?.gender,
memberType: member?.memberType,
handicap: member?.handicap || 0,
average: member?.average || 0,
status: p.status,
paymentStatus: p.paymentStatus,
comment: p.comment,
isGuest: member?.memberType === '게스트',
score: scoreObj ? scoreObj.score : null
};
});
// 반환 데이터
res.json({
clubName: event.Club.name,
event: {
id: event.id,
title: event.title,
description: event.description,
eventType: event.eventType,
startDate: event.startDate,
endDate: event.endDate,
location: event.location,
gameCount: event.gameCount,
registrationDeadline: event.registrationDeadline,
maxParticipants: event.maxParticipants,
participantFee: event.participantFee,
status: event.status
},
teams: teamList,
participants: participantList,
availableMembers,
needPassword: !!event.accessPassword
});
} catch (e) {
console.error('공개 이벤트 조회 오류:', e);
res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' });
}
};
// 공개 URL(publicHash) 재생성
exports.regeneratePublicHash = async (req, res) => {
const { id } = req.params;
try {
const event = await Event.findOne({ where: { id } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
// 랜덤 해시 생성 (중복 없는 값)
const generateRandomHash = async () => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let hash;
let exists = true;
while (exists) {
hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
exists = await Event.findOne({ where: { publicHash: hash } });
}
return hash;
};
const newHash = await generateRandomHash();
await event.update({ publicHash: newHash });
res.json({ publicHash: newHash });
} catch (e) {
console.error('공개 URL 재생성 오류:', e);
res.status(500).json({ message: '공개 URL 재생성 중 오류가 발생했습니다.' });
}
};
// 공개 참가자 등록/수정
exports.registerPublicParticipant = async (req, res) => {
const { publicHash } = req.params;
const { participantId, memberId, status, paymentStatus, comment } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
// 기존 참가자 있는지 검사(이름+연락처 기준)
let member = await Member.findOne({ where: { id:memberId } });
// if (!member) {
// member = await Member.create({ memberId, memberType: '일반' });
// }
let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } });
if (!participant) {
participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus });
} else {
await participant.update({ comment, status, paymentStatus });
}
res.json({ message: '참가 신청/수정이 완료되었습니다.' });
} catch (e) {
console.error('공개 참가자 등록 오류:', e);
res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다.' });
}
};
// 공개 게스트 추가
exports.addPublicGuest = async (req, res) => {
const { publicHash } = req.params;
const { name, password } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
if (event.accessPassword) {
if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' });
}
// 게스트 멤버 생성
const member = await Member.create({ name, memberType: '게스트' });
await EventParticipant.create({ eventId: event.id, memberId: member.id });
res.json({ message: '게스트가 추가되었습니다.' });
} catch (e) {
console.error('공개 게스트 추가 오류:', e);
res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' });
}
};
// 공개 점수 수정
exports.updatePublicScore = async (req, res) => {
const { publicHash } = req.params;
const { participantId, score, password } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status !== '준비' && event.status !== '활성') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' });
if (event.accessPassword) {
if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' });
}
let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId } });
if (!eventScore) {
eventScore = await EventScore.create({ eventId: event.id, participantId, score });
} else {
await eventScore.update({ score });
}
res.json({ message: '점수가 저장되었습니다.' });
} catch (e) {
console.error('공개 점수 수정 오류:', e);
res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' });
}
};
// 이벤트 정보 업데이트
exports.updateEvent = async (req, res) => {
try {
@@ -194,7 +454,9 @@ exports.updateEvent = async (req, res) => {
entryFee,
gameCount,
laneCount,
status
status,
publicHash,
accessPassword
} = req.body;
const eventId = req.body.id;
let endDate = req.body.endDate;
@@ -228,9 +490,10 @@ exports.updateEvent = async (req, res) => {
entryFee: entryFee !== undefined ? entryFee : event.entryFee,
gameCount: gameCount || event.gameCount,
laneCount: laneCount || event.laneCount,
status: status || event.status
status: status || event.status,
publicHash: publicHash || event.publicHash,
accessPassword: accessPassword || null
};
await event.update(updateData);
const updatedEvent = await Event.findOne({
+304
View File
@@ -0,0 +1,304 @@
// publicController.js
// 공개 이벤트 관련 API만 담당 (비밀번호 인증, 점수입력 등)
const { Event, EventParticipant, Member, Club, EventScore, ClubMember, EventTeam, EventTeamMember, sequelize } = require('../models');
const { Op } = require('sequelize');
const { signPublicEventToken, verifyPublicEventToken } = require('../utils/publicJwt');
// 공개 이벤트 최초 진입 (토큰 또는 비밀번호 인증)
exports.publicEventEntry = async (req, res) => {
const { publicHash } = req.params;
const { password } = req.body || {};
const auth = req.headers.authorization;
let event;
try {
event = await Event.findOne({
where: { publicHash },
include: [{ model: Club, attributes: ['id', 'name'] }]
});
if (!event) {
return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
}
if (event.status === '취소') {
return res.status(403).json({ message: '취소된 이벤트입니다.' });
}
let token = null;
let isAuthenticated = false;
// 1. 토큰 인증 우선
if (auth && auth.startsWith('Bearer ')) {
try {
const payload = verifyPublicEventToken(auth.slice(7));
if (payload.publicHash === event.publicHash) {
isAuthenticated = true;
}
} catch (e) {
// 토큰 인증 실패: isAuthenticated는 false
}
}
// 2. 토큰 인증 실패 시
if (!isAuthenticated) {
// 비밀번호가 필요한 이벤트
if (event.accessPassword) {
if (!password) {
return res.status(401).json({ message: '비밀번호가 필요합니다.' });
}
if (password !== event.accessPassword) {
return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' });
}
}
// 비밀번호가 없거나(공개 이벤트) or 비밀번호 인증 성공
isAuthenticated = true;
token = signPublicEventToken({ publicHash: event.publicHash });
}
// 3. 인증 성공 시 데이터 반환
if (isAuthenticated) {
const data = await exports.getEventByPublicHash(event, token);
return res.json(data);
}
// 4. 나머지는 모두 인증 실패
return res.status(401).json({ message: '인증이 필요합니다.' });
} catch (e) {
console.error('[publicEventEntry] 공개 이벤트 최초 진입 오류', { publicHash, error: e });
res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' });
}
};
// 공개 이벤트 조회 (내부 데이터 반환 전용)
exports.getEventByPublicHash = async (event, token = null) => {
try {
const allMembers = await Member.findAll({
where: { clubId: event.clubId },
attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average']
});
const participants = await EventParticipant.findAll({
where: { eventId: event.id, status: { [Op.ne]: '취소' } },
include: [ { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] } ]
});
const allMemberObjs = allMembers.map(m => m.dataValues);
const participantMemberIds = [
...new Set(participants.filter(p => p.dataValues.status !== '취소').map(p => parseInt(p.dataValues.memberId)))
];
// 상태별로 미참가자/게스트 추가 제한
let availableMembers = [];
if (event.status === '준비') {
availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId)));
} // 준비 아닐 땐 빈 배열(신청/게스트 추가 불가)
// 팀 정보
const teams = await EventTeam.findAll({
where: { eventId: event.id },
order: [['teamNumber', 'ASC']],
include: [{
model: EventTeamMember,
include: [{
model: EventParticipant,
where: { status: { [Op.ne]: '취소' } },
include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }]
}]
}]
});
const scores = await EventScore.findAll({ where: { eventId: event.id } });
// 참가자 id → 참가자 객체 Map
const participantMap = new Map();
participants.forEach(p => participantMap.set(p.id, p));
// 팀에 소속된 참가자 id Set
const assignedParticipantIds = new Set();
const teamList = teams.map(team => ({
teamId: team.id,
teamNumber: team.teamNumber,
handicap: team.handicap,
members: (team.EventTeamMembers || []).map(tm => {
const participant = tm.EventParticipant;
if (participant?.id) assignedParticipantIds.add(participant.id);
const member = participant?.Member;
// 각 게임별 점수 배열 생성
const scoresArr = Array(event.gameCount).fill(null);
scores.filter(s => s.participantId === participant?.id).forEach(s => {
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
scoresArr[s.gameNumber - 1] = s.score;
}
});
return {
participantId: participant?.id,
memberId: member?.memberId,
name: member?.name || `참가자 ${participant?.id}`,
gender: member?.gender,
memberType: member?.memberType,
average: member?.average || 0,
handicap: member?.handicap || 0,
status: participant?.status,
paymentStatus: participant?.paymentStatus,
comment: participant?.comment,
isGuest: member?.memberType === '게스트',
scores: scoresArr
};
})
}));
// 팀에 소속되지 않은 참가자(미배정)
const unassignedMembers = participants
.filter(p => !assignedParticipantIds.has(p.id))
.map(p => {
const member = p.Member;
// 각 게임별 점수 배열 생성
const scoresArr = Array(event.gameCount).fill(null);
scores.filter(s => s.participantId === p.id).forEach(s => {
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
scoresArr[s.gameNumber - 1] = s.score;
}
});
return {
participantId: p.id,
memberId: p.memberId,
name: member?.name || `참가자 ${p.id}`,
gender: member?.gender,
memberType: member?.memberType,
handicap: member?.handicap || 0,
average: member?.average || 0,
status: p.status,
paymentStatus: p.paymentStatus,
comment: p.comment,
isGuest: member?.memberType === '게스트',
scores: scoresArr
};
});
let participantList = participants.map(p => {
const member = p.Member;
// 각 게임별 점수 배열 생성
const scoresArr = Array(event.gameCount).fill(null);
scores.filter(s => s.participantId === p.id).forEach(s => {
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
scoresArr[s.gameNumber - 1] = s.score;
}
});
return {
participantId: p.id,
memberId: p.memberId,
name: member?.name || `참가자 ${p.id}`,
gender: member?.gender,
memberType: member?.memberType,
handicap: member?.handicap || 0,
average: member?.average || 0,
status: p.status,
paymentStatus: p.paymentStatus,
comment: p.comment,
isGuest: member?.memberType === '게스트',
scores: scoresArr
};
});
return {
clubName: event.Club.name,
event: {
id: event.id,
title: event.title,
description: event.description,
eventType: event.eventType,
startDate: event.startDate,
endDate: event.endDate,
location: event.location,
gameCount: event.gameCount,
registrationDeadline: event.registrationDeadline,
maxParticipants: event.maxParticipants,
participantFee: event.participantFee,
status: event.status
},
teams: teamList,
participants: participantList,
unassignedMembers,
availableMembers,
needPassword: !!event.accessPassword,
...(token ? { token } : {})
};
} catch (e) {
console.error('공개 이벤트 조회 오류:', e);
throw new Error('이벤트 정보를 가져오는 중 오류가 발생했습니다.');
}
};
// 공개 참가자 등록/수정
exports.registerPublicParticipant = async (req, res) => {
const { publicHash } = req.params;
const { participantId, memberId, status, paymentStatus, comment } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
let member = await Member.findOne({ where: { id: memberId } });
let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } });
if (!participant) {
participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus });
} else {
await participant.update({ comment, status, paymentStatus });
}
res.json({ message: '참가 신청/수정이 완료되었습니다.' });
} catch (e) {
console.error('공개 참가자 등록 오류:', e);
res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다.' });
}
};
// 공개 게스트 추가
exports.addPublicGuest = async (req, res) => {
const { publicHash } = req.params;
const { name, phone, gender, average } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
// publicAuth 미들웨어로 인증됨
const member = await Member.create({
name,
phone,
gender,
clubId: event.clubId,
memberType: '게스트',
average: average || 0
});
const participant = await EventParticipant.create({ eventId: event.id, memberId: member.id });
res.json({
message: '게스트가 추가되었습니다.',
data: {
memberId: member.id,
participantId: participant.id,
name: member.name,
phone: member.phone,
gender: member.gender,
memberType: member.memberType,
handicap: member.handicap,
average: member.average,
group: '미신청'
}
});
} catch (e) {
console.error('공개 게스트 추가 오류:', e);
res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' });
}
};
// 공개 점수 수정
exports.updatePublicScore = async (req, res) => {
const { publicHash } = req.params;
let { participantId, gameNumber, score, handicap, teamNumber } = req.body || {};
score = Number(score);
handicap = Number(handicap) || 0; // undefined/null이면 0
teamNumber = teamNumber ?? null;
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status !== '준비' && event.status !== '활성') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' });
// publicAuth 미들웨어로 인증됨
let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId, gameNumber } });
if (!eventScore) {
eventScore = await EventScore.create({ eventId: event.id, participantId, gameNumber, teamNumber, score, handicap, totalScore: score + handicap });
} else {
await eventScore.update({ teamNumber, score, handicap, totalScore: score + handicap });
}
res.json({ message: '점수가 저장되었습니다.' });
} catch (e) {
console.error('공개 점수 수정 오류:', e);
res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' });
}
};
+13
View File
@@ -0,0 +1,13 @@
// middleware/publicAuth.js
const { verifyPublicEventToken } = require('../utils/publicJwt');
module.exports = (req, res, next) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ message: '토큰 필요' });
try {
req.publicAuth = verifyPublicEventToken(auth.slice(7));
next();
} catch (e) {
return res.status(401).json({ message: '토큰 인증 실패' });
}
};
+10
View File
@@ -33,6 +33,16 @@ const Event = sequelize.define('Event', {
defaultValue: '정기전',
comment: '이벤트 유형'
},
publicHash: {
type: DataTypes.STRING,
allowNull: true,
comment: '공개 URL 해시'
},
accessPassword: {
type: DataTypes.STRING,
allowNull: true,
comment: '공개 URL 비밀번호'
},
startDate: {
type: DataTypes.DATE,
allowNull: false,
+5
View File
@@ -35,6 +35,11 @@ const EventParticipant = sequelize.define('EventParticipant', {
},
comment: '이벤트 팀 ID'
},
comment: {
type: DataTypes.STRING,
allowNull: true,
comment: '비고'
},
status: {
type: DataTypes.ENUM('참가예정', '참가확정', '취소'),
allowNull: false,
+1 -1
View File
@@ -39,7 +39,7 @@ const Member = sequelize.define('Member', {
comment: '성별'
},
memberType: {
type: DataTypes.ENUM('정회원', '준회원', '운영진', '모임장'),
type: DataTypes.ENUM('정회원', '준회원', '게스트', '운영진', '모임장'),
allowNull: false,
defaultValue: '준회원',
comment: '회원 유형'
+2 -2
View File
@@ -27,8 +27,8 @@ const syncModels = async () => {
// await sequelize.query('SET FOREIGN_KEY_CHECKS = 1');
// 운영 alter: true - 구조변경만 alter처리
// await sequelize.sync({ alter: true });
await sequelize.sync();
await sequelize.sync({ alter: true });
// await sequelize.sync();
} catch (error) {
console.error('모델 동기화 중 오류 발생:', error);
}
+5
View File
@@ -31,4 +31,9 @@ router.delete('/:id/participants/:participantId/scores', authenticateJWT, requir
// 사용자별 이벤트 관리
router.get('/user/events', authenticateJWT, requireFeature('event_management'), eventController.getUserEvents);
// 공개 URL(publicHash) 재생성
router.patch('/:id/publicHash', eventController.regeneratePublicHash);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const publicController = require('../controllers/publicController');
const publicAuth = require('../middleware/publicAuth');
// 공개 이벤트 최초 진입 (비밀번호 인증 또는 토큰 인증)
router.post('/:publicHash', publicController.publicEventEntry);
// 공개 참가자 등록/수정
router.post('/:publicHash/participant', publicAuth, publicController.registerPublicParticipant);
// 공개 게스트 추가
router.post('/:publicHash/guest', publicAuth, publicController.addPublicGuest);
// 공개 점수 수정
router.put('/:publicHash/score', publicAuth, publicController.updatePublicScore);
module.exports = router;
+12
View File
@@ -0,0 +1,12 @@
// utils/publicJwt.js
const jwt = require('jsonwebtoken');
const SECRET = process.env.PUBLIC_EVENT_JWT_SECRET || 'default_public_event_secret';
exports.signPublicEventToken = (payload) => {
const token = jwt.sign(payload, SECRET, { expiresIn: '2h' });
return token;
};
exports.verifyPublicEventToken = (token) => {
const decoded = jwt.verify(token, SECRET);
return decoded;
};
+119 -11
View File
@@ -13,16 +13,20 @@
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@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",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"primeicons": "^7.0.0",
"primelocale": "^2.1.2",
"primevue": "^4.3.3",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5",
"vue": "^3.5.13",
@@ -1045,6 +1049,87 @@
"dev": true,
"license": "MIT"
},
"node_modules/@primeuix/styled": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.5.1.tgz",
"integrity": "sha512-5Ftw/KSauDPClQ8F2qCyCUF7cIUEY4yLNikf0rKV7Vsb8zGYNK0dahQe7CChaR6M2Kn+NA2DSBSk76ZXqj6Uog==",
"license": "MIT",
"dependencies": {
"@primeuix/utils": "^0.5.3"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primeuix/styles": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-1.0.3.tgz",
"integrity": "sha512-yHj/Q+fosJ1736Ty5lRbpqhKa9piou+xZPPppNHUDshq0+XhrFwDGggvPGmDAJyUIM+ChM/Nj8lPY/AwTNXAkg==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.1"
}
},
"node_modules/@primeuix/themes": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-1.0.3.tgz",
"integrity": "sha512-f/1qadrv5TFMHfvtVv4Y9zjrkeDP2BO/cuzbHBO9DYxKL6YBIPT9BjKec2K4Kg8PcfGm6CAvxAvICadJSWejRw==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.1"
}
},
"node_modules/@primeuix/utils": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.5.3.tgz",
"integrity": "sha512-7SGh7734wcF1/uK6RzO6Z6CBjGQ97GDHfpyl2F1G/c7R0z9hkT/V72ypDo82AWcCS7Ta07oIjDpOCTkSVZuEGQ==",
"license": "MIT",
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primevue/core": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.3.3.tgz",
"integrity": "sha512-kSkN5oourG7eueoFPIqiNX3oDT/f0I5IRK3uOY/ytz+VzTZp5yuaCN0Nt42ZQpVXjDxMxDvUhIdaXVrjr58NhQ==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.0",
"@primeuix/utils": "^0.5.1"
},
"engines": {
"node": ">=12.11.0"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@primevue/icons": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.3.3.tgz",
"integrity": "sha512-ouQaxHyeFB6MSfEGGbjaK5Qv9efS1xZGetZoU5jcPm090MSYLFtroP1CuK3lZZAQals06TZ6T6qcoNukSHpK5w==",
"license": "MIT",
"dependencies": {
"@primeuix/utils": "^0.5.1",
"@primevue/core": "4.3.3"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primevue/themes": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@primevue/themes/-/themes-4.3.3.tgz",
"integrity": "sha512-LiYlSXsHeA8DFm8+yGyiDFQc3SEQwHcESTN1/rV+rrZ+UPuPisHY9fNIGRFQKA5XUQPDTQDQjtwYGx25Jikwhg==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.0",
"@primeuix/themes": "^1.0.0"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
@@ -2561,6 +2646,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2844,18 +2935,35 @@
"license": "MIT"
},
"node_modules/primeicons": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz",
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
"license": "MIT"
},
"node_modules/primevue": {
"version": "3.53.1",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.53.1.tgz",
"integrity": "sha512-Bp4peZPdhfKYXwvtsOGGh5dfgmTelm+LZEZKGs/c5mOHhsUJ6xi3EcOZoQVI6oklS946ayMQvgD5L0S7itGO0g==",
"node_modules/primelocale": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/primelocale/-/primelocale-2.1.2.tgz",
"integrity": "sha512-sTDkqu/QBwpqzq86oZGXaU/QbPLKLZ3Qix2gbqHXr410B7tfQCx39HSWjM9Hsnpzqn9KVdXJzk1DH1yW1iNNPg==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
"engines": {
"node": ">=18.0.0",
"npm": ">=8.6.0"
}
},
"node_modules/primevue": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.3.3.tgz",
"integrity": "sha512-nooYVoEz5CdP3EhUkD6c3qTdRmpLHZh75fBynkUkl46K8y5rksHTjdSISiDijwTA5STQIOkyqLb+RM+HQ6nC1Q==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.0",
"@primeuix/styles": "^1.0.0",
"@primeuix/utils": "^0.5.1",
"@primevue/core": "4.3.3",
"@primevue/icons": "4.3.3"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/property-expr": {
+6 -2
View File
@@ -15,16 +15,20 @@
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@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",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"primeicons": "^7.0.0",
"primelocale": "^2.1.2",
"primevue": "^4.3.3",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5",
"vue": "^3.5.13",
+108 -93
View File
@@ -1,100 +1,103 @@
<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>
<template v-if="isRouterReady">
<AppPublicLayout v-if="isPublicLayout">
<router-view />
</AppPublicLayout>
<div v-else 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="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 class="club-stats">
<div class="stat-item">
<i class="pi pi-users"></i>
<span>{{ memberCount }} </span>
</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 class="stat-item">
<i class="pi pi-user"></i>
<span>모임장: {{ ownerName }}</span>
</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>
<!-- 클럽 선택 기능 (관리자 또는 클럽이 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>
</div>
</aside>
<main class="main-content" :class="{ 'full-width': !isLoggedIn }">
<router-view />
</main>
</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>
<footer>
<p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
</footer>
</div>
</template>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, provide } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import AppHeader from './components/AppHeader.vue';
import AppPublicLayout from './layouts/AppPublicLayout.vue';
import apiClient from './services/api';
const route = useRoute();
const layoutComponent = computed(() => route.meta.layout === 'public' ? AppPublicLayout : 'div');
const isPublicLayout = computed(() => {
// 라우트 meta.layout이 public이거나, 경로가 /public/로 시작하면 public 레이아웃으로 간주
return route.meta.layout === 'public' || route.path.startsWith('/public/');
});
import authService from './services/authService';
// 상태 변수
@@ -111,7 +114,7 @@ const selectedClubId = ref(localStorage.getItem('clubId') || null); // 선택된
const selectedClub = ref(null);
const memberCount = ref(0);
const ownerName = ref('');
const isRouterReady = ref(false);
const userRole = computed(() => user.value?.role || '');
const isAdmin = computed(() => userRole.value === 'admin' || userRole.value === 'superadmin');
@@ -156,6 +159,7 @@ const initializeExpandedGroups = () => {
// 클럽 목록 가져오기
const fetchClubs = async () => {
if (isPublicLayout.value) return;
try {
const userId = user.value?.id;
const userRole = localStorage.getItem('userRole');
@@ -182,6 +186,7 @@ const fetchClubs = async () => {
// 클럽 정보 가져오기
const fetchClubInfo = async () => {
if (isPublicLayout.value) return;
try {
const response = await apiClient.post(`/api/club`, { clubId: selectedClubId.value ?? clubs.value[0].id });
@@ -201,6 +206,7 @@ const fetchClubInfo = async () => {
// 클럽 선택
const setSelectedClub = async (club) => {
if (isPublicLayout.value) return;
// 1. localStorage에 저장
localStorage.setItem('clubId', club.id);
localStorage.setItem('userClubRole', club.memberType || '');
@@ -237,6 +243,7 @@ const onClubChange = async () => {
// 메뉴 아이템 로드
const loadMenuItems = async () => {
if (isPublicLayout.value) return;
try {
const role = localStorage.getItem('userRole');
const clubId = selectedClubId.value;
@@ -324,10 +331,19 @@ const handleUserLoggedIn = async () => {
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
await router.isReady?.()
isRouterReady.value = true
// public 레이아웃이면 내부 데이터 로딩/초기화 완전 스킵
if (isPublicLayout.value) {
window.addEventListener('resize', handleResize);
window.addEventListener('user-logged-in', handleUserLoggedIn);
initializeExpandedGroups();
return;
}
// 내부(클럽) 페이지만 내부 데이터 로딩
await loadUserData();
const clubId = localStorage.getItem('clubId');
if (clubId) {
// 서버 세션에 clubId를 동기화
apiClient.post('/api/club/select-club', { clubId });
}
if (isLoggedIn.value) {
@@ -335,14 +351,8 @@ onMounted(async () => {
await fetchClubInfo();
loadMenuItems();
}
// 화면 크기 변경 감지
window.addEventListener('resize', handleResize);
// 로그인 성공 이벤트 감지
window.addEventListener('user-logged-in', handleUserLoggedIn);
// 초기 expandedGroups 설정
initializeExpandedGroups();
});
@@ -352,7 +362,12 @@ onBeforeUnmount(() => {
window.removeEventListener('user-logged-in', handleUserLoggedIn);
});
</script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
<style scoped>
/* App.vue 스타일 */
.app {
+2 -1
View File
@@ -36,6 +36,7 @@
--section-gap: 160px;
}
/*
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
@@ -49,13 +50,13 @@
--color-text: var(--vt-c-text-dark-2);
}
}
*/
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
@@ -24,6 +24,14 @@
<p><strong>시작일:</strong> {{ formattedStartDate }}</p>
<p><strong>종료일:</strong> {{ formattedEndDate }}</p>
<p><strong>장소:</strong> {{ eventModel.location }}</p>
<p v-if="eventModel.publicHash">
<strong>공개 URL:</strong>
<InputText :value="publicEventUrl" readonly style="width:70%" class="mr-2" />
<Button icon="pi pi-copy" @click="copyPublicUrl" class="p-button-sm" v-tooltip.bottom="'복사'" />
</p>
<p v-if="eventModel.accessPassword">
<strong>공개 비밀번호:</strong> {{ eventModel.accessPassword }}
</p>
</div>
<div class="col-12 md:col-6">
<p><strong>최대 참가자 :</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p>
@@ -81,7 +89,6 @@ import TabPanel from 'primevue/tabpanel';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import ParticipantManagement from './ParticipantManagement.vue';
import ScoreManagement from './ScoreManagement.vue';
@@ -94,6 +101,11 @@ const props = defineProps({
getStatusName: { type: Function, required: true },
getStatusSeverity: { type: Function, required: true },
formatDate: { type: Function, required: true },
// (권장) 상태명이 매칭 안 될 경우 실제 상태 문자열 반환
// getStatusName 함수 예시:
// (status) => STATUS_LABELS[status] || status
availableMembers: { type: Array, default: () => [] }
});
@@ -103,6 +115,20 @@ const eventModel = computed(() => props.event || {});
const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate));
const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDate));
// 공개 URL 전체 경로
const publicEventUrl = computed(() => {
if (!eventModel.value.publicHash) return '';
// 실제 서비스 도메인에 맞게 수정 가능
return `${window.location.origin}/public/${eventModel.value.publicHash}`;
});
function copyPublicUrl() {
if (!publicEventUrl.value) return;
navigator.clipboard.writeText(publicEventUrl.value).then(() => {
toast.add({ severity: 'info', summary: 'URL 복사', detail: '공개 URL이 복사되었습니다.', life: 1500 });
});
}
const hideDialog = () => {
emit('hide');
};
+78 -47
View File
@@ -9,84 +9,82 @@
>
<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 />
<label for="title">이벤트 제목 *</label>
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" variant="filled" 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>
<label for="description">이벤트 설명</label>
<Textarea id="description" v-model="eventModel.description" variant="filled" rows="3" autoResize class="w-full" />
</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>
<label for="eventType">이벤트 유형 *</label>
<Select id="eventType" v-model="eventModel.eventType" variant="filled" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType, 'w-full': true}"/>
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</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>
<label for="status">상태 *</label>
<Select id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</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>
<label for="startDate">시작일 *</label>
<Datepicker id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.startDate, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</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>
<label for="endDate">종료일</label>
<Datepicker id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.endDate, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.endDate">종료일은 필수입니다.</small>
</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>
<label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location, 'w-full': true}" />
</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>
<label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" class="w-full" />
</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>
<label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" class="w-full" />
</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>
<label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.participantFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" class="w-full" />
</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>
<label for="registrationDeadline">신청마감</label>
<Datepicker id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.registrationDeadline, 'w-full': true}" />
</div>
<div class="col-12 md:col-6">
<label for="publicHash">공개 URL</label>
<InputGroup>
<InputText id="publicHash" v-model="eventModel.publicHash" readonly placeholder="생성 후 표시" />
<Button
type="button"
icon="pi pi-refresh"
@click="regeneratePublicHash"
:disabled="isNew || loadingHash"
class="p-button-sm"
:aria-label="eventModel.publicHash ? '공개 URL 재생성' : '공개 URL 생성'"
/>
</InputGroup>
</div>
<div class="col-12 md:col-6">
<label for="accessPassword">공개 비밀번호(선택)</label>
<InputText id="accessPassword" v-model="eventModel.accessPassword" type="text" maxlength="32" placeholder="공개 페이지 접근용 비밀번호(선택)" style="width:100%;" />
</div>
</div>
@@ -99,6 +97,7 @@
<script setup>
import { ref, watch, toRefs, computed } from 'vue';
import InputGroup from 'primevue/inputgroup';
import dayjs from 'dayjs';
const statusOptions = [
@@ -142,6 +141,10 @@ const eventTypeOptions = [
// 이벤트 모델
const eventModel = ref({ ...props.event });
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const loadingHash = ref(false);
// 날짜 형식 변환
const formatDate = (date) => {
@@ -160,6 +163,26 @@ watch(() => props.event, (newValue) => {
eventModel.value = formattedEvent;
}, { deep: true });
// 공개 URL(공개 해시) 재생성
const regeneratePublicHash = async () => {
if (props.isNew) return;
loadingHash.value = true;
try {
const res = await fetch(`/api/club/events/${eventModel.value.id}/publicHash`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('공개 URL 생성/재생성에 실패했습니다.');
const data = await res.json();
eventModel.value.publicHash = data.publicHash;
toast.add({ severity: 'info', summary: '공개 URL 재생성', detail: '저장 버튼을 눌러야 최종 반영됩니다.', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: '오류', detail: e.message });
} finally {
loadingHash.value = false;
}
};
// 이벤트 저장
const saveEvent = () => {
const eventData = { ...eventModel.value };
@@ -172,6 +195,7 @@ const saveEvent = () => {
if (eventData.registrationDeadline) {
eventData.registrationDeadline = dayjs(eventData.registrationDeadline).toDate();
}
emit('save', eventData);
};
@@ -223,4 +247,11 @@ const hideDialog = () => {
:deep(.p-inputtext) {
width: 100%;
}
.public-hash-btn.p-button-icon-only {
min-width:28px;
height:28px;
padding:0;
font-size:1.1em;
}
</style>
@@ -72,6 +72,7 @@
:max="300"
:placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'"
@update:modelValue="() => onScoreChange(game)"
class="w-full"
/>
</div>
</div>
@@ -85,6 +86,7 @@
:max="300"
placeholder="핸디캡 입력"
@update:modelValue="() => onScoreChange(game)"
class="w-full"
/>
</div>
</div>
@@ -44,7 +44,7 @@
<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" />
<Button icon="pi pi-times" class="p-button-text p-button-danger p-0" @click="removeMemberFromTeam(idx, mIdx)" v-if="team.members.length > 0" />
</li>
</template>
</draggable>
+23
View File
@@ -0,0 +1,23 @@
<template>
<div class="public-layout">
<main>
<slot />
</main>
</div>
</template>
<script setup>
// 별도 로직 없음
</script>
<style scoped>
.public-layout,
.public-layout > main {
min-height: 100vh;
min-width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>
+26 -9
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/base.css'
// dayjs configuration
import dayjs from 'dayjs'
@@ -14,8 +15,7 @@ dayjs.tz.setDefault('Asia/Seoul')
// PrimeVue
import PrimeVue from 'primevue/config'
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import Aura from '@primeuix/themes/aura';
import 'primeicons/primeicons.css'
import 'primeflex/primeflex.css'
@@ -25,11 +25,10 @@ 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 Select from 'primevue/select'
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'
@@ -40,29 +39,45 @@ 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'
import Datepicker from 'primevue/datepicker'
import Tag from 'primevue/tag'
import FloatLabel from 'primevue/floatlabel';
import Tooltip from 'primevue/tooltip';
import {ko} from 'primelocale/js/ko.js';
import AppHeader from './components/AppHeader.vue';
const app = createApp(App)
// Register PrimeVue
app.use(PrimeVue)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: false
},
locale: ko
}
})
app.directive('tooltip', Tooltip)
app.use(ToastService)
app.use(createPinia())
app.use(router)
// Register custom components
app.component('AppHeader', AppHeader);
// 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('Select', Select)
app.component('Toast', Toast)
app.component('Card', Card)
app.component('TabView', TabView)
app.component('TabPanel', TabPanel)
app.component('Menu', Menu)
app.component('Menubar', Menubar)
@@ -73,9 +88,11 @@ 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.component('Datepicker', Datepicker)
app.component('Tag', Tag)
app.component('FloatLabel', FloatLabel);
app.mount('#app')
+8
View File
@@ -22,12 +22,20 @@ const UserClubCreate = () => import('@/views/club/ClubCreate.vue');
// 구독 관리 컴포넌트 import
const ClubSubscriptionManagement = () => import('@/views/club/subscription/SubscriptionManagement.vue');
const EventPublicPage = () => import('@/views/event/EventPublicPage.vue');
const routes = [
// 공개 경로
{
path: '/',
redirect: '/login'
},
{
path: '/public/:publicHash',
name: 'EventPublicPage',
component: EventPublicPage,
meta: { requiresAuth: false, layout: 'public' }
},
{
path: '/login',
name: 'Login',
@@ -0,0 +1,84 @@
// PublicEventService.js
// 모든 public API 호출을 담당하는 서비스 (토큰 자동 관리)
const STORAGE_KEY = 'event_token_';
function getToken(publicHash) {
return sessionStorage.getItem(STORAGE_KEY + publicHash);
}
function setToken(publicHash, token) {
sessionStorage.setItem(STORAGE_KEY + publicHash, token);
}
function removeToken(publicHash) {
sessionStorage.removeItem(STORAGE_KEY + publicHash);
}
async function fetchEvent(publicHash, password) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
let body = undefined;
if (!token && password) {
body = JSON.stringify({ password });
}
const res = await fetch(`/api/public/${publicHash}`, {
method: 'POST',
headers,
body
});
if (res.status === 403 || res.status === 401) {
removeToken(publicHash);
}
const data = await res.json();
if (data.token) {
setToken(publicHash, data.token);
}
return { status: res.status, data };
}
async function updateScore(publicHash, participantId, gameNumber, score, handicap, teamNumber = null) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(`/api/public/${publicHash}/score`, {
method: 'PUT',
headers,
body: JSON.stringify({ participantId, gameNumber, score, handicap, teamNumber })
});
return res;
}
async function registerParticipant(publicHash, payload) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(`/api/public/${publicHash}/participant`, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
return res;
}
async function createGuest(publicHash, payload) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(`/api/public/${publicHash}/guest`, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
return await res.json();
}
export default {
fetchEvent,
updateScore,
registerParticipant,
createGuest,
getToken,
setToken,
removeToken
};
+39 -21
View File
@@ -23,31 +23,26 @@
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" />
<Select v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
</template>
</Column>
<Column field="title" header="제목" sortable>
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="제목으로 검색" />
</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) }}
<Datepicker v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
</template>
</Column>
<Column field="location" header="장소" sortable style="width: 15%"></Column>
@@ -236,15 +231,20 @@ const getEventTypeName = (type) => {
};
// 이벤트 상태 이름 반환
const getStatusName = (status) => {
switch (status) {
case '준비': return '준비';
case '진행중': return '진행중';
case '완료': return '완료';
case '취소': return '취소';
default: return '알 수 없음';
}
const STATUS_LABELS = {
'준비': '준비',
'진행중': '진행중',
'완료': '완료',
'취소': '취소',
'예정됨': '예정됨',
'in_progress': '진행중',
'completed': '완료',
'cancelled': '취소',
'활성': '활성',
'비활성': '비활성',
'삭제': '삭제',
};
const getStatusName = (status) => STATUS_LABELS[status] || status;
// 이벤트 상태에 따른 Badge 스타일
const getStatusSeverity = (status) => {
@@ -468,4 +468,22 @@ const navigateToCalendar = () => {
:deep(.p-column-filter) {
width: 100%;
}
/* 테이블 헤더(정렬/필터 버튼) 한 줄 정렬 및 공간 최소화 */
:deep(.p-column-header-content) {
white-space: nowrap;
align-items: center;
}
:deep(.p-sortable-column-icon),
:deep(.p-column-filter-menu-button) {
margin-left: 2px !important;
margin-right: 2px !important;
padding: 0 !important;
font-size: 13px !important;
vertical-align: middle;
}
:deep(.p-column-title) {
vertical-align: middle;
}
</style>
@@ -0,0 +1,504 @@
.event-public-wrapper {
width: 100%;
max-width: 700px;
margin: 0 auto;
}
.event-public-wrapper>.p-card>.p-card-body {
padding: var(--p-card-body-padding) 0.25rem !important;
}
h1.p-text-lg {
font-size: 2.5rem !important;
font-weight: 600 !important;
margin-bottom: 0.75rem !important;
text-align: center;
}
h2.p-text-lg span.p-tag {
margin-left: 0.25rem !important;
}
.event-title-inline {
display: inline-flex;
align-items: center;
font-size: 2rem;
font-weight: 600;
line-height: 1.3;
}
@media (max-width: 767px) {
.event-public-wrapper {
max-width: 98vw;
padding: 0 2vw;
}
}
.event-password-form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0 32px 0;
border-radius: 12px;
min-height: 280px;
}
.event-password-lock {
font-size: 2.5rem !important;
margin-bottom: 16px;
}
.event-password-input {
width: 220px;
}
.event-password-btn {
width: 220px;
}
.event-password-form .pi-lock {
font-size: 2.8rem !important;
margin-bottom: 16px;
}
.event-password-form .p-text-lg {
font-size: 1.14rem;
font-weight: 600;
margin-bottom: 18px;
line-height: 1.6;
}
.event-password-form .p-inputtext {
font-size: 1.1rem;
padding: 10px 14px;
border-radius: 8px;
margin-bottom: 8px;
width: 220px;
transition: border 0.2s;
}
.event-password-form .p-inputtext:focus {
outline: none;
}
.event-password-form .p-button {
width: 220px;
font-size: 1.1rem;
border-radius: 8px;
margin-bottom: 8px;
border: none;
}
.event-password-form .p-error {
font-size: 1rem;
margin-top: 2px;
font-weight: 500;
text-align: center;
color: var(--p-red-500);
}
.info-row {
margin-bottom: 4px !important;
color: var(--p-gray-700);
}
.info-under-name {
font-size: 14px;
margin-top: 2px;
color: var(--p-gray-700);
}
.status-cancel {
font-weight: 600;
color: var(--p-red-500);
}
.status-pending {
font-weight: 600;
}
.payment-unpaid {
font-weight: 600;
color: var(--p-red-500);
}
.member-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: stretch;
gap: 12px;
padding: 10px 0;
font-size: 1.22rem;
}
.member-row:last-child {
border-bottom: none;
}
.member-info-block {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.member-header-row {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.member-meta {
font-size: 14px;
color: var(--p-gray-700);
}
.score-inputs-vertical {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
}
.score-input {
width: 60px !important;
height: 22px !important;
padding: 0 6px !important;
font-size: 15px !important;
text-align: center;
border-radius: 6px !important;
box-shadow: none !important;
}
.score-gray {
color: var(--p-gray-500);
}
.score-blue {
color: var(--p-blue-500);
}
.score-red {
color: var(--p-red-500);
}
.score-black {
color: var(--p-black-500);
}
.team-hc-small {
display: block;
margin-top: 2px;
font-size: 13px;
font-weight: 400;
line-height: 1.2;
}
.team-total-row {
display: flex;
align-items: center;
margin-top: 8px;
}
.team-total-main {
width: 100%;
text-align: right;
margin-top: 3px;
font-size: 1.5rem;
}
.team-total-label {
margin-right: 10px;
letter-spacing: 0.5px;
color: var(--p-blue-500);
}
.team-total-scores {
margin-left: 12px;
letter-spacing: 2px;
margin: 0 16px 0 8px;
color: var(--p-blue-500);
font-size: 2rem;
font-weight: 900;
}
.team-score-table-wrapper {
overflow-x: auto;
border-radius: 10px;
padding: 12px 8px;
}
.event-public-card {
width: 100%;
max-width: 700px;
position: relative;
}
.score-flex-col {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.2;
}
.score-main {
font-size: 15px;
font-weight: 600;
}
/* 순위별 색상 */
.rank-badge.rank-1 {
color: var(--p-yellow-500);
}
.rank-badge.rank-2 {
color: var(--p-gray-400);
}
.rank-badge.rank-3 {
color: var(--p-orange-500);
}
.rank-badge.rank-4 {
color: var(--p-blue-400);
}
.rank-badge.rank-5 {
color: var(--p-green-400);
}
/* 필요시 더 추가 가능 */
.team-score-table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
.team-score-table th,
.team-score-table td {
text-align: center !important;
}
.team-score-table td .score-main,
.team-score-table td .rank-badge {
font-weight: normal !important;
}
.team-score-table th,
.team-score-table .p-datatable-thead > tr > th > .p-datatable-column-header-content {
justify-content: center !important;
align-items: center !important;
text-align: center !important;
}
.rank-badge {
font-weight: 900;
letter-spacing: 0.5px;
color: var(--p-yellow-500);
}
.score-table th,
.score-table td {
font-size: 12px !important;
padding: 3px 2px !important;
min-width: unset !important;
word-break: keep-all;
white-space: normal;
line-height: 1.3;
}
.score-table tr {
height: auto !important;
}
.score-table th,
.score-table td {
text-align: center;
}
.team-card {
border-radius: 16px;
margin-bottom: 12px;
}
.member-row {
padding: 8px 0;
font-size: 1.22rem;
}
.member-row:last-child {
border-bottom: none;
}
.team-total-row-block {
margin-top: 12px;
margin-bottom: 6px;
}
.team-games-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0 12px;
margin-top: 2px;
width: 100%;
}
.team-game-cell {
font-size: 1.13rem;
font-weight: 600;
text-align: center;
min-width: 0;
white-space: nowrap;
padding: 2px 0 0 0;
}
.team-game-cell b {
font-weight: 700;
margin-right: 2px;
}
.team-game-cell .rank-badge {
font-size: 1.05rem;
font-weight: 700;
margin-left: 3px;
vertical-align: middle;
color: var(--p-purple-500);
}
.team-title-row {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 7px;
}
.team-name-strong {
font-size: 2.3rem;
font-weight: 900;
line-height: 1.1;
margin-bottom: 2px;
color: var(--p-blue-500);
}
.team-hc {
font-size: 1.15rem;
font-weight: 500;
margin-top: 2px;
margin-left: 2px;
letter-spacing: 0.2px;
}
.member-meta {
font-size: 0.98rem;
margin-top: 1px;
}
.team-total-row-block {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 10px;
}
.score-input-hc-wrap {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
width: 100%;
margin-bottom: 2px;
}
.score-input-status-wrap {
display: flex;
align-items: center;
gap: 6px;
width: 110px;
justify-content: flex-end;
}
.score-input-hc-wrap > .score-input,
.score-input-hc-wrap > .score-hc-value {
margin-bottom: 0;
}
.score-input {
flex: 0 0 60px;
min-width: 48px;
max-width: 70px;
text-align: right;
margin-bottom: 0;
}
.score-hc-value {
flex: 0 0 auto;
min-width: 32px;
margin-left: 6px;
display: flex;
align-items: center;
font-size: 20px;
font-weight: 500;
padding-right: 0;
white-space: nowrap;
}
.score-status-icon {
flex-shrink: 0;
min-width: 18px;
margin-left: 2px;
vertical-align: middle;
font-size: 1em;
}
.score-hc-value:empty {
visibility: hidden;
}
.member-score-summary {
margin-top: 4px;
font-size: 14px;
font-weight: 600;
}
.member-score-avg {
margin-left: 8px;
font-weight: 400;
font-size: 13px;
}
@@ -0,0 +1,601 @@
<template>
<div class="event-public-wrapper">
<h1 v-if="clubName" class="p-text-primary p-text-bold p-text-lg p-mb-2">{{ clubName }}</h1>
<Divider class="p-mb-4" />
<Card class="p-shadow-8 p-p-5 p-rounded-xl event-public-card">
<template #content>
<div v-if="loading" class="p-text-center p-mb-4">로딩 중...</div>
<div v-else-if="error" class="p-text-center p-mb-4">{{ error }}</div>
<div v-else-if="!isAuthenticated">
<div class="event-password-form flex flex-column p-ai-center p-mt-6 p-mb-6">
<i class="pi pi-lock p-mb-3 event-password-lock"></i>
<div class="p-text-center p-mb-3 p-text-lg event-password-desc"> 이벤트는 비밀번호가 필요합니다.<br>비밀번호를 입력해 주세요.</div>
<InputText v-model="inputPassword" type="password" placeholder="비밀번호 입력" class="p-mb-2 event-password-input"
@keyup.enter="verifyPassword" autofocus />
<Button label="확인" @click="verifyPassword" class="p-mb-2 event-password-btn" :loading="loading" />
<div v-if="passwordError" class="p-error p-mt-1 event-password-error">{{ passwordError }}</div>
</div>
</div>
<div v-else>
<div class="event-tag">
<Tag :value="event.eventType" class="event-type-tag" />
<Tag :value="event.status"
:severity="event.status === '완료' ? 'success' : event.status === '활성' ? 'info' : 'warning'"
class="p-ml-4" />
</div>
<div>
<div class="flex justify-content-end gap-2">
<Button :label="joinButtonLabel" class="p-button-lg p-button-primary" @click="showJoinDialog = true" v-if="canJoin" />
</div>
<div class="flex justify-content-start p-ai-center p-mb-4">
<h2 class="event-title-inline p-text-lg p-text-bold">
{{ event.title }}
</h2>
</div>
<div class="p-text-secondary p-mb-3">
<div class="info-row">
<i class="pi pi-calendar p-mr-5" /><strong>기간</strong>
{{ formatDate(event.startDate) }}
<template v-if="event.endDate">~ {{ formatDate(event.endDate) }}</template>
</div>
<div v-if="event.registrationDeadline" class="info-row">
<i class="pi pi-calendar-times p-mr-2" /><strong>마감</strong> {{ formatDate(event.registrationDeadline)
}}
</div>
<div v-if="event.location" class="info-row">
<i class="pi pi-map-marker p-mr-2" /><strong>장소</strong> {{ event.location }}
</div>
<div v-if="event.participantFee" class="info-row">
<i class="pi pi-credit-card p-mr-2" /><strong>참가비</strong> {{
Number(event.participantFee).toLocaleString('ko-KR') }}
</div>
<div v-if="event.maxParticipants" class="info-row">
<i class="pi pi-user p-mr-2" /><strong>참여</strong> {{ participants.length }}/{{ event.maxParticipants
}}
</div>
</div>
<div class="p-text-secondary p-mb-4">{{ event.description || '' }}</div>
<!-- 팀별 게임별 점수/등수 : 하단으로 이동 -->
<div v-if="teams.length > 0 || unassignedMembers.length > 0" class="team-score-table-wrapper p-mt-4">
<DataTable :value="getAllTeamsWithUnassigned()" class="team-score-table" :responsiveLayout="'scroll'">
<Column header="팀">
<template #body="slotProps">
{{ slotProps.data.teamNumber }}
<span v-if="slotProps.data.handicap" class="team-hc-small">
핸디캡: {{ slotProps.data.handicap ?? 0 }}
</span>
</template>
</Column>
<Column v-for="n in event.gameCount" :key="n" :header="n + 'G'">
<template #body="slotProps">
<span class="score-main">{{ getTeamTotalScore(slotProps.data, n) }}</span>
<span class="block rank-badge"
v-if="getRanksByGame(n)[(slotProps.data.id || slotProps.data.teamNumber) + ''] !== undefined"
:class="'rank-badge rank-' + getRanksByGame(n)[(slotProps.data.id || slotProps.data.teamNumber) + '']">
({{ getRanksByGame(n)[(slotProps.data.id || slotProps.data.teamNumber) + ''] }})
</span>
</template>
</Column>
<Column header="총점">
<template #body="slotProps">
{{Array.from({ length: event.gameCount }, (_, i) => getTeamTotalScore(slotProps.data, i +
1)).reduce((a, b) => a + b, 0) }}
<span class="block rank-badge" :class="'rank-' + getTeamOverallRank(slotProps.data)">
({{ getTeamOverallRank(slotProps.data) }})
</span>
</template>
</Column>
</DataTable>
</div>
<div v-for="team in getAllTeamsWithUnassigned()" :key="team.id" class="team-card">
<Card class=" p-rounded-lg p-shadow-1 p-p-3 p-mb-4">
<template #title>
<div class="team-title-row">
<span class="team-name-strong">{{ parseInt(team.teamNumber) ? '팀 ' + team.teamNumber : '미배정 인원'}}</span>
<span class="team-hc" v-if="team.handicap"> 핸디캡: {{ team.handicap ?? 0 }}</span>
</div>
</template>
<template #content>
<div class="member-list">
<div v-for="member in team.members" :key="member.participantId" class="member-row">
<div class="member-info-block">
<div class="member-header-row">
<span class="member-name">{{ member.name }}</span>
<Tag v-if="member.isGuest" value="G" severity="warning" class="ml-2" />
</div>
<div class="info-under-name">
{{ member.gender || '-' }}
<span v-if="member.status" :class="{
'status-cancel': member.status === '취소',
'status-pending': member.status === '참가예정'
}">
· {{ member.status }}
</span>
<span v-if="member.paymentStatus" :class="{
'payment-unpaid': member.paymentStatus === '미납'
}">
· {{ member.paymentStatus }}
</span>
</div>
<div class="member-meta">핸디캡: {{ member.handicap ?? '-' }} / 에버리지: {{ member.average ?? '-' }}
</div>
<div class="member-meta">{{ member.comment }}</div>
</div>
<div class="score-inputs-vertical">
<template v-for="n in event.gameCount" :key="n">
<div class="score-input-hc-wrap">
<div class="score-input-status-wrap">
<InputText
v-if="canInputScore"
v-model.number="scoresProxy[member.participantId + '-' + n]"
type="number"
class="score-input"
:placeholder="n + 'G'"
maxlength="3"
@input="onScoreInput(member, n, team.teamNumber)"
@blur="onScoreBlur(member, n, team.teamNumber)"
@keydown.enter="onScoreEnter(member, n, team.teamNumber)"
/>
<span v-else>{{ member.scores?.[n - 1] ?? '-' }}</span>
<span :class="scoreColorClass(scoreStates[member.participantId + '-' + n], scoresProxy[member.participantId + '-' + n], member.handicap)"
class="score-hc-value score-status-text"
:key="scoreStates[member.participantId + '-' + n] + '-' + scoresProxy[member.participantId + '-' + n]">
{{ (scoresProxy[member.participantId + '-' + n] !== undefined && scoresProxy[member.participantId + '-' + n] !== '') ?
Number(scoresProxy[member.participantId + '-' + n]) + (Number(member.handicap) || 0) : ''
}}
<i v-if="scoreStates[member.participantId + '-' + n] === 'saving'" class="pi pi-spin pi-spinner score-status-icon" />
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'saved'" class="pi pi-check score-status-icon" />
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'error'" class="pi pi-times score-status-icon" />
</span>
</div>
</div>
</template>
<div class="member-score-summary">
<span>총점: {{ getMemberTotalScore(member) }}</span>
<span class="member-score-avg">/ 에버리지: {{ getMemberAverageScore(member) }}</span>
</div>
</div>
</div>
</div>
<Divider />
<div class="team-game-scores">
<div class="team-total-row team-total-row-responsive team-total-row-block">
<div class="team-games-grid"
:style="{ gridTemplateColumns: `repeat(${event.gameCount > 3 ? 3 : event.gameCount}, 1fr)` }">
<template v-for="n in event.gameCount" :key="n">
<div class="team-game-cell">
<b>{{ n }}G:</b> {{ getTeamTotalScore(team, n) }}
<span v-if="getRanksByGame(n)[(team.id || team.teamNumber) + ''] !== undefined"
class="rank-badge"
:class="'rank-' + getRanksByGame(n)[(team.id || team.teamNumber) + '']">
({{ getRanksByGame(n)[(team.id || team.teamNumber) + ''] }})
</span>
</div>
</template>
</div>
<div class="team-total-main">
<span class="team-total-label">총점</span>
<span class="team-total-scores">{{Array.from({ length: event.gameCount }, (_, i) =>
getTeamTotalScore(team, i + 1)).reduce((a, b) => a + b, 0)}}</span>
<span v-if="getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })"
class="rank-badge"
:class="'rank-' + getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })">
{{ getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' }) }}
</span>
</div>
</div>
</div>
</template>
</Card>
</div>
<JoinDialog v-if="showJoinDialog && canJoin" v-model:visible="showJoinDialog" :publicHash="publicHash"
:password="needPassword ? inputPassword : ''" :participants="participants"
:availableMembers="availableMembers" :eventStatus="event.status"
@close="showJoinDialog = false" @updated="reload" />
<GuestDialog v-if="showGuestDialog && canAddGuest" v-model:visible="showGuestDialog" :publicHash="publicHash"
:password="needPassword ? inputPassword : ''" @close="showGuestDialog = false" @updated="reload" />
</div>
</div>
</template>
</Card>
</div>
</template>
<script setup>
import { ref, computed, watch, reactive, onMounted } from 'vue';
import { debounce } from 'lodash-es'; // lodash 설치 필요 (이미 있다면 무시)
import { useRoute } from 'vue-router';
import JoinDialog from './JoinDialog.vue';
import dayjs from 'dayjs';
import PublicEventService from '@/services/PublicEventService';
const route = useRoute();
const publicHash = route.params.publicHash;
const loading = ref(true);
const error = ref('');
const clubName = ref('');
const event = ref({});
const teams = ref([]);
const participants = ref([]);
const isAuthenticated = ref(false);
const inputPassword = ref('');
const passwordError = ref('');
const showJoinDialog = ref(false);
const showGuestDialog = ref(false);
const allMembers = ref([]); // 전체 클럽 멤버 목록
const availableMembers = ref([]); // 참가 신청 가능 명단
// ===== 이벤트 상태별 권한 제어 =====
const isReady = computed(() => event.value.status === '준비');
const isActive = computed(() => event.value.status === '활성');
const isFinished = computed(() => event.value.status === '완료');
const canJoin = computed(() => (isReady.value || isActive.value) && isAuthenticated.value);
const canAddGuest = computed(() => isReady.value && isAuthenticated.value);
const canEditParticipant = computed(() => (isReady.value || isActive.value) && isAuthenticated.value);
const canInputScore = computed(() => isActive.value && isAuthenticated.value);
const joinButtonLabel = computed(() => {
if (isReady.value) return '참가 신청/수정';
if (isActive.value) return '참가자 정보 수정';
return '참가자 정보';
});
// ==============================
// 점수 상태 관리: idle, saving, saved, error
const scoreStates = reactive({});
// 점수 색상 클래스 계산
// 점수 색상 클래스 계산 (핸디캡 포함)
function scoreColorClass(state, value, handicap) {
if (state === 'saving' || state === undefined) return 'score-gray';
const baseScore = Number(value);
const hc = Number(handicap) || 0;
const total = baseScore + hc;
if (state === 'saved') {
if (value === '' || value === null || isNaN(baseScore)) return 'score-gray';
return total >= 200 ? 'score-red' : 'score-black';
}
if (state === 'idle') {
// 값이 있으면 색상 유지, 없으면 회색
if (value === '' || value === null || isNaN(baseScore)) return 'score-gray';
return total >= 200 ? 'score-red' : 'score-black';
}
if (state === 'error') return 'score-gray';
return 'score-gray';
}
// 점수 저장 로직 (API 연동)
const saveScore = debounce(async (member, n, oldValue, newValue, teamNumber) => {
const key = member.participantId + '-' + n;
if (String(oldValue) === String(newValue)) return; // 변경 없으면 저장 X
scoreStates[key] = 'saving';
try {
// 실제 API 연동
const res = await PublicEventService.updateScore(publicHash, member.participantId, n, Number(newValue), Number(member.handicap || 0), teamNumber ?? null);
if (!res.ok) throw new Error('점수 저장 실패');
scoreStates[key] = 'saved';
// 프론트 점수 동기화
if (Array.isArray(member.scores)) {
member.scores[n-1] = Number(newValue);
}
setTimeout(() => { scoreStates[key] = 'idle'; }, 1200); // 일정 시간 후 아이콘 사라지게
} catch (e) {
scoreStates[key] = 'error';
setTimeout(() => { scoreStates[key] = 'idle'; }, 2000);
}
}, 200);
function onScoreBlur(member, n, teamNumber) {
const key = member.participantId + '-' + n;
const val = (scoresProxy.value[key] === undefined || scoresProxy.value[key] === null) ? '' : String(scoresProxy.value[key]);
// 화면상의 이전 값(마지막 저장된 값)을 별도로 기억해야 함
const oldVal = (onScoreBlur.lastValues && onScoreBlur.lastValues[key] !== undefined) ? String(onScoreBlur.lastValues[key]) : '';
if (oldVal === val) return;
saveScore(member, n, oldVal, val, teamNumber);
// 저장 후 이전 값 갱신
if (!onScoreBlur.lastValues) onScoreBlur.lastValues = {};
onScoreBlur.lastValues[key] = val;
}
// onScoreInput에서 입력값을 기록해두고, blur에서 비교
function onScoreInput(member, n, teamNumber) {
const key = member.participantId + '-' + n;
scoreStates[key] = 'idle'; // 입력 시작 시 상태 초기화(회색)
const val = String(scoresProxy.value[key] ?? '');
// 기존 저장된 값과 다를 때만 저장
const oldVal = member.scores?.[n-1] ?? '';
if (val.length === 3 && String(oldVal) !== val) {
saveScore(member, n, oldVal, val, teamNumber);
}
}
function onScoreEnter(member, n, teamNumber) {
const key = member.participantId + '-' + n;
const val = String(scoresProxy.value[key] ?? '');
saveScore(member, n, member.scores?.[n-1] ?? '', val, teamNumber);
}
// 멤버별 평균 점수 계산 (입력된 점수만 평균, 소수점 1자리)
function getMemberAverageScore(member) {
if (!member || !event.value.gameCount) return 0;
let total = 0, count = 0;
for (let i = 1; i <= event.value.gameCount; i++) {
const score = Number(scoresProxy.value[member.participantId + '-' + i]);
const hc = Number(member.handicap) || 0;
if (!isNaN(score) && score > 0) {
total += score + hc;
count++;
}
}
return count > 0 ? Math.round((total / count) * 10) / 10 : 0;
}
// 멤버별 핸디캡 포함 총점 계산
function getMemberTotalScore(member) {
if (!member || !event.value.gameCount) return 0;
let total = 0;
for (let i = 1; i <= event.value.gameCount; i++) {
const score = Number(scoresProxy.value[member.participantId + '-' + i]) || 0;
const hc = Number(member.handicap) || 0;
total += score + hc;
}
return total;
}
// 각 참가자별로 점수 배열(scores) 생성 (점수 미정시 null)
const sortedParticipants = computed(() => {
return [...participants.value].map(p => {
// 기존 score가 단일 값이면 배열로 변환
let scores = [];
if (Array.isArray(p.scores)) {
scores = p.scores;
} else if (typeof p.score !== 'undefined') {
// 백엔드에서 배열이 안 내려올 경우 단일값을 1게임으로 간주
scores = [p.score];
} else {
// 여러 게임 점수 미존재시 null로 채움
scores = Array(event.value.gameCount).fill(null);
}
// 점수 개수 맞추기
if (scores.length < event.value.gameCount) {
scores = [...scores, ...Array(event.value.gameCount - scores.length).fill(null)];
}
return { ...p, scores };
}).sort((a, b) => (a.name || '').localeCompare(b.name || '', 'ko'));
});
// 점수 입력용 프록시 (v-model에서 계산식 사용 회피)
const scoresProxy = ref({});
// 팀 미배정 인원 (백엔드 응답을 그대로 사용)
const unassignedMembers = ref([]);
function formatDate(date) {
if (!date) return '-';
const d = dayjs(date);
const ampm = d.format('A') === 'AM' ? '오전' : '오후';
return `${d.format('YYYY-MM-DD')} ${ampm} ${d.format('hh:mm')}`;
}
async function fetchEventData(password) {
loading.value = true;
error.value = '';
try {
const { status, data } = await PublicEventService.fetchEvent(publicHash, password);
if (status === 403) {
console.log('[fetchEventData] 403', { publicHash, password });
error.value = '이벤트에 접근할 수 없습니다.';
PublicEventService.removeToken(publicHash);
return;
}
if (status === 401) {
console.log('[fetchEventData] 401', { publicHash, password });
isAuthenticated.value = false;
PublicEventService.removeToken(publicHash);
return;
}
console.log('[fetchEventData] 200', data);
clubName.value = data.clubName || '';
event.value = data.event;
teams.value = data.teams || [];
participants.value = data.participants || [];
unassignedMembers.value = data.unassignedMembers || [];
availableMembers.value = data.availableMembers || [];
isAuthenticated.value = true;
// [추가] 기존 점수를 scoresProxy에 반영
const proxy = {};
[...(data.teams || [])].forEach(team => {
(team.members || []).forEach(member => {
if (Array.isArray(member.scores)) {
member.scores.forEach((score, idx) => {
const key = member.participantId + '-' + (idx + 1);
proxy[key] = score !== null && score !== undefined ? String(score) : '';
});
}
});
});
(data.unassignedMembers || []).forEach(member => {
if (Array.isArray(member.scores)) {
member.scores.forEach((score, idx) => {
const key = member.participantId + '-' + (idx + 1);
proxy[key] = score !== null && score !== undefined ? String(score) : '';
});
}
});
scoresProxy.value = proxy;
// [추가] 점수 상태도 초기화 (핸디캡 포함 점수 기준 색상 적용)
Object.keys(proxy).forEach(key => {
// 점수 상태는 항상 'idle'로 초기화 (저장됨 아이콘이 안나오게)
scoreStates[key] = 'idle';
});
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
function verifyPassword() {
passwordError.value = '';
PublicEventService.removeToken(publicHash); // 항상 먼저 토큰 제거
fetchEventData(inputPassword.value).then(() => {
if (isAuthenticated.value) {
inputPassword.value = '';
} else {
passwordError.value = '비밀번호가 올바르지 않습니다.';
PublicEventService.removeToken(publicHash);
}
});
}
async function updateScore(team, member) {
if (!canEdit.value) return;
if (!isAuthenticated.value) return;
try {
await PublicEventService.updateScore(publicHash, member.id, member.score);
reload();
} catch (e) {
// 에러 핸들링
}
}
function reload() {
fetchEventData();
}
onMounted(() => {
const token = sessionStorage.getItem('event_token_' + publicHash);
if (token) {
fetchEventData();
} else {
fetchEventData();
}
});
// (핸디캡 제외) 팀별 게임별 실제 점수 합계
function getTeamRawScore(team, gameIdx) {
let total = 0;
if (!team || !team.members) return 0;
team.members.forEach(member => {
const score = Number(scoresProxy.value[member.participantId + '-' + gameIdx]) || 0;
total += score;
});
return total;
}
// 팀별 게임별 합산 점수 계산 (computed)
// 팀별 게임별 합산 점수 계산 함수 (단순 값 반환)
function getAllTeamsWithUnassigned() {
// 멤버 1명 이상인 팀만 등수 산정에 포함
const teamList = (teams.value || [])
.filter(team => team.members && team.members.length > 0)
.map(team => ({
...team,
id: team.id || team.teamNumber, // id 없으면 teamNumber 사용
isUnassigned: false
}));
if (unassignedMembers.value && unassignedMembers.value.length > 0) {
teamList.push({
id: 'unassigned',
teamNumber: '미배정',
members: unassignedMembers.value,
handicap: 0,
isUnassigned: true
});
}
return teamList;
}
function getTeamTotalScore(team, gameIdx) {
let total = 0;
if (!team || !team.members) return 0;
team.members.forEach(member => {
const score = Number(scoresProxy.value[member.participantId + '-' + gameIdx]) || 0;
const memberHc = Number(member.handicap) || 0;
total += score + memberHc;
});
total += Number(team.handicap) || 0;
return total;
}
// 팀별 핸디캡 합산 계산 함수 (단순 값 반환)
function getTeamTotalHandicap(team) {
let total = 0;
if (!team || !team.members) return 0;
team.members.forEach(member => {
const memberHc = Number(member.handicap) || 0;
total += memberHc;
});
total += Number(team.handicap) || 0;
return total;
}
// 전체 게임 합계 기준 팀별 등수 계산 함수
function getTeamOverallRank(team) {
if (!team || !team.id) return '';
const allTeams = getAllTeamsWithUnassigned();
const teamTotals = allTeams.map(t => ({
teamId: t.id || t.teamNumber,
total: Array.from({ length: event.value.gameCount }, (_, i) => getTeamTotalScore(t, i + 1)).reduce((a, b) => a + b, 0),
handicapSum: getTeamTotalHandicap(t),
memberCount: (t.members ? t.members.length : 0)
}));
teamTotals.sort((a, b) => {
if (b.total !== a.total) return b.total - a.total;
if (a.handicapSum !== b.handicapSum) return a.handicapSum - b.handicapSum;
return b.memberCount - a.memberCount;
});
let rank = 1;
for (let i = 0; i < teamTotals.length; i++) {
if (
i > 0 && (
teamTotals[i].total !== teamTotals[i - 1].total ||
teamTotals[i].handicapSum !== teamTotals[i - 1].handicapSum ||
teamTotals[i].memberCount !== teamTotals[i - 1].memberCount
)
) {
rank = i + 1;
}
if ((teamTotals[i].teamId + '') === (team.id + '')) {
return rank;
}
}
return '';
}
// 게임별 등수 계산 함수 (computed)
// 통합 등수 산정 함수
function getRanksByGame(gameIdx) {
const allTeams = getAllTeamsWithUnassigned();
const scores = allTeams.map(team => ({
teamId: team.id,
score: getTeamTotalScore(team, gameIdx),
handicapSum: getTeamTotalHandicap(team),
memberCount: (team.members ? team.members.length : 0)
}));
scores.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.handicapSum !== b.handicapSum) return a.handicapSum - b.handicapSum;
return b.memberCount - a.memberCount;
});
const ranks = {};
let rank = 1;
scores.forEach((item, i) => {
if (
i > 0 && (
item.score !== scores[i - 1].score ||
item.handicapSum !== scores[i - 1].handicapSum ||
item.memberCount !== scores[i - 1].memberCount
)
) {
rank = i + 1;
}
ranks[item.teamId] = rank;
});
return ranks;
}
</script>
<style src="./EventPublicPage.css"></style>
+257
View File
@@ -0,0 +1,257 @@
<template>
<Dialog
:visible="visible"
@update:visible="val => $emit('update:visible', val)"
modal
header="참가 신청/수정"
:style="{ width: '400px' }"
:closable="true"
@hide="$emit('close')"
>
<div class="flex flex-wrap gap-3">
<!-- 회원 선택 -->
<div class="w-full min-w-[200px]">
<label>회원</label>
<Select
v-model="selectedMember"
:options="memberOptions"
optionLabel="name"
placeholder="회원 선택"
class="w-full"
filter
filterPlaceholder="이름으로 검색"
>
<!-- 선택된 표시 -->
<template #value="slotProps">
<div v-if="slotProps.value && slotProps.value.isNewGuest" class="flex align-items-center text-primary font-bold">
<i class="pi pi-user-plus mr-2" /> 신규 게스트 추가
</div>
<div v-else-if="slotProps.value" class="flex align-items-center">
<Tag :value="slotProps.value.group" :severity="slotProps.value.group === '참가자' ? 'success' : 'info'" class="mr-2" />
<span style="font-weight:bold;">{{ slotProps.value.name }}</span>
<span style="color:#888; font-size:0.93em; margin-left: 0.5em;">
{{ slotProps.value.memberType }} / {{ slotProps.value.gender }} / 에버리지: {{ slotProps.value.average ?? '-' }}
</span>
</div>
<span v-else>
{{ slotProps.placeholder }}
</span>
</template>
<!-- 옵션 목록 표시 -->
<template #option="slotProps">
<div v-if="slotProps.option.isNewGuest" class="flex align-items-center text-primary font-bold">
<i class="pi pi-user-plus mr-2" /> 신규 게스트 추가
</div>
<div v-else class="flex align-items-center">
<Tag :value="slotProps.option.group" :severity="slotProps.option.group === '참가자' ? 'success' : 'info'" class="mr-2" />
<span style="font-weight:bold;">{{ slotProps.option.name }}</span>
<span style="color:#888; font-size:0.93em; margin-left: 0.5em;">
{{ slotProps.option.memberType }} / {{ slotProps.option.gender }} / 에버리지: {{ slotProps.option.average ?? '-' }}
</span>
</div>
</template>
</Select>
<small v-if="submitted && !selectedMemberId" class="p-error">회원을 선택해주세요.</small>
<!-- 신규 게스트 추가 입력폼 -->
<div v-if="selectedMember && selectedMember.isNewGuest" class="w-full border p-3 rounded bg-gray-50 mt-2">
<div class="mb-2">
<label>이름</label>
<InputText v-model="newGuest.name" class="w-full" />
</div>
<div class="mb-2">
<label>연락처</label>
<InputText v-model="newGuest.phone" class="w-full" />
</div>
<div class="mb-2">
<label>성별</label>
<Select v-model="newGuest.gender" :options="genderOptions" optionLabel="label" optionValue="value" placeholder="성별 선택" class="w-full" />
</div>
<div class="mb-2">
<label>에버리지</label>
<InputText v-model="newGuest.average" class="w-full" />
</div>
<Button label="게스트 추가" class="mt-1" @click="addNewGuest" :disabled="!newGuest.name || !newGuest.phone || !newGuest.gender" />
</div>
</div>
<!-- 참가 상태 -->
<div class="w-full min-w-[200px]">
<label>참가 상태</label>
<Select
v-model="status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="상태를 선택하세요"
checkmark
class="w-full"
/>
<small v-if="submitted && !status" class="p-error">참가 상태를 선택해주세요.</small>
</div>
<!-- 회비 납부 상태 -->
<div class="flex-1 min-w-[200px]">
<label>회비 납부 상태</label>
<Select
v-model="paymentStatus"
:options="paymentStatusOptions"
optionLabel="label"
optionValue="value"
placeholder="납부 상태를 선택하세요"
checkmark
class="w-full"
/>
<small v-if="submitted && !paymentStatus" class="p-error">납부 상태를 선택해주세요.</small>
</div>
<!-- 비고 -->
<div class="w-full">
<label>비고</label>
<InputText v-model="comment" class="w-full" />
</div>
<div v-if="error" class="w-full p-error p-mb-2">{{ error }}</div>
<div class="w-full flex justify-content-end gap-2 mt-3">
<Button type="submit" label="확인" @click="submit" />
<Button type="button" label="취소" severity="secondary" @click="$emit('close')" />
</div>
</div>
</Dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import Tag from 'primevue/tag';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import Button from 'primevue/button';
const props = defineProps({
publicHash: String,
participants: Array,
availableMembers: Array,
visible: Boolean, // Dialog의 v-model:visible과 연결
eventStatus: String
});
const emits = defineEmits(['close', 'updated']);
const selectedMember = ref(null);
const memberOptions = computed(() => {
const baseOptions = [
...(props.participants || []).map(m => ({ ...m, group: '참가자', id: m.participantId, participantId: m.participantId, memberId: m.memberId })),
...(props.availableMembers || []).map(m => ({ ...m, group: '미신청', id: m.memberId, participantId: null, memberId: m.memberId }))
].sort((a, b) => {
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB, 'ko');
});
if (props.eventStatus === '준비') {
baseOptions.push({ name: '신규 게스트 추가', isNewGuest: true });
}
return baseOptions;
});
const selectedParticipantId = ref('');
const selectedMemberId = ref('');
watch(selectedMember, (val) => {
selectedParticipantId.value = val ? val.participantId : '';
selectedMemberId.value = val ? val.memberId : '';
// 참가자라면 기존 정보 자동 세팅
if (val) {
const found = (props.participants || []).find(m => m.memberId === val.memberId);
status.value = found?.status || '';
paymentStatus.value = found?.paymentStatus || '';
comment.value = found?.comment || '';
}
});
const comment = ref('');
const status = ref('');
const paymentStatus = ref('');
const error = ref('');
const submitted = ref(false);
const statusOptions = [
{ label: '참가예정', value: '참가예정' },
{ label: '참가확정', value: '참가확정' },
{ label: '취소', value: '취소' }
];
const paymentStatusOptions = [
{ label: '미납', value: '미납' },
{ label: '납부완료', value: '납부완료' }
];
import PublicEventService from '@/services/PublicEventService';
const newGuest = ref({ name: '', phone: '', gender: '' });
const genderOptions = [
{ label: '남', value: '남성' },
{ label: '여', value: '여성' }
];
async function addNewGuest() {
error.value = '';
if (!newGuest.value.name || !newGuest.value.phone || !newGuest.value.gender) {
error.value = '이름, 연락처, 성별을 모두 입력하세요.';
return;
}
try {
// 실제 API 경로/파라미터는 상황에 맞게 수정 필요
const res = await PublicEventService.createGuest(props.publicHash, {
name: newGuest.value.name,
phone: newGuest.value.phone,
gender: newGuest.value.gender,
average: newGuest.value.average
});
if (!res || !res.data || !res.data.memberId) throw new Error('게스트 등록 실패');
// 새 게스트를 멤버 옵션에 추가하고 자동 선택
const guest = {
...res.data,
group: '참가자',
id: res.data.participantId,
participantId: res.data.participantId,
memberId: res.data.memberId
};
// options에 직접 push (reactivity 보장 위해 availableMembers에도 추가)
props.availableMembers.push(guest);
// 참가자 목록에도 추가
props.participants.push(guest);
// 선택값 세팅
selectedMember.value = guest;
selectedParticipantId.value = guest.participantId;
selectedMemberId.value = guest.memberId;
// 입력폼 초기화
newGuest.value = { name: '', phone: '', gender: '', average: '' };
} catch (e) {
error.value = e.message || '게스트 등록 중 오류 발생';
}
}
async function submit() {
submitted.value = true;
error.value = '';
if (!selectedMemberId.value || !status.value || !paymentStatus.value) {
error.value = '모든 항목을 입력해주세요.';
return;
}
try {
const res = await PublicEventService.registerParticipant(props.publicHash, {
participantId: selectedParticipantId.value,
memberId: selectedMemberId.value,
comment: comment.value,
status: status.value,
paymentStatus: paymentStatus.value
});
if (!res.ok) throw new Error('참가 신청에 실패했습니다.');
emits('updated');
emits('close');
} catch (e) {
error.value = e.message;
}
}
</script>
<style scoped>
</style>