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