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 adminRoutes = require('./routes/adminRoutes');
const authRoutes = require('./routes/authRoutes'); const authRoutes = require('./routes/authRoutes');
const eventRoutes = require('./routes/eventRoutes'); const eventRoutes = require('./routes/eventRoutes');
const publicEventRoutes = require('./routes/publicEventRoutes');
const clubRoutes = require('./routes/clubRoutes'); const clubRoutes = require('./routes/clubRoutes');
const userRoutes = require('./routes/userRoutes'); const userRoutes = require('./routes/userRoutes');
const notificationRoutes = require('./routes/notificationRoutes'); const notificationRoutes = require('./routes/notificationRoutes');
@@ -75,6 +76,7 @@ app.use('/api/auth', authRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/club', clubRoutes); app.use('/api/club', clubRoutes);
app.use('/api/club/events', eventRoutes); app.use('/api/club/events', eventRoutes);
app.use('/api/public', publicEventRoutes);
app.use('/api/features', featureRoutes); app.use('/api/features', featureRoutes);
app.use('/api/subscriptions', subscriptionRoutes); app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
+1 -1
View File
@@ -18,7 +18,7 @@ const sequelize = new Sequelize(
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT, port: process.env.DB_PORT,
dialect: 'mysql', dialect: 'mysql',
logging: process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화 logging: false, //process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화
pool: { pool: {
max: 5, max: 5,
min: 0, min: 0,
+267 -4
View File
@@ -141,6 +141,22 @@ exports.createEvent = async (req, res) => {
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' }); return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
} }
const endDateVal = endDate ? new Date(endDate) : null; 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({ const event = await Event.create({
clubId, clubId,
title, title,
@@ -153,7 +169,9 @@ exports.createEvent = async (req, res) => {
entryFee: entryFee || 0, entryFee: entryFee || 0,
gameCount: gameCount || 3, gameCount: gameCount || 3,
laneCount: laneCount || 1, laneCount: laneCount || 1,
status: status || '활성' status: status || '활성',
publicHash,
accessPassword
}); });
const createdEvent = await Event.findOne({ 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) => { exports.updateEvent = async (req, res) => {
try { try {
@@ -194,7 +454,9 @@ exports.updateEvent = async (req, res) => {
entryFee, entryFee,
gameCount, gameCount,
laneCount, laneCount,
status status,
publicHash,
accessPassword
} = req.body; } = req.body;
const eventId = req.body.id; const eventId = req.body.id;
let endDate = req.body.endDate; let endDate = req.body.endDate;
@@ -228,9 +490,10 @@ exports.updateEvent = async (req, res) => {
entryFee: entryFee !== undefined ? entryFee : event.entryFee, entryFee: entryFee !== undefined ? entryFee : event.entryFee,
gameCount: gameCount || event.gameCount, gameCount: gameCount || event.gameCount,
laneCount: laneCount || event.laneCount, laneCount: laneCount || event.laneCount,
status: status || event.status status: status || event.status,
publicHash: publicHash || event.publicHash,
accessPassword: accessPassword || null
}; };
await event.update(updateData); await event.update(updateData);
const updatedEvent = await Event.findOne({ 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: '정기전', defaultValue: '정기전',
comment: '이벤트 유형' comment: '이벤트 유형'
}, },
publicHash: {
type: DataTypes.STRING,
allowNull: true,
comment: '공개 URL 해시'
},
accessPassword: {
type: DataTypes.STRING,
allowNull: true,
comment: '공개 URL 비밀번호'
},
startDate: { startDate: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
+5
View File
@@ -35,6 +35,11 @@ const EventParticipant = sequelize.define('EventParticipant', {
}, },
comment: '이벤트 팀 ID' comment: '이벤트 팀 ID'
}, },
comment: {
type: DataTypes.STRING,
allowNull: true,
comment: '비고'
},
status: { status: {
type: DataTypes.ENUM('참가예정', '참가확정', '취소'), type: DataTypes.ENUM('참가예정', '참가확정', '취소'),
allowNull: false, allowNull: false,
+1 -1
View File
@@ -39,7 +39,7 @@ const Member = sequelize.define('Member', {
comment: '성별' comment: '성별'
}, },
memberType: { memberType: {
type: DataTypes.ENUM('정회원', '준회원', '운영진', '모임장'), type: DataTypes.ENUM('정회원', '준회원', '게스트', '운영진', '모임장'),
allowNull: false, allowNull: false,
defaultValue: '준회원', defaultValue: '준회원',
comment: '회원 유형' comment: '회원 유형'
+2 -2
View File
@@ -27,8 +27,8 @@ const syncModels = async () => {
// await sequelize.query('SET FOREIGN_KEY_CHECKS = 1'); // await sequelize.query('SET FOREIGN_KEY_CHECKS = 1');
// 운영 alter: true - 구조변경만 alter처리 // 운영 alter: true - 구조변경만 alter처리
// await sequelize.sync({ alter: true }); await sequelize.sync({ alter: true });
await sequelize.sync(); // await sequelize.sync();
} catch (error) { } catch (error) {
console.error('모델 동기화 중 오류 발생:', 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); router.get('/user/events', authenticateJWT, requireFeature('event_management'), eventController.getUserEvents);
// 공개 URL(publicHash) 재생성
router.patch('/:id/publicHash', eventController.regeneratePublicHash);
module.exports = router; 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/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15", "@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@vuelidate/core": "^2.0.3", "@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4", "@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2", "axios": "^1.8.2",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"primeflex": "^3.3.1", "primeflex": "^3.3.1",
"primeicons": "^6.0.1", "primeicons": "^7.0.0",
"primevue": "^3.49.1", "primelocale": "^2.1.2",
"primevue": "^4.3.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5", "vee-validate": "^4.12.5",
"vue": "^3.5.13", "vue": "^3.5.13",
@@ -1045,6 +1049,87 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@rollup/pluginutils": {
"version": "5.1.4", "version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
@@ -2561,6 +2646,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2844,18 +2935,35 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/primeicons": { "node_modules/primeicons": {
"version": "6.0.1", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==", "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/primevue": { "node_modules/primelocale": {
"version": "3.53.1", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.53.1.tgz", "resolved": "https://registry.npmjs.org/primelocale/-/primelocale-2.1.2.tgz",
"integrity": "sha512-Bp4peZPdhfKYXwvtsOGGh5dfgmTelm+LZEZKGs/c5mOHhsUJ6xi3EcOZoQVI6oklS946ayMQvgD5L0S7itGO0g==", "integrity": "sha512-sTDkqu/QBwpqzq86oZGXaU/QbPLKLZ3Qix2gbqHXr410B7tfQCx39HSWjM9Hsnpzqn9KVdXJzk1DH1yW1iNNPg==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "engines": {
"vue": "^3.0.0" "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": { "node_modules/property-expr": {
+6 -2
View File
@@ -15,16 +15,20 @@
"@fullcalendar/interaction": "^6.1.15", "@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15", "@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@vuelidate/core": "^2.0.3", "@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4", "@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2", "axios": "^1.8.2",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"primeflex": "^3.3.1", "primeflex": "^3.3.1",
"primeicons": "^6.0.1", "primeicons": "^7.0.0",
"primevue": "^3.49.1", "primelocale": "^2.1.2",
"primevue": "^4.3.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5", "vee-validate": "^4.12.5",
"vue": "^3.5.13", "vue": "^3.5.13",
+36 -21
View File
@@ -1,12 +1,12 @@
<template> <template>
<div class="app"> <template v-if="isRouterReady">
<AppPublicLayout v-if="isPublicLayout">
<router-view />
</AppPublicLayout>
<div v-else class="app">
<AppHeader /> <AppHeader />
<div class="app-content"> <div class="app-content">
<!-- 사이드바 오버레이 추가 -->
<div v-if="isSidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div> <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 }"> <aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': isSidebarVisible, 'mobile-hidden': !isSidebarVisible && isMobileView }">
<div class="sidebar-content"> <div class="sidebar-content">
<!-- 클럽 정보 --> <!-- 클럽 정보 -->
@@ -26,7 +26,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 클럽 선택 기능 (관리자 또는 클럽이 2 이상) --> <!-- 클럽 선택 기능 (관리자 또는 클럽이 2 이상) -->
<div v-if="isAdmin || clubs.length > 1" class="club-selector"> <div v-if="isAdmin || clubs.length > 1" class="club-selector">
<label for="club-select">클럽 선택:</label> <label for="club-select">클럽 선택:</label>
@@ -34,7 +33,6 @@
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option> <option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
</select> </select>
</div> </div>
<!-- 커스텀 사이드바 메뉴 --> <!-- 커스텀 사이드바 메뉴 -->
<div class="custom-sidebar-menu"> <div class="custom-sidebar-menu">
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex"> <template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
@@ -44,7 +42,6 @@
<span>{{ menuGroup.label }}</span> <span>{{ menuGroup.label }}</span>
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i> <i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</div> </div>
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length"> <div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
<router-link <router-link
v-for="(item, itemIndex) in menuGroup.items" v-for="(item, itemIndex) in menuGroup.items"
@@ -61,7 +58,6 @@
</div> </div>
</div> </div>
</template> </template>
<!-- 그룹이 없는 단일 메뉴 아이템 --> <!-- 그룹이 없는 단일 메뉴 아이템 -->
<template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex"> <template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex">
<router-link <router-link
@@ -78,23 +74,30 @@
</div> </div>
</div> </div>
</aside> </aside>
<main class="main-content" :class="{ 'full-width': !isLoggedIn }"> <main class="main-content" :class="{ 'full-width': !isLoggedIn }">
<router-view /> <router-view />
</main> </main>
</div> </div>
<footer> <footer>
<p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p> <p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
</footer> </footer>
</div> </div>
</template>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, provide } from 'vue'; 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 AppHeader from './components/AppHeader.vue';
import AppPublicLayout from './layouts/AppPublicLayout.vue';
import apiClient from './services/api'; 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'; import authService from './services/authService';
// 상태 변수 // 상태 변수
@@ -111,7 +114,7 @@ const selectedClubId = ref(localStorage.getItem('clubId') || null); // 선택된
const selectedClub = ref(null); const selectedClub = ref(null);
const memberCount = ref(0); const memberCount = ref(0);
const ownerName = ref(''); const ownerName = ref('');
const isRouterReady = ref(false);
const userRole = computed(() => user.value?.role || ''); const userRole = computed(() => user.value?.role || '');
const isAdmin = computed(() => userRole.value === 'admin' || userRole.value === 'superadmin'); const isAdmin = computed(() => userRole.value === 'admin' || userRole.value === 'superadmin');
@@ -156,6 +159,7 @@ const initializeExpandedGroups = () => {
// 클럽 목록 가져오기 // 클럽 목록 가져오기
const fetchClubs = async () => { const fetchClubs = async () => {
if (isPublicLayout.value) return;
try { try {
const userId = user.value?.id; const userId = user.value?.id;
const userRole = localStorage.getItem('userRole'); const userRole = localStorage.getItem('userRole');
@@ -182,6 +186,7 @@ const fetchClubs = async () => {
// 클럽 정보 가져오기 // 클럽 정보 가져오기
const fetchClubInfo = async () => { const fetchClubInfo = async () => {
if (isPublicLayout.value) return;
try { try {
const response = await apiClient.post(`/api/club`, { clubId: selectedClubId.value ?? clubs.value[0].id }); 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) => { const setSelectedClub = async (club) => {
if (isPublicLayout.value) return;
// 1. localStorage에 저장 // 1. localStorage에 저장
localStorage.setItem('clubId', club.id); localStorage.setItem('clubId', club.id);
localStorage.setItem('userClubRole', club.memberType || ''); localStorage.setItem('userClubRole', club.memberType || '');
@@ -237,6 +243,7 @@ const onClubChange = async () => {
// 메뉴 아이템 로드 // 메뉴 아이템 로드
const loadMenuItems = async () => { const loadMenuItems = async () => {
if (isPublicLayout.value) return;
try { try {
const role = localStorage.getItem('userRole'); const role = localStorage.getItem('userRole');
const clubId = selectedClubId.value; const clubId = selectedClubId.value;
@@ -324,10 +331,19 @@ const handleUserLoggedIn = async () => {
// 컴포넌트 마운트 시 데이터 로드 // 컴포넌트 마운트 시 데이터 로드
onMounted(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(); await loadUserData();
const clubId = localStorage.getItem('clubId'); const clubId = localStorage.getItem('clubId');
if (clubId) { if (clubId) {
// 서버 세션에 clubId를 동기화
apiClient.post('/api/club/select-club', { clubId }); apiClient.post('/api/club/select-club', { clubId });
} }
if (isLoggedIn.value) { if (isLoggedIn.value) {
@@ -335,14 +351,8 @@ onMounted(async () => {
await fetchClubInfo(); await fetchClubInfo();
loadMenuItems(); loadMenuItems();
} }
// 화면 크기 변경 감지
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
// 로그인 성공 이벤트 감지
window.addEventListener('user-logged-in', handleUserLoggedIn); window.addEventListener('user-logged-in', handleUserLoggedIn);
// 초기 expandedGroups 설정
initializeExpandedGroups(); initializeExpandedGroups();
}); });
@@ -352,7 +362,12 @@ onBeforeUnmount(() => {
window.removeEventListener('user-logged-in', handleUserLoggedIn); window.removeEventListener('user-logged-in', handleUserLoggedIn);
}); });
</script> </script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
<style scoped> <style scoped>
/* App.vue 스타일 */ /* App.vue 스타일 */
.app { .app {
+2 -1
View File
@@ -36,6 +36,7 @@
--section-gap: 160px; --section-gap: 160px;
} }
/*
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--color-background: var(--vt-c-black); --color-background: var(--vt-c-black);
@@ -49,13 +50,13 @@
--color-text: var(--vt-c-text-dark-2); --color-text: var(--vt-c-text-dark-2);
} }
} }
*/
*, *,
*::before, *::before,
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
font-weight: normal;
} }
body { body {
@@ -24,6 +24,14 @@
<p><strong>시작일:</strong> {{ formattedStartDate }}</p> <p><strong>시작일:</strong> {{ formattedStartDate }}</p>
<p><strong>종료일:</strong> {{ formattedEndDate }}</p> <p><strong>종료일:</strong> {{ formattedEndDate }}</p>
<p><strong>장소:</strong> {{ eventModel.location }}</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>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<p><strong>최대 참가자 :</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p> <p><strong>최대 참가자 :</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p>
@@ -81,7 +89,6 @@ import TabPanel from 'primevue/tabpanel';
import Badge from 'primevue/badge'; import Badge from 'primevue/badge';
import Button from 'primevue/button'; import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import ParticipantManagement from './ParticipantManagement.vue'; import ParticipantManagement from './ParticipantManagement.vue';
import ScoreManagement from './ScoreManagement.vue'; import ScoreManagement from './ScoreManagement.vue';
@@ -94,6 +101,11 @@ const props = defineProps({
getStatusName: { type: Function, required: true }, getStatusName: { type: Function, required: true },
getStatusSeverity: { type: Function, required: true }, getStatusSeverity: { type: Function, required: true },
formatDate: { type: Function, required: true }, formatDate: { type: Function, required: true },
// (권장) 상태명이 매칭 안 될 경우 실제 상태 문자열 반환
// getStatusName 함수 예시:
// (status) => STATUS_LABELS[status] || status
availableMembers: { type: Array, default: () => [] } availableMembers: { type: Array, default: () => [] }
}); });
@@ -103,6 +115,20 @@ const eventModel = computed(() => props.event || {});
const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate)); const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate));
const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDate)); 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 = () => { const hideDialog = () => {
emit('hide'); emit('hide');
}; };
+66 -35
View File
@@ -9,84 +9,82 @@
> >
<div class="grid"> <div class="grid">
<div class="col-12"> <div class="col-12">
<div class="field"> <label for="title">이벤트 제목 *</label>
<label for="title">제목 *</label> <InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" variant="filled" required autofocus />
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" required autofocus />
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small> <small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
</div> </div>
</div>
<div class="col-12"> <div class="col-12">
<div class="field"> <label for="description">이벤트 설명</label>
<label for="description">설명</label> <Textarea id="description" v-model="eventModel.description" variant="filled" rows="3" autoResize class="w-full" />
<Textarea id="description" v-model="eventModel.description" rows="3" autoResize />
</div>
</div> </div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field">
<label for="eventType">이벤트 유형 *</label> <label for="eventType">이벤트 유형 *</label>
<Dropdown id="eventType" v-model="eventModel.eventType" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType}" /> <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> <small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</div> </div>
</div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field">
<label for="status">상태 *</label> <label for="status">상태 *</label>
<Dropdown id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status}" /> <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> <small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</div> </div>
</div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field">
<label for="startDate">시작일 *</label> <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}" /> <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> <small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</div> </div>
</div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field">
<label for="endDate">종료일</label> <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}" /> <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}" />
</div> <small class="p-error" v-if="submitted && !eventModel.endDate">종료일은 필수입니다.</small>
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="field">
<label for="location">장소</label> <label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location}" /> <InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location, 'w-full': true}" />
</div>
</div> </div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field">
<label for="maxParticipants">최대 참가자 </label> <label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" showButtons /> <InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" class="w-full" />
</div>
</div> </div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field">
<label for="gameCount">게임 </label> <label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" showButtons /> <InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" class="w-full" />
</div>
</div> </div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field">
<label for="participantFee">참가비</label> <label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.entryFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" /> <InputNumber id="participantFee" v-model="eventModel.participantFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" class="w-full" />
</div>
</div> </div>
<div class="col-12 md:col-6"> <div class="col-12 md:col-6">
<div class="field"> <label for="registrationDeadline">신청마감</label>
<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}" />
<Calendar id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" />
</div> </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>
</div> </div>
@@ -99,6 +97,7 @@
<script setup> <script setup>
import { ref, watch, toRefs, computed } from 'vue'; import { ref, watch, toRefs, computed } from 'vue';
import InputGroup from 'primevue/inputgroup';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const statusOptions = [ const statusOptions = [
@@ -142,6 +141,10 @@ const eventTypeOptions = [
// 이벤트 모델 // 이벤트 모델
const eventModel = ref({ ...props.event }); const eventModel = ref({ ...props.event });
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const loadingHash = ref(false);
// 날짜 형식 변환 // 날짜 형식 변환
const formatDate = (date) => { const formatDate = (date) => {
@@ -160,6 +163,26 @@ watch(() => props.event, (newValue) => {
eventModel.value = formattedEvent; eventModel.value = formattedEvent;
}, { deep: true }); }, { 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 saveEvent = () => {
const eventData = { ...eventModel.value }; const eventData = { ...eventModel.value };
@@ -172,6 +195,7 @@ const saveEvent = () => {
if (eventData.registrationDeadline) { if (eventData.registrationDeadline) {
eventData.registrationDeadline = dayjs(eventData.registrationDeadline).toDate(); eventData.registrationDeadline = dayjs(eventData.registrationDeadline).toDate();
} }
emit('save', eventData); emit('save', eventData);
}; };
@@ -223,4 +247,11 @@ const hideDialog = () => {
:deep(.p-inputtext) { :deep(.p-inputtext) {
width: 100%; width: 100%;
} }
.public-hash-btn.p-button-icon-only {
min-width:28px;
height:28px;
padding:0;
font-size:1.1em;
}
</style> </style>
@@ -72,6 +72,7 @@
:max="300" :max="300"
:placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'" :placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'"
@update:modelValue="() => onScoreChange(game)" @update:modelValue="() => onScoreChange(game)"
class="w-full"
/> />
</div> </div>
</div> </div>
@@ -85,6 +86,7 @@
:max="300" :max="300"
placeholder="핸디캡 입력" placeholder="핸디캡 입력"
@update:modelValue="() => onScoreChange(game)" @update:modelValue="() => onScoreChange(game)"
class="w-full"
/> />
</div> </div>
</div> </div>
@@ -44,7 +44,7 @@
<span class="badge bg-primary text-white mr-1">{{ mIdx + 1 }}</span> <span class="badge bg-primary text-white mr-1">{{ mIdx + 1 }}</span>
<span>{{ member.name }}</span> <span>{{ member.name }}</span>
<span v-if="getMemberAverage(member) !== null" class="text-xs text-gray-700 ml-1">({{ getMemberAverage(member) }})</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> </li>
</template> </template>
</draggable> </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 { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './assets/base.css'
// dayjs configuration // dayjs configuration
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -14,8 +15,7 @@ dayjs.tz.setDefault('Asia/Seoul')
// PrimeVue // PrimeVue
import PrimeVue from 'primevue/config' import PrimeVue from 'primevue/config'
import 'primevue/resources/themes/lara-light-blue/theme.css' import Aura from '@primeuix/themes/aura';
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css' import 'primeicons/primeicons.css'
import 'primeflex/primeflex.css' import 'primeflex/primeflex.css'
@@ -25,11 +25,10 @@ import InputText from 'primevue/inputtext'
import DataTable from 'primevue/datatable' import DataTable from 'primevue/datatable'
import Column from 'primevue/column' import Column from 'primevue/column'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import Dropdown from 'primevue/dropdown' import Select from 'primevue/select'
import Toast from 'primevue/toast' import Toast from 'primevue/toast'
import ToastService from 'primevue/toastservice' import ToastService from 'primevue/toastservice'
import Card from 'primevue/card' import Card from 'primevue/card'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel' import TabPanel from 'primevue/tabpanel'
import Menu from 'primevue/menu' import Menu from 'primevue/menu'
import Menubar from 'primevue/menubar' import Menubar from 'primevue/menubar'
@@ -40,29 +39,45 @@ import Checkbox from 'primevue/checkbox'
import Password from 'primevue/password' import Password from 'primevue/password'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import Badge from 'primevue/badge' import Badge from 'primevue/badge'
import Calendar from 'primevue/calendar'
import InputNumber from 'primevue/inputnumber' import InputNumber from 'primevue/inputnumber'
import Divider from 'primevue/divider' import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner' 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) const app = createApp(App)
// Register PrimeVue // 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(ToastService)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
// Register custom components
app.component('AppHeader', AppHeader);
// Register PrimeVue Components // Register PrimeVue Components
app.component('Button', Button) app.component('Button', Button)
app.component('InputText', InputText) app.component('InputText', InputText)
app.component('DataTable', DataTable) app.component('DataTable', DataTable)
app.component('Column', Column) app.component('Column', Column)
app.component('Dialog', Dialog) app.component('Dialog', Dialog)
app.component('Dropdown', Dropdown) app.component('Select', Select)
app.component('Toast', Toast) app.component('Toast', Toast)
app.component('Card', Card) app.component('Card', Card)
app.component('TabView', TabView)
app.component('TabPanel', TabPanel) app.component('TabPanel', TabPanel)
app.component('Menu', Menu) app.component('Menu', Menu)
app.component('Menubar', Menubar) app.component('Menubar', Menubar)
@@ -73,9 +88,11 @@ app.component('Checkbox', Checkbox)
app.component('Password', Password) app.component('Password', Password)
app.component('Textarea', Textarea) app.component('Textarea', Textarea)
app.component('Badge', Badge) app.component('Badge', Badge)
app.component('Calendar', Calendar)
app.component('InputNumber', InputNumber) app.component('InputNumber', InputNumber)
app.component('Divider', Divider) app.component('Divider', Divider)
app.component('ProgressSpinner', ProgressSpinner) app.component('ProgressSpinner', ProgressSpinner)
app.component('Datepicker', Datepicker)
app.component('Tag', Tag)
app.component('FloatLabel', FloatLabel);
app.mount('#app') app.mount('#app')
+8
View File
@@ -22,12 +22,20 @@ const UserClubCreate = () => import('@/views/club/ClubCreate.vue');
// 구독 관리 컴포넌트 import // 구독 관리 컴포넌트 import
const ClubSubscriptionManagement = () => import('@/views/club/subscription/SubscriptionManagement.vue'); const ClubSubscriptionManagement = () => import('@/views/club/subscription/SubscriptionManagement.vue');
const EventPublicPage = () => import('@/views/event/EventPublicPage.vue');
const routes = [ const routes = [
// 공개 경로 // 공개 경로
{ {
path: '/', path: '/',
redirect: '/login' redirect: '/login'
}, },
{
path: '/public/:publicHash',
name: 'EventPublicPage',
component: EventPublicPage,
meta: { requiresAuth: false, layout: 'public' }
},
{ {
path: '/login', path: '/login',
name: '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" v-model:filters="filters"
class="mt-4" 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%"> <Column field="eventType" header="유형" sortable style="width: 10%">
<template #body="slotProps"> <template #body="slotProps">
<Badge :value="getEventTypeName(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" /> <Badge :value="getEventTypeName(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" />
</template> </template>
<template #filter="{ filterModel, filterCallback }"> <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> </template>
</Column> </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%"> <Column field="startDate" header="시작일" sortable style="width: 12%">
<template #body="slotProps"> <template #body="slotProps">
{{ formatDate(slotProps.data.startDate) }} {{ formatDate(slotProps.data.startDate) }}
</template> </template>
<template #filter="{ filterModel, filterCallback }"> <template #filter="{ filterModel, filterCallback }">
<Calendar v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" /> <Datepicker v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
</template>
</Column>
<Column field="endDate" header="종료일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.endDate) }}
</template> </template>
</Column> </Column>
<Column field="location" header="장소" sortable style="width: 15%"></Column> <Column field="location" header="장소" sortable style="width: 15%"></Column>
@@ -236,15 +231,20 @@ const getEventTypeName = (type) => {
}; };
// 이벤트 상태 이름 반환 // 이벤트 상태 이름 반환
const getStatusName = (status) => { const STATUS_LABELS = {
switch (status) { '준비': '준비',
case '준비': return '준비'; '진행중': '진행중',
case '진행중': return '진행중'; '완료': '완료',
case '완료': return '완료'; '취소': '취소',
case '취소': return '취소'; '예정됨': '예정됨',
default: return '알 수 없음'; 'in_progress': '진행중',
} 'completed': '완료',
'cancelled': '취소',
'활성': '활성',
'비활성': '비활성',
'삭제': '삭제',
}; };
const getStatusName = (status) => STATUS_LABELS[status] || status;
// 이벤트 상태에 따른 Badge 스타일 // 이벤트 상태에 따른 Badge 스타일
const getStatusSeverity = (status) => { const getStatusSeverity = (status) => {
@@ -468,4 +468,22 @@ const navigateToCalendar = () => {
:deep(.p-column-filter) { :deep(.p-column-filter) {
width: 100%; 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> </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>