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