게스트-기능 #2

Merged
jaybe merged 4 commits from 게스트-기능 into main 2025-05-08 16:24:42 +00:00
28 changed files with 2522 additions and 194 deletions
Showing only changes of commit 1684fbf5d1 - Show all commits
+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>