게스트-기능 #2

Merged
jaybe merged 4 commits from 게스트-기능 into main 2025-05-08 16:24:42 +00:00
47 changed files with 3065 additions and 493 deletions
+2
View File
@@ -7,6 +7,7 @@ const socketIo = require('socket.io');
const adminRoutes = require('./routes/adminRoutes');
const authRoutes = require('./routes/authRoutes');
const eventRoutes = require('./routes/eventRoutes');
const publicEventRoutes = require('./routes/publicEventRoutes');
const clubRoutes = require('./routes/clubRoutes');
const userRoutes = require('./routes/userRoutes');
const notificationRoutes = require('./routes/notificationRoutes');
@@ -75,6 +76,7 @@ app.use('/api/auth', authRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/club', clubRoutes);
app.use('/api/club/events', eventRoutes);
app.use('/api/public', publicEventRoutes);
app.use('/api/features', featureRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/users', userRoutes);
+1 -1
View File
@@ -18,7 +18,7 @@ const sequelize = new Sequelize(
host: process.env.DB_HOST,
port: process.env.DB_PORT,
dialect: 'mysql',
logging: process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화
logging: false, //process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화
pool: {
max: 5,
min: 0,
+332 -46
View File
@@ -141,6 +141,22 @@ exports.createEvent = async (req, res) => {
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
}
const endDateVal = endDate ? new Date(endDate) : null;
// 랜덤 해시 생성 함수
const generateRandomHash = async () => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let hash;
let exists = true;
while (exists) {
hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
// 중복 체크
exists = await Event.findOne({ where: { publicHash: hash } });
}
return hash;
};
const publicHash = await generateRandomHash();
const accessPassword = req.body.accessPassword || null;
const event = await Event.create({
clubId,
title,
@@ -153,7 +169,9 @@ exports.createEvent = async (req, res) => {
entryFee: entryFee || 0,
gameCount: gameCount || 3,
laneCount: laneCount || 1,
status: status || '활성'
status: status || '활성',
publicHash,
accessPassword
});
const createdEvent = await Event.findOne({
@@ -180,6 +198,248 @@ exports.createEvent = async (req, res) => {
}
};
// 공개 이벤트 조회 및 비밀번호 인증
exports.getEventByPublicHash = async (req, res) => {
const { publicHash } = req.params;
const { password } = req.body || {};
try {
// 1. 이벤트 조회
const event = await Event.findOne({
where: { publicHash },
include: [{
model: Club,
attributes: ['id', 'name']
}]
});
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
// 2. 비밀번호 필요 여부 및 인증
if (event.accessPassword) {
if (!password) return res.status(401).json({ needPassword: true, message: '비밀번호 필요' });
if (event.accessPassword !== password) return res.status(401).json({ needPassword: true, message: '비밀번호 불일치' });
}
// 3. 전체 회원 명단 추출 (clubId 사용, clubId는 응답에 포함 X)
const allMembers = await Member.findAll({
where: { clubId: event.clubId },
attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average']
});
// 4. 참가자 명단 추출 (참가자 테이블 + Member 조인)
const participants = await EventParticipant.findAll({
where: { eventId: event.id, status: { [Op.ne]: '취소' } },
include: [
{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] }
]
});
// 5. 참가 신청 가능 명단(아직 참가하지 않은 회원)
// Sequelize 인스턴스에서 dataValues 추출
const allMemberObjs = allMembers.map(m => m.dataValues);
// '취소'가 아닌 status가 하나라도 있으면 참가자로 간주 (중복 제거)
const participantMemberIds = [
...new Set(
participants
.filter(p => p.dataValues.status !== '취소')
.map(p => parseInt(p.dataValues.memberId))
)
];
const availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId)));
// 6. 팀 정보 등 추가 조회 (기존 코드 유지)
const teams = await EventTeam.findAll({
where: { eventId: event.id },
order: [['teamNumber', 'ASC']],
include: [
{
model: EventTeamMember,
include: [
{
model: EventParticipant,
include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }]
}
]
}
]
});
// 점수
const scores = await EventScore.findAll({ where: { eventId: event.id } });
// 팀별 그룹핑
let teamList = [];
if (teams.length > 0) {
teamList = teams.map(team => ({
teamId: team.id,
teamNumber: team.teamNumber,
handicap: team.handicap,
members: (team.EventTeamMembers || []).map(tm => {
const participant = tm.EventParticipant;
const member = participant?.Member;
const scoreObj = scores.find(s => s.participantId === participant?.id);
return {
participantId: participant?.id,
memberId: member?.memberId,
name: member?.name || `참가자 ${participant?.id}`,
gender: member?.gender,
memberType: member?.memberType,
average: member?.average || 0,
handicap: member?.handicap || 0,
status: participant?.status,
paymentStatus: participant?.paymentStatus,
comment: participant?.comment,
isGuest: member?.memberType === '게스트',
score: scoreObj ? scoreObj.score : null
};
})
}));
}
// 참가자 리스트(팀 없는 경우)
let participantList = [];
participantList = participants.map(p => {
const member = p.Member;
const scoreObj = scores.find(s => s.participantId === p.id);
return {
participantId: p.id,
memberId: p.memberId,
name: member?.name || `참가자 ${p.id}`,
gender: member?.gender,
memberType: member?.memberType,
handicap: member?.handicap || 0,
average: member?.average || 0,
status: p.status,
paymentStatus: p.paymentStatus,
comment: p.comment,
isGuest: member?.memberType === '게스트',
score: scoreObj ? scoreObj.score : null
};
});
// 반환 데이터
res.json({
clubName: event.Club.name,
event: {
id: event.id,
title: event.title,
description: event.description,
eventType: event.eventType,
startDate: event.startDate,
endDate: event.endDate,
location: event.location,
gameCount: event.gameCount,
registrationDeadline: event.registrationDeadline,
maxParticipants: event.maxParticipants,
participantFee: event.participantFee,
status: event.status
},
teams: teamList,
participants: participantList,
availableMembers,
needPassword: !!event.accessPassword
});
} catch (e) {
console.error('공개 이벤트 조회 오류:', e);
res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' });
}
};
// 공개 URL(publicHash) 재생성
exports.regeneratePublicHash = async (req, res) => {
const { id } = req.params;
try {
const event = await Event.findOne({ where: { id } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
// 랜덤 해시 생성 (중복 없는 값)
const generateRandomHash = async () => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let hash;
let exists = true;
while (exists) {
hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
exists = await Event.findOne({ where: { publicHash: hash } });
}
return hash;
};
const newHash = await generateRandomHash();
await event.update({ publicHash: newHash });
res.json({ publicHash: newHash });
} catch (e) {
console.error('공개 URL 재생성 오류:', e);
res.status(500).json({ message: '공개 URL 재생성 중 오류가 발생했습니다.' });
}
};
// 공개 참가자 등록/수정
exports.registerPublicParticipant = async (req, res) => {
const { publicHash } = req.params;
const { participantId, memberId, status, paymentStatus, comment } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
// 기존 참가자 있는지 검사(이름+연락처 기준)
let member = await Member.findOne({ where: { id:memberId } });
// if (!member) {
// member = await Member.create({ memberId, memberType: '일반' });
// }
let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } });
if (!participant) {
participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus });
} else {
await participant.update({ comment, status, paymentStatus });
}
res.json({ message: '참가 신청/수정이 완료되었습니다.' });
} catch (e) {
console.error('공개 참가자 등록 오류:', e);
res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다.' });
}
};
// 공개 게스트 추가
exports.addPublicGuest = async (req, res) => {
const { publicHash } = req.params;
const { name, password } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
if (event.accessPassword) {
if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' });
}
// 게스트 멤버 생성
const member = await Member.create({ name, memberType: '게스트' });
await EventParticipant.create({ eventId: event.id, memberId: member.id });
res.json({ message: '게스트가 추가되었습니다.' });
} catch (e) {
console.error('공개 게스트 추가 오류:', e);
res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' });
}
};
// 공개 점수 수정
exports.updatePublicScore = async (req, res) => {
const { publicHash } = req.params;
const { participantId, score, password } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status !== '준비' && event.status !== '활성') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' });
if (event.accessPassword) {
if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' });
}
let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId } });
if (!eventScore) {
eventScore = await EventScore.create({ eventId: event.id, participantId, score });
} else {
await eventScore.update({ score });
}
res.json({ message: '점수가 저장되었습니다.' });
} catch (e) {
console.error('공개 점수 수정 오류:', e);
res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' });
}
};
// 이벤트 정보 업데이트
exports.updateEvent = async (req, res) => {
try {
@@ -194,7 +454,9 @@ exports.updateEvent = async (req, res) => {
entryFee,
gameCount,
laneCount,
status
status,
publicHash,
accessPassword
} = req.body;
const eventId = req.body.id;
let endDate = req.body.endDate;
@@ -228,9 +490,10 @@ exports.updateEvent = async (req, res) => {
entryFee: entryFee !== undefined ? entryFee : event.entryFee,
gameCount: gameCount || event.gameCount,
laneCount: laneCount || event.laneCount,
status: status || event.status
status: status || event.status,
publicHash: publicHash || event.publicHash,
accessPassword: accessPassword || null
};
await event.update(updateData);
const updatedEvent = await Event.findOne({
@@ -997,14 +1260,30 @@ exports.getEventParticipants = async (req, res) => {
}
};
// 이벤트 참가자 등록
// 참가자 정보 핵심 update 함수 (중복 제거)
async function updateEventParticipantCore({ id, eventId, memberId, clubId, status, paymentStatus, comment }) {
const [updatedCount] = await EventParticipant.update(
{ status, paymentStatus, comment },
{
where: {
id,
eventId,
memberId
}
}
);
return updatedCount;
}
// 이벤트 참가자 등록 (이미 있으면 update, 없으면 create)
exports.addEventParticipant = async (req, res) => {
try {
const {
clubId,
memberId,
status,
paymentStatus
paymentStatus,
comment
} = req.body;
const eventId = req.params.id;
if (!eventId || !clubId || !memberId) {
@@ -1048,51 +1327,58 @@ exports.addEventParticipant = async (req, res) => {
}
});
let participant;
let isUpdate = false;
if (existingParticipant) {
return res.status(400).json({ message: '이미 참가 신청한 사용자입니다.' });
}
// 참가자 수 제한 확인
if (event.maxParticipants > 0) {
const currentParticipants = await EventParticipant.count({
where: {
eventId,
status: { [Op.ne]: '취소' }
}
// 이미 있으면 update (핵심 update 함수 활용)
await updateEventParticipantCore({
id: existingParticipant.id,
eventId,
memberId,
clubId,
status: status || existingParticipant.status,
paymentStatus: paymentStatus || existingParticipant.paymentStatus,
comment: comment || existingParticipant.comment
});
participant = await EventParticipant.findOne({
where: { id: existingParticipant.id },
include: [
{
model: Member,
attributes: ['id', 'name']
}
]
});
isUpdate = true;
} else {
// 없으면 create
participant = await EventParticipant.create({
eventId,
memberId,
status: status || '참가예정',
paymentStatus: paymentStatus || '미납',
comment: comment || null,
createdAt: new Date(),
updatedAt: new Date()
});
participant = await EventParticipant.findOne({
where: { id: participant.id },
include: [
{
model: Member,
attributes: ['id', 'name']
}
]
});
if (currentParticipants >= event.maxParticipants) {
return res.status(400).json({ message: '참가자 수가 초과되었습니다.' });
}
}
const participant = await EventParticipant.create({
eventId,
memberId,
status: status || '참가예정',
paymentStatus: paymentStatus || '미납',
createdAt: new Date(),
updatedAt: new Date()
});
// 참가자 정보 조회
const participantWithDetails = await EventParticipant.findOne({
where: { id: participant.id },
include: [
{
model: Member,
attributes: ['id', 'name']
}
]
});
res.status(201).json({
message: '참가자가 등록되었습니다.',
participant: participantWithDetails
message: isUpdate ? '기존 참가자 정보를 수정했습니다.' : '참가자가 등록되었습니다.',
participant
});
} catch (error) {
console.error('참가자 등록 오류:', error);
res.status(500).json({ message: '참가자 등록 중 오류가 발생했습니다.' });
console.error('참가자 등록/수정 오류:', error);
res.status(500).json({ message: '참가자 등록/수정 중 오류가 발생했습니다.' });
}
};
@@ -1100,7 +1386,7 @@ exports.addEventParticipant = async (req, res) => {
exports.updateEventParticipant = async (req, res) => {
try {
const eventId = req.params.id;
const { id, clubId, memberId, status, paymentStatus } = req.body;
const { id, clubId, memberId, status, paymentStatus, comment } = req.body;
if (!eventId || !clubId || !memberId || !id) {
return res.status(400).json({ message: '이벤트 ID, 클럽 ID, 사용자 ID가 필요합니다.' });
@@ -1121,7 +1407,7 @@ exports.updateEventParticipant = async (req, res) => {
// 참가자 정보 수정
const [updatedCount] = await EventParticipant.update(
{ status, paymentStatus },
{ status, paymentStatus, comment },
{
where: {
id,
+304
View File
@@ -0,0 +1,304 @@
// publicController.js
// 공개 이벤트 관련 API만 담당 (비밀번호 인증, 점수입력 등)
const { Event, EventParticipant, Member, Club, EventScore, ClubMember, EventTeam, EventTeamMember, sequelize } = require('../models');
const { Op } = require('sequelize');
const { signPublicEventToken, verifyPublicEventToken } = require('../utils/publicJwt');
// 공개 이벤트 최초 진입 (토큰 또는 비밀번호 인증)
exports.publicEventEntry = async (req, res) => {
const { publicHash } = req.params;
const { password } = req.body || {};
const auth = req.headers.authorization;
let event;
try {
event = await Event.findOne({
where: { publicHash },
include: [{ model: Club, attributes: ['id', 'name'] }]
});
if (!event) {
return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
}
if (event.status === '취소') {
return res.status(403).json({ message: '취소된 이벤트입니다.' });
}
let token = null;
let isAuthenticated = false;
// 1. 토큰 인증 우선
if (auth && auth.startsWith('Bearer ')) {
try {
const payload = verifyPublicEventToken(auth.slice(7));
if (payload.publicHash === event.publicHash) {
isAuthenticated = true;
}
} catch (e) {
// 토큰 인증 실패: isAuthenticated는 false
}
}
// 2. 토큰 인증 실패 시
if (!isAuthenticated) {
// 비밀번호가 필요한 이벤트
if (event.accessPassword) {
if (!password) {
return res.status(401).json({ message: '비밀번호가 필요합니다.' });
}
if (password !== event.accessPassword) {
return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' });
}
}
// 비밀번호가 없거나(공개 이벤트) or 비밀번호 인증 성공
isAuthenticated = true;
token = signPublicEventToken({ publicHash: event.publicHash });
}
// 3. 인증 성공 시 데이터 반환
if (isAuthenticated) {
const data = await exports.getEventByPublicHash(event, token);
return res.json(data);
}
// 4. 나머지는 모두 인증 실패
return res.status(401).json({ message: '인증이 필요합니다.' });
} catch (e) {
console.error('[publicEventEntry] 공개 이벤트 최초 진입 오류', { publicHash, error: e });
res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' });
}
};
// 공개 이벤트 조회 (내부 데이터 반환 전용)
exports.getEventByPublicHash = async (event, token = null) => {
try {
const allMembers = await Member.findAll({
where: { clubId: event.clubId },
attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average']
});
const participants = await EventParticipant.findAll({
where: { eventId: event.id, status: { [Op.ne]: '취소' } },
include: [ { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] } ]
});
const allMemberObjs = allMembers.map(m => m.dataValues);
const participantMemberIds = [
...new Set(participants.filter(p => p.dataValues.status !== '취소').map(p => parseInt(p.dataValues.memberId)))
];
// 상태별로 미참가자/게스트 추가 제한
let availableMembers = [];
if (event.status === '준비') {
availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId)));
} // 준비 아닐 땐 빈 배열(신청/게스트 추가 불가)
// 팀 정보
const teams = await EventTeam.findAll({
where: { eventId: event.id },
order: [['teamNumber', 'ASC']],
include: [{
model: EventTeamMember,
include: [{
model: EventParticipant,
where: { status: { [Op.ne]: '취소' } },
include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }]
}]
}]
});
const scores = await EventScore.findAll({ where: { eventId: event.id } });
// 참가자 id → 참가자 객체 Map
const participantMap = new Map();
participants.forEach(p => participantMap.set(p.id, p));
// 팀에 소속된 참가자 id Set
const assignedParticipantIds = new Set();
const teamList = teams.map(team => ({
teamId: team.id,
teamNumber: team.teamNumber,
handicap: team.handicap,
members: (team.EventTeamMembers || []).map(tm => {
const participant = tm.EventParticipant;
if (participant?.id) assignedParticipantIds.add(participant.id);
const member = participant?.Member;
// 각 게임별 점수 배열 생성
const scoresArr = Array(event.gameCount).fill(null);
scores.filter(s => s.participantId === participant?.id).forEach(s => {
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
scoresArr[s.gameNumber - 1] = s.score;
}
});
return {
participantId: participant?.id,
memberId: member?.memberId,
name: member?.name || `참가자 ${participant?.id}`,
gender: member?.gender,
memberType: member?.memberType,
average: member?.average || 0,
handicap: member?.handicap || 0,
status: participant?.status,
paymentStatus: participant?.paymentStatus,
comment: participant?.comment,
isGuest: member?.memberType === '게스트',
scores: scoresArr
};
})
}));
// 팀에 소속되지 않은 참가자(미배정)
const unassignedMembers = participants
.filter(p => !assignedParticipantIds.has(p.id))
.map(p => {
const member = p.Member;
// 각 게임별 점수 배열 생성
const scoresArr = Array(event.gameCount).fill(null);
scores.filter(s => s.participantId === p.id).forEach(s => {
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
scoresArr[s.gameNumber - 1] = s.score;
}
});
return {
participantId: p.id,
memberId: p.memberId,
name: member?.name || `참가자 ${p.id}`,
gender: member?.gender,
memberType: member?.memberType,
handicap: member?.handicap || 0,
average: member?.average || 0,
status: p.status,
paymentStatus: p.paymentStatus,
comment: p.comment,
isGuest: member?.memberType === '게스트',
scores: scoresArr
};
});
let participantList = participants.map(p => {
const member = p.Member;
// 각 게임별 점수 배열 생성
const scoresArr = Array(event.gameCount).fill(null);
scores.filter(s => s.participantId === p.id).forEach(s => {
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
scoresArr[s.gameNumber - 1] = s.score;
}
});
return {
participantId: p.id,
memberId: p.memberId,
name: member?.name || `참가자 ${p.id}`,
gender: member?.gender,
memberType: member?.memberType,
handicap: member?.handicap || 0,
average: member?.average || 0,
status: p.status,
paymentStatus: p.paymentStatus,
comment: p.comment,
isGuest: member?.memberType === '게스트',
scores: scoresArr
};
});
return {
clubName: event.Club.name,
event: {
id: event.id,
title: event.title,
description: event.description,
eventType: event.eventType,
startDate: event.startDate,
endDate: event.endDate,
location: event.location,
gameCount: event.gameCount,
registrationDeadline: event.registrationDeadline,
maxParticipants: event.maxParticipants,
participantFee: event.participantFee,
status: event.status
},
teams: teamList,
participants: participantList,
unassignedMembers,
availableMembers,
needPassword: !!event.accessPassword,
...(token ? { token } : {})
};
} catch (e) {
console.error('공개 이벤트 조회 오류:', e);
throw new Error('이벤트 정보를 가져오는 중 오류가 발생했습니다.');
}
};
// 공개 참가자 등록/수정
exports.registerPublicParticipant = async (req, res) => {
const { publicHash } = req.params;
const { participantId, memberId, status, paymentStatus, comment } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
let member = await Member.findOne({ where: { id: memberId } });
let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } });
if (!participant) {
participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus });
} else {
await participant.update({ comment, status, paymentStatus });
}
res.json({ message: '참가 신청/수정이 완료되었습니다.' });
} catch (e) {
console.error('공개 참가자 등록 오류:', e);
res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다.' });
}
};
// 공개 게스트 추가
exports.addPublicGuest = async (req, res) => {
const { publicHash } = req.params;
const { name, phone, gender, average } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
// publicAuth 미들웨어로 인증됨
const member = await Member.create({
name,
phone,
gender,
clubId: event.clubId,
memberType: '게스트',
average: average || 0
});
const participant = await EventParticipant.create({ eventId: event.id, memberId: member.id });
res.json({
message: '게스트가 추가되었습니다.',
data: {
memberId: member.id,
participantId: participant.id,
name: member.name,
phone: member.phone,
gender: member.gender,
memberType: member.memberType,
handicap: member.handicap,
average: member.average,
group: '미신청'
}
});
} catch (e) {
console.error('공개 게스트 추가 오류:', e);
res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' });
}
};
// 공개 점수 수정
exports.updatePublicScore = async (req, res) => {
const { publicHash } = req.params;
let { participantId, gameNumber, score, handicap, teamNumber } = req.body || {};
score = Number(score);
handicap = Number(handicap) || 0; // undefined/null이면 0
teamNumber = teamNumber ?? null;
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status !== '준비' && event.status !== '활성') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' });
// publicAuth 미들웨어로 인증됨
let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId, gameNumber } });
if (!eventScore) {
eventScore = await EventScore.create({ eventId: event.id, participantId, gameNumber, teamNumber, score, handicap, totalScore: score + handicap });
} else {
await eventScore.update({ teamNumber, score, handicap, totalScore: score + handicap });
}
res.json({ message: '점수가 저장되었습니다.' });
} catch (e) {
console.error('공개 점수 수정 오류:', e);
res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' });
}
};
+13
View File
@@ -0,0 +1,13 @@
// middleware/publicAuth.js
const { verifyPublicEventToken } = require('../utils/publicJwt');
module.exports = (req, res, next) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ message: '토큰 필요' });
try {
req.publicAuth = verifyPublicEventToken(auth.slice(7));
next();
} catch (e) {
return res.status(401).json({ message: '토큰 인증 실패' });
}
};
+21 -10
View File
@@ -5,15 +5,18 @@ const Club = sequelize.define('Club', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '클럽 고유 식별자'
},
name: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '클럽명'
},
description: {
type: DataTypes.TEXT,
allowNull: true
allowNull: true,
comment: '클럽 설명'
},
ownerId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -21,36 +24,44 @@ const Club = sequelize.define('Club', {
references: {
model: 'Users',
key: 'id'
}
},
comment: '클럽장(소유자) 유저 ID'
},
location: {
type: DataTypes.STRING,
allowNull: true
allowNull: true,
comment: '주 활동 지역'
},
contact: {
type: DataTypes.STRING,
allowNull: true
allowNull: true,
comment: '연락처'
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
allowNull: false,
defaultValue: 'active'
defaultValue: 'active',
comment: '클럽 상태'
},
memberCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '회원 수'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '수정일'
}
}, {
tableName: 'Clubs',
comment: '볼링 클럽 정보를 저장하는 테이블',
timestamps: true
});
+15 -7
View File
@@ -5,7 +5,8 @@ const ClubFeature = sequelize.define('ClubFeature', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '클럽-기능 연결 고유 식별자'
},
clubId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,7 +14,8 @@ const ClubFeature = sequelize.define('ClubFeature', {
references: {
model: 'Clubs',
key: 'id'
}
},
comment: '클럽 ID'
},
featureId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -21,27 +23,33 @@ const ClubFeature = sequelize.define('ClubFeature', {
references: {
model: 'Features',
key: 'id'
}
},
comment: '기능 ID'
},
enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
defaultValue: true,
comment: '기능 활성화 여부'
},
expiryDate: {
type: DataTypes.DATE,
allowNull: true
allowNull: true,
comment: '기능 만료일'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '수정일'
}
}, {
tableName: 'ClubFeatures',
comment: '클럽별 활성화된 기능 정보를 저장하는 테이블',
timestamps: true
});
+15 -7
View File
@@ -5,7 +5,8 @@ const ClubSubscription = sequelize.define('ClubSubscription', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '클럽-구독 연결 고유 식별자'
},
clubId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,7 +14,8 @@ const ClubSubscription = sequelize.define('ClubSubscription', {
references: {
model: 'Clubs',
key: 'id'
}
},
comment: '클럽 ID'
},
planId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -21,28 +23,34 @@ const ClubSubscription = sequelize.define('ClubSubscription', {
references: {
model: 'SubscriptionPlans',
key: 'id'
}
},
comment: '구독 플랜 ID'
},
status: {
type: DataTypes.ENUM('active', 'expired', 'suspended'),
defaultValue: 'active',
allowNull: false
allowNull: false,
comment: '구독 상태'
},
startDate: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '구독 시작일'
},
endDate: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '구독 종료일'
},
price: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '구독 가격'
}
}, {
tableName: 'ClubSubscriptions',
comment: '클럽별 구독 정보(플랜 등)를 저장하는 테이블',
timestamps: true
});
+43 -16
View File
@@ -5,7 +5,8 @@ const Event = sequelize.define('Event', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '이벤트 고유 식별자'
},
clubId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,72 +14,98 @@ const Event = sequelize.define('Event', {
references: {
model: 'Clubs',
key: 'id'
}
},
comment: '소속 클럽 ID'
},
title: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '이벤트 제목'
},
description: {
type: DataTypes.TEXT,
allowNull: true
allowNull: true,
comment: '이벤트 설명'
},
eventType: {
type: DataTypes.ENUM('정기전', '번개', '연습', '교류전', '이벤트', '기타'),
allowNull: false,
defaultValue: '정기전'
defaultValue: '정기전',
comment: '이벤트 유형'
},
publicHash: {
type: DataTypes.STRING,
allowNull: true,
comment: '공개 URL 해시'
},
accessPassword: {
type: DataTypes.STRING,
allowNull: true,
comment: '공개 URL 비밀번호'
},
startDate: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '시작일'
},
endDate: {
type: DataTypes.DATE,
allowNull: true
allowNull: true,
comment: '종료일'
},
location: {
type: DataTypes.STRING,
allowNull: true
allowNull: true,
comment: '장소'
},
gameCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 3
defaultValue: 3,
comment: '게임 수'
},
participantFee: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '참가비'
},
maxParticipants: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '최대 참가 인원'
},
registrationDeadline: {
type: DataTypes.DATE,
allowNull: true
allowNull: true,
comment: '참가 신청 마감일'
},
participantCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '참가자 수'
},
status: {
type: DataTypes.ENUM('준비', '활성', '완료', '취소', '삭제'),
allowNull: false,
defaultValue: '활성'
defaultValue: '활성',
comment: '이벤트 상태'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '수정일'
}
}, {
tableName: 'Events',
comment: '클럽 내 이벤트(대회, 모임 등) 정보를 저장하는 테이블',
timestamps: true
});
+25 -6
View File
@@ -5,7 +5,8 @@ const EventParticipant = sequelize.define('EventParticipant', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '이벤트 참가자 고유 식별자'
},
eventId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,7 +14,8 @@ const EventParticipant = sequelize.define('EventParticipant', {
references: {
model: 'Events',
key: 'id'
}
},
comment: '이벤트 ID'
},
memberId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -21,7 +23,8 @@ const EventParticipant = sequelize.define('EventParticipant', {
references: {
model: 'Members',
key: 'id'
}
},
comment: '회원 ID'
},
teamId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -29,20 +32,36 @@ const EventParticipant = sequelize.define('EventParticipant', {
references: {
model: 'EventTeams',
key: 'id'
}
},
comment: '이벤트 팀 ID'
},
comment: {
type: DataTypes.STRING,
allowNull: true,
comment: '비고'
},
status: {
type: DataTypes.ENUM('참가예정', '참가확정', '취소'),
allowNull: false,
defaultValue: '참가예정'
defaultValue: '참가예정',
comment: '참가 상태'
},
paymentStatus: {
type: DataTypes.ENUM('미납', '납부완료'),
allowNull: false,
defaultValue: '미납'
defaultValue: '미납',
comment: '참가비 납부 상태'
}
}, {
tableName: 'EventParticipants',
comment: '이벤트별 참가자 정보를 저장하는 테이블',
indexes: [
{
unique: true,
fields: ['eventId', 'memberId'],
name: 'unique_event_member'
}
],
timestamps: true
});
+16 -7
View File
@@ -5,7 +5,8 @@ const EventScore = sequelize.define('EventScore', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '점수 고유 식별자'
},
eventId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,7 +14,8 @@ const EventScore = sequelize.define('EventScore', {
references: {
model: 'Events',
key: 'id'
}
},
comment: '이벤트 ID'
},
participantId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -21,31 +23,38 @@ const EventScore = sequelize.define('EventScore', {
references: {
model: 'EventParticipants',
key: 'id'
}
},
comment: '이벤트 참가자 ID'
},
teamNumber: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: true,
comment: '팀 번호'
},
gameNumber: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false
allowNull: false,
comment: '게임 번호'
},
score: {
type: DataTypes.INTEGER,
allowNull: false
allowNull: false,
comment: '점수'
},
handicap: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '핸디캡'
},
totalScore: {
type: DataTypes.INTEGER,
allowNull: false
allowNull: false,
comment: '총점'
}
}, {
tableName: 'EventScores',
comment: '이벤트별 참가자 점수 정보를 저장하는 테이블',
timestamps: true
});
+6
View File
@@ -6,25 +6,31 @@ const EventTeam = sequelize.define('EventTeam', {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true,
comment: '이벤트 팀 고유 식별자',
},
eventId: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
comment: '이벤트 ID',
},
teamNumber: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
comment: '팀 번호',
},
name: {
type: DataTypes.STRING,
allowNull: true,
comment: '팀명',
},
handicap: {
type: DataTypes.INTEGER,
allowNull: true,
comment: '팀 핸디캡',
},
}, {
tableName: 'EventTeams',
comment: '이벤트별 팀 정보를 저장하는 테이블',
timestamps: true,
});
+7 -3
View File
@@ -5,20 +5,24 @@ const EventTeamMember = sequelize.define('EventTeamMember', {
eventTeamId: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
primaryKey: true
primaryKey: true,
comment: '이벤트 팀 ID'
},
eventParticipantId: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
primaryKey: true
primaryKey: true,
comment: '이벤트 참가자 ID'
},
order: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 1
defaultValue: 1,
comment: '팀 내 순번'
}
}, {
tableName: 'EventTeamMembers',
comment: '이벤트 팀별 멤버 정보를 저장하는 테이블',
timestamps: false
});
+17 -8
View File
@@ -5,41 +5,50 @@ const Feature = sequelize.define('Feature', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '기능 고유 식별자'
},
code: {
type: DataTypes.STRING,
allowNull: false,
unique: true
unique: true,
comment: '기능 코드'
},
name: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '기능명'
},
description: {
type: DataTypes.TEXT,
allowNull: true
allowNull: true,
comment: '기능 설명'
},
price: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '기능 가격'
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
allowNull: false,
defaultValue: 'active'
defaultValue: 'active',
comment: '기능 상태'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '수정일'
}
}, {
tableName: 'Features',
comment: '클럽에서 제공하는 기능 정보를 저장하는 테이블',
timestamps: true
});
+28 -14
View File
@@ -7,7 +7,8 @@ const Member = sequelize.define('Member', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '회원 고유 식별자'
},
clubId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -15,7 +16,8 @@ const Member = sequelize.define('Member', {
references: {
model: 'Clubs',
key: 'id'
}
},
comment: '소속 클럽 ID'
},
userId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -23,56 +25,68 @@ const Member = sequelize.define('Member', {
references: {
model: 'Users',
key: 'id'
}
},
comment: '유저 ID'
},
name: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '회원 이름'
},
gender: {
type: DataTypes.ENUM('남성', '여성'),
allowNull: false
allowNull: false,
comment: '성별'
},
memberType: {
type: DataTypes.ENUM('정회원', '준회원', '운영진', '모임장'),
type: DataTypes.ENUM('정회원', '준회원', '게스트', '운영진', '모임장'),
allowNull: false,
defaultValue: '준회원'
defaultValue: '준회원',
comment: '회원 유형'
},
handicap: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '핸디캡'
},
average: {
type: DataTypes.FLOAT,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '평균 점수'
},
games: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '게임 수'
},
phone: {
type: DataTypes.STRING,
allowNull: true
allowNull: true,
comment: '전화번호'
},
email: {
type: DataTypes.STRING,
allowNull: true
allowNull: true,
comment: '이메일'
},
joinDate: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
defaultValue: DataTypes.NOW,
comment: '가입일'
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'deleted'),
allowNull: false,
defaultValue: 'active'
defaultValue: 'active',
comment: '회원 상태'
}
}, {
tableName: 'Members',
comment: '클럽 회원 정보를 저장하는 테이블',
timestamps: true
});
Member.associate = (models) => {
+24 -15
View File
@@ -5,7 +5,8 @@ const Menu = sequelize.define('Menu', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '메뉴 고유 식별자'
},
parentId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,19 +14,23 @@ const Menu = sequelize.define('Menu', {
references: {
model: 'Menus', // 실제 테이블명
key: 'id'
}
},
comment: '상위 메뉴 ID'
},
title: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '메뉴명'
},
icon: {
type: DataTypes.STRING,
allowNull: true
allowNull: true,
comment: '아이콘'
},
to: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '라우팅 경로'
},
featureCode: {
type: DataTypes.STRING,
@@ -33,37 +38,41 @@ const Menu = sequelize.define('Menu', {
references: {
model: 'Features',
key: 'code'
}
},
requiredSubscriptionTier: {
type: DataTypes.ENUM('basic', 'standard', 'premium'),
allowNull: true
},
comment: '연결된 기능 코드'
},
role: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'user'
defaultValue: 'user',
comment: '권한 역할'
},
order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '정렬 순서'
},
visible: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
defaultValue: true,
comment: '표시 여부'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '수정일'
}
}, {
tableName: 'Menus',
comment: '서비스 내 메뉴 구조 및 정보를 저장하는 테이블',
timestamps: true
});
+21 -10
View File
@@ -5,7 +5,8 @@ const Notification = sequelize.define('Notification', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '알림 고유 식별자'
},
userId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,46 +14,56 @@ const Notification = sequelize.define('Notification', {
references: {
model: 'Users',
key: 'id'
}
},
comment: '유저 ID'
},
title: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '알림 제목'
},
message: {
type: DataTypes.TEXT,
allowNull: false
allowNull: false,
comment: '알림 내용'
},
icon: {
type: DataTypes.STRING,
defaultValue: 'pi pi-info-circle'
defaultValue: 'pi pi-info-circle',
comment: '아이콘'
},
type: {
type: DataTypes.ENUM('info', 'success', 'warning', 'error'),
defaultValue: 'info'
defaultValue: 'info',
comment: '알림 타입'
},
read: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
defaultValue: false,
comment: '읽음 여부'
},
link: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null
defaultValue: null,
comment: '관련 링크'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
defaultValue: DataTypes.NOW,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
defaultValue: DataTypes.NOW,
comment: '수정일'
}
}, {
timestamps: true,
comment: '유저 알림 정보를 저장하는 테이블',
tableName: 'Notifications'
});
+21 -10
View File
@@ -5,7 +5,8 @@ const Participant = sequelize.define('Participant', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '미팅 참가자 고유 식별자'
},
meetingId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,7 +14,8 @@ const Participant = sequelize.define('Participant', {
references: {
model: 'Meetings',
key: 'id'
}
},
comment: '미팅 ID'
},
memberId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -21,42 +23,51 @@ const Participant = sequelize.define('Participant', {
references: {
model: 'Members',
key: 'id'
}
},
comment: '회원 ID'
},
teamNumber: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 1
defaultValue: 1,
comment: '팀 번호'
},
status: {
type: DataTypes.ENUM('참가예정', '참가완료', '취소'),
allowNull: false,
defaultValue: '참가예정'
defaultValue: '참가예정',
comment: '참가 상태'
},
attendance: {
type: DataTypes.BOOLEAN,
defaultValue: false
defaultValue: false,
comment: '출석 여부'
},
paymentStatus: {
type: DataTypes.ENUM('미납', '납부완료'),
allowNull: false,
defaultValue: '미납'
defaultValue: '미납',
comment: '참가비 납부 상태'
},
paymentAmount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '납부 금액'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '수정일'
}
}, {
tableName: 'EventParticipants',
comment: '미팅 참가자 정보를 저장하는 테이블',
timestamps: true
});
+13 -6
View File
@@ -5,31 +5,38 @@ const SubscriptionPlan = sequelize.define('SubscriptionPlan', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '구독 플랜 고유 식별자'
},
name: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '플랜명'
},
description: {
type: DataTypes.TEXT
type: DataTypes.TEXT,
comment: '플랜 설명'
},
maxMembers: {
type: DataTypes.INTEGER,
allowNull: false
allowNull: false,
comment: '최대 허용 회원 수'
},
price: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
defaultValue: 0,
comment: '플랜 가격'
},
status: {
type: DataTypes.ENUM('active', 'inactive'),
defaultValue: 'active',
allowNull: false
allowNull: false,
comment: '플랜 상태'
}
}, {
tableName: 'SubscriptionPlan',
comment: '구독 플랜 정보를 저장하는 테이블',
timestamps: true
});
+9 -4
View File
@@ -5,7 +5,8 @@ const SubscriptionPlanFeature = sequelize.define('SubscriptionPlanFeature', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '구독-기능 연결 고유 식별자'
},
planId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -13,7 +14,8 @@ const SubscriptionPlanFeature = sequelize.define('SubscriptionPlanFeature', {
references: {
model: 'SubscriptionPlans',
key: 'id'
}
},
comment: '구독 플랜 ID'
},
featureId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -21,15 +23,18 @@ const SubscriptionPlanFeature = sequelize.define('SubscriptionPlanFeature', {
references: {
model: 'Features',
key: 'id'
}
},
comment: '기능 ID'
},
status: {
type: DataTypes.ENUM('active', 'inactive'),
defaultValue: 'active',
allowNull: false
allowNull: false,
comment: '상태'
}
}, {
tableName: 'SubscriptionPlanFeatures',
comment: '구독 플랜별 제공 기능 정보를 저장하는 테이블',
timestamps: true
});
+21 -10
View File
@@ -6,20 +6,24 @@ const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '유저 고유 식별자'
},
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true
unique: true,
comment: '로그인 계정명'
},
password: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '비밀번호(해시)'
},
name: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
comment: '이름'
},
email: {
type: DataTypes.STRING,
@@ -27,32 +31,39 @@ const User = sequelize.define('User', {
unique: true,
validate: {
isEmail: true
}
},
comment: '이메일'
},
role: {
type: DataTypes.ENUM('admin', 'clubadmin', 'user'),
allowNull: false,
defaultValue: 'user'
defaultValue: 'user',
comment: '권한 역할'
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended'),
allowNull: false,
defaultValue: 'active'
defaultValue: 'active',
comment: '계정 상태'
},
lastLoginAt: {
type: DataTypes.DATE,
allowNull: true
allowNull: true,
comment: '마지막 로그인 일시'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '생성일'
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
allowNull: false,
comment: '수정일'
}
}, {
tableName: 'Users',
comment: '서비스 회원(유저) 정보를 저장하는 테이블',
timestamps: true,
hooks: {
beforeCreate: async (user) => {
+11 -5
View File
@@ -7,7 +7,8 @@ ActivityLog.init({
id: {
type: DataTypes.INTEGER.UNSIGNED,
primaryKey: true,
autoIncrement: true
autoIncrement: true,
comment: '활동 로그 고유 식별자'
},
userId: {
type: DataTypes.INTEGER.UNSIGNED,
@@ -15,22 +16,27 @@ ActivityLog.init({
references: {
model: 'Users',
key: 'id'
}
},
comment: '유저 ID'
},
type: {
type: DataTypes.STRING(50),
allowNull: false
allowNull: false,
comment: '로그 유형'
},
description: {
type: DataTypes.TEXT,
allowNull: true
allowNull: true,
comment: '상세 설명'
},
createdAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
defaultValue: DataTypes.NOW,
comment: '생성일'
}
}, {
sequelize,
comment: '유저 활동 로그를 저장하는 테이블',
modelName: 'ActivityLog',
tableName: 'ActivityLogs',
timestamps: true,
+5
View File
@@ -31,4 +31,9 @@ router.delete('/:id/participants/:participantId/scores', authenticateJWT, requir
// 사용자별 이벤트 관리
router.get('/user/events', authenticateJWT, requireFeature('event_management'), eventController.getUserEvents);
// 공개 URL(publicHash) 재생성
router.patch('/:id/publicHash', eventController.regeneratePublicHash);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const publicController = require('../controllers/publicController');
const publicAuth = require('../middleware/publicAuth');
// 공개 이벤트 최초 진입 (비밀번호 인증 또는 토큰 인증)
router.post('/:publicHash', publicController.publicEventEntry);
// 공개 참가자 등록/수정
router.post('/:publicHash/participant', publicAuth, publicController.registerPublicParticipant);
// 공개 게스트 추가
router.post('/:publicHash/guest', publicAuth, publicController.addPublicGuest);
// 공개 점수 수정
router.put('/:publicHash/score', publicAuth, publicController.updatePublicScore);
module.exports = router;
@@ -4,7 +4,7 @@ module.exports = {
const clubSubscriptions = [];
for (let clubId = 1; clubId <= 5; clubId++) {
const planId = Math.floor(Math.random() * 2) + 1; // 랜덤으로 플랜 1, 2 중 하나 선택
const startDate = new Date(2025, 3, 1 + clubId);
const startDate = new Date(2025, 4, 1 + clubId);
const endDate = new Date(startDate.getFullYear(), startDate.getMonth()+1, startDate.getDate());
clubSubscriptions.push({
clubId,
+3 -4
View File
@@ -43,7 +43,6 @@ module.exports = {
visible: true,
parentId: clubMenuId,
featureCode: 'member_management',
requiredSubscriptionTier: 'basic',
createdAt: new Date(),
updatedAt: new Date()
},
@@ -56,7 +55,7 @@ module.exports = {
visible: true,
parentId: clubMenuId,
featureCode: 'event_management',
requiredSubscriptionTier: 'standard',
createdAt: new Date(),
updatedAt: new Date()
},
@@ -69,7 +68,7 @@ module.exports = {
visible: true,
parentId: clubMenuId,
featureCode: 'event_calendar',
requiredSubscriptionTier: 'standard',
createdAt: new Date(),
updatedAt: new Date()
},
@@ -82,7 +81,7 @@ module.exports = {
visible: true,
parentId: clubMenuId,
featureCode: 'statistics',
requiredSubscriptionTier: 'premium',
createdAt: new Date(),
updatedAt: new Date()
}
+12
View File
@@ -0,0 +1,12 @@
// utils/publicJwt.js
const jwt = require('jsonwebtoken');
const SECRET = process.env.PUBLIC_EVENT_JWT_SECRET || 'default_public_event_secret';
exports.signPublicEventToken = (payload) => {
const token = jwt.sign(payload, SECRET, { expiresIn: '2h' });
return token;
};
exports.verifyPublicEventToken = (token) => {
const decoded = jwt.verify(token, SECRET);
return decoded;
};
+119 -11
View File
@@ -13,16 +13,20 @@
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2",
"chart.js": "^4.4.8",
"dayjs": "^1.11.10",
"jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"primeicons": "^7.0.0",
"primelocale": "^2.1.2",
"primevue": "^4.3.3",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5",
"vue": "^3.5.13",
@@ -1045,6 +1049,87 @@
"dev": true,
"license": "MIT"
},
"node_modules/@primeuix/styled": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.5.1.tgz",
"integrity": "sha512-5Ftw/KSauDPClQ8F2qCyCUF7cIUEY4yLNikf0rKV7Vsb8zGYNK0dahQe7CChaR6M2Kn+NA2DSBSk76ZXqj6Uog==",
"license": "MIT",
"dependencies": {
"@primeuix/utils": "^0.5.3"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primeuix/styles": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-1.0.3.tgz",
"integrity": "sha512-yHj/Q+fosJ1736Ty5lRbpqhKa9piou+xZPPppNHUDshq0+XhrFwDGggvPGmDAJyUIM+ChM/Nj8lPY/AwTNXAkg==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.1"
}
},
"node_modules/@primeuix/themes": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-1.0.3.tgz",
"integrity": "sha512-f/1qadrv5TFMHfvtVv4Y9zjrkeDP2BO/cuzbHBO9DYxKL6YBIPT9BjKec2K4Kg8PcfGm6CAvxAvICadJSWejRw==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.1"
}
},
"node_modules/@primeuix/utils": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.5.3.tgz",
"integrity": "sha512-7SGh7734wcF1/uK6RzO6Z6CBjGQ97GDHfpyl2F1G/c7R0z9hkT/V72ypDo82AWcCS7Ta07oIjDpOCTkSVZuEGQ==",
"license": "MIT",
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primevue/core": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.3.3.tgz",
"integrity": "sha512-kSkN5oourG7eueoFPIqiNX3oDT/f0I5IRK3uOY/ytz+VzTZp5yuaCN0Nt42ZQpVXjDxMxDvUhIdaXVrjr58NhQ==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.0",
"@primeuix/utils": "^0.5.1"
},
"engines": {
"node": ">=12.11.0"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@primevue/icons": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.3.3.tgz",
"integrity": "sha512-ouQaxHyeFB6MSfEGGbjaK5Qv9efS1xZGetZoU5jcPm090MSYLFtroP1CuK3lZZAQals06TZ6T6qcoNukSHpK5w==",
"license": "MIT",
"dependencies": {
"@primeuix/utils": "^0.5.1",
"@primevue/core": "4.3.3"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primevue/themes": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@primevue/themes/-/themes-4.3.3.tgz",
"integrity": "sha512-LiYlSXsHeA8DFm8+yGyiDFQc3SEQwHcESTN1/rV+rrZ+UPuPisHY9fNIGRFQKA5XUQPDTQDQjtwYGx25Jikwhg==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.0",
"@primeuix/themes": "^1.0.0"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
@@ -2561,6 +2646,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2844,18 +2935,35 @@
"license": "MIT"
},
"node_modules/primeicons": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz",
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
"license": "MIT"
},
"node_modules/primevue": {
"version": "3.53.1",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.53.1.tgz",
"integrity": "sha512-Bp4peZPdhfKYXwvtsOGGh5dfgmTelm+LZEZKGs/c5mOHhsUJ6xi3EcOZoQVI6oklS946ayMQvgD5L0S7itGO0g==",
"node_modules/primelocale": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/primelocale/-/primelocale-2.1.2.tgz",
"integrity": "sha512-sTDkqu/QBwpqzq86oZGXaU/QbPLKLZ3Qix2gbqHXr410B7tfQCx39HSWjM9Hsnpzqn9KVdXJzk1DH1yW1iNNPg==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
"engines": {
"node": ">=18.0.0",
"npm": ">=8.6.0"
}
},
"node_modules/primevue": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.3.3.tgz",
"integrity": "sha512-nooYVoEz5CdP3EhUkD6c3qTdRmpLHZh75fBynkUkl46K8y5rksHTjdSISiDijwTA5STQIOkyqLb+RM+HQ6nC1Q==",
"license": "MIT",
"dependencies": {
"@primeuix/styled": "^0.5.0",
"@primeuix/styles": "^1.0.0",
"@primeuix/utils": "^0.5.1",
"@primevue/core": "4.3.3",
"@primevue/icons": "4.3.3"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/property-expr": {
+6 -2
View File
@@ -15,16 +15,20 @@
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2",
"chart.js": "^4.4.8",
"dayjs": "^1.11.10",
"jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.49.1",
"primeicons": "^7.0.0",
"primelocale": "^2.1.2",
"primevue": "^4.3.3",
"socket.io-client": "^4.8.1",
"vee-validate": "^4.12.5",
"vue": "^3.5.13",
+108 -93
View File
@@ -1,100 +1,103 @@
<template>
<div class="app">
<AppHeader />
<div class="app-content">
<!-- 사이드바 오버레이 추가 -->
<div v-if="isSidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div>
<!-- 사이드바에 모바일 클래스 추가 -->
<aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': isSidebarVisible, 'mobile-hidden': !isSidebarVisible && isMobileView }">
<div class="sidebar-content">
<!-- 클럽 정보 -->
<div class="club-info" v-if="selectedClub">
<div class="club-header">
<h3>{{ selectedClub.name }}</h3>
<p class="club-location"><i class="pi pi-map-marker"></i> {{ selectedClub.location }}</p>
</div>
<div class="club-stats">
<div class="stat-item">
<i class="pi pi-users"></i>
<span>{{ memberCount }} </span>
<template v-if="isRouterReady">
<AppPublicLayout v-if="isPublicLayout">
<router-view />
</AppPublicLayout>
<div v-else class="app">
<AppHeader />
<div class="app-content">
<div v-if="isSidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div>
<aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': isSidebarVisible, 'mobile-hidden': !isSidebarVisible && isMobileView }">
<div class="sidebar-content">
<!-- 클럽 정보 -->
<div class="club-info" v-if="selectedClub">
<div class="club-header">
<h3>{{ selectedClub.name }}</h3>
<p class="club-location"><i class="pi pi-map-marker"></i> {{ selectedClub.location }}</p>
</div>
<div class="stat-item">
<i class="pi pi-user"></i>
<span>모임장: {{ ownerName }}</span>
</div>
</div>
</div>
<!-- 클럽 선택 기능 (관리자 또는 클럽이 2 이상) -->
<div v-if="isAdmin || clubs.length > 1" class="club-selector">
<label for="club-select">클럽 선택:</label>
<select id="club-select" v-model="selectedClubId" @change="onClubChange">
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
</select>
</div>
<!-- 커스텀 사이드바 메뉴 -->
<div class="custom-sidebar-menu">
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
<div class="menu-group">
<div class="menu-group-header" @click="toggleMenuGroup(groupIndex)">
<i :class="menuGroup.icon"></i>
<span>{{ menuGroup.label }}</span>
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
<div class="club-stats">
<div class="stat-item">
<i class="pi pi-users"></i>
<span>{{ memberCount }} </span>
</div>
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
<router-link
v-for="(item, itemIndex) in menuGroup.items"
:key="itemIndex"
:to="item.to"
class="menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</router-link>
<div class="stat-item">
<i class="pi pi-user"></i>
<span>모임장: {{ ownerName }}</span>
</div>
</div>
</template>
<!-- 그룹이 없는 단일 메뉴 아이템 -->
<template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex">
<router-link
:to="menuItem.to"
class="menu-item single-menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="menuItem.icon"></i>
<span>{{ menuItem.label }}</span>
</router-link>
</template>
</div>
<!-- 클럽 선택 기능 (관리자 또는 클럽이 2 이상) -->
<div v-if="isAdmin || clubs.length > 1" class="club-selector">
<label for="club-select">클럽 선택:</label>
<select id="club-select" v-model="selectedClubId" @change="onClubChange">
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
</select>
</div>
<!-- 커스텀 사이드바 메뉴 -->
<div class="custom-sidebar-menu">
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
<div class="menu-group">
<div class="menu-group-header" @click="toggleMenuGroup(groupIndex)">
<i :class="menuGroup.icon"></i>
<span>{{ menuGroup.label }}</span>
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</div>
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
<router-link
v-for="(item, itemIndex) in menuGroup.items"
:key="itemIndex"
:to="item.to"
class="menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</router-link>
</div>
</div>
</template>
<!-- 그룹이 없는 단일 메뉴 아이템 -->
<template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex">
<router-link
:to="menuItem.to"
class="menu-item single-menu-item"
exact
active-class="router-link-active"
@click="closeSidebarOnMobile"
>
<i :class="menuItem.icon"></i>
<span>{{ menuItem.label }}</span>
</router-link>
</template>
</div>
</div>
</div>
</aside>
<main class="main-content" :class="{ 'full-width': !isLoggedIn }">
<router-view />
</main>
</aside>
<main class="main-content" :class="{ 'full-width': !isLoggedIn }">
<router-view />
</main>
</div>
<footer>
<p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
</footer>
</div>
<footer>
<p>&copy; {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
</footer>
</div>
</template>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, provide } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import AppHeader from './components/AppHeader.vue';
import AppPublicLayout from './layouts/AppPublicLayout.vue';
import apiClient from './services/api';
const route = useRoute();
const layoutComponent = computed(() => route.meta.layout === 'public' ? AppPublicLayout : 'div');
const isPublicLayout = computed(() => {
// 라우트 meta.layout이 public이거나, 경로가 /public/로 시작하면 public 레이아웃으로 간주
return route.meta.layout === 'public' || route.path.startsWith('/public/');
});
import authService from './services/authService';
// 상태 변수
@@ -111,7 +114,7 @@ const selectedClubId = ref(localStorage.getItem('clubId') || null); // 선택된
const selectedClub = ref(null);
const memberCount = ref(0);
const ownerName = ref('');
const isRouterReady = ref(false);
const userRole = computed(() => user.value?.role || '');
const isAdmin = computed(() => userRole.value === 'admin' || userRole.value === 'superadmin');
@@ -156,6 +159,7 @@ const initializeExpandedGroups = () => {
// 클럽 목록 가져오기
const fetchClubs = async () => {
if (isPublicLayout.value) return;
try {
const userId = user.value?.id;
const userRole = localStorage.getItem('userRole');
@@ -182,6 +186,7 @@ const fetchClubs = async () => {
// 클럽 정보 가져오기
const fetchClubInfo = async () => {
if (isPublicLayout.value) return;
try {
const response = await apiClient.post(`/api/club`, { clubId: selectedClubId.value ?? clubs.value[0].id });
@@ -201,6 +206,7 @@ const fetchClubInfo = async () => {
// 클럽 선택
const setSelectedClub = async (club) => {
if (isPublicLayout.value) return;
// 1. localStorage에 저장
localStorage.setItem('clubId', club.id);
localStorage.setItem('userClubRole', club.memberType || '');
@@ -237,6 +243,7 @@ const onClubChange = async () => {
// 메뉴 아이템 로드
const loadMenuItems = async () => {
if (isPublicLayout.value) return;
try {
const role = localStorage.getItem('userRole');
const clubId = selectedClubId.value;
@@ -324,10 +331,19 @@ const handleUserLoggedIn = async () => {
// 컴포넌트 마운트 시 데이터 로드
onMounted(async () => {
await router.isReady?.()
isRouterReady.value = true
// public 레이아웃이면 내부 데이터 로딩/초기화 완전 스킵
if (isPublicLayout.value) {
window.addEventListener('resize', handleResize);
window.addEventListener('user-logged-in', handleUserLoggedIn);
initializeExpandedGroups();
return;
}
// 내부(클럽) 페이지만 내부 데이터 로딩
await loadUserData();
const clubId = localStorage.getItem('clubId');
if (clubId) {
// 서버 세션에 clubId를 동기화
apiClient.post('/api/club/select-club', { clubId });
}
if (isLoggedIn.value) {
@@ -335,14 +351,8 @@ onMounted(async () => {
await fetchClubInfo();
loadMenuItems();
}
// 화면 크기 변경 감지
window.addEventListener('resize', handleResize);
// 로그인 성공 이벤트 감지
window.addEventListener('user-logged-in', handleUserLoggedIn);
// 초기 expandedGroups 설정
initializeExpandedGroups();
});
@@ -352,7 +362,12 @@ onBeforeUnmount(() => {
window.removeEventListener('user-logged-in', handleUserLoggedIn);
});
</script>
<style>
body {
margin: 0;
padding: 0;
}
</style>
<style scoped>
/* App.vue 스타일 */
.app {
+2 -1
View File
@@ -36,6 +36,7 @@
--section-gap: 160px;
}
/*
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
@@ -49,13 +50,13 @@
--color-text: var(--vt-c-text-dark-2);
}
}
*/
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
@@ -7,62 +7,81 @@
:modal="true"
class="event-details-dialog"
>
<TabView>
<Tabs value="기본정보">
<TabList>
<Tab value="기본정보"><i class="pi pi-info-circle mr-2" />기본정보</Tab>
<Tab value="참가자 관리"><i class="pi pi-users mr-2" />참가자 관리</Tab>
<Tab value="점수 관리"><i class="pi pi-chart-bar mr-2" />점수 관리</Tab>
<Tab value="팀 편성"><i class="pi pi-sitemap mr-2" /> 편성</Tab>
</TabList>
<TabPanels>
<!-- 기본 정보 -->
<TabPanel header="기본 정보">
<div class="event-details">
<div class="event-details-header">
<Badge :value="getEventTypeName(eventModel.eventType)" :severity="getStatusSeverity(eventModel.status)" />
<Badge :value="getStatusName(eventModel.status)" :severity="getStatusSeverity(eventModel.status)" />
</div>
<div class="event-details-content">
<div class="event-details-section">
<h4>이벤트 정보</h4>
<TabPanel value="기본정보">
<div class="event-details p-3">
<div class="flex align-items-center gap-2 mb-3">
<Badge :value="getEventTypeName(eventModel.eventType)" :severity="getStatusSeverity(eventModel.status)" />
<Badge :value="getStatusName(eventModel.status)" :severity="getStatusSeverity(eventModel.status)" />
</div>
<div class="card surface-100 p-3 mb-3">
<div class="grid">
<div class="col-12 md:col-6">
<p><strong>시작일:</strong> {{ formattedStartDate }}</p>
<p><strong>종료일:</strong> {{ formattedEndDate }}</p>
<p><strong>장소:</strong> {{ eventModel.location }}</p>
<div class="col-12 md:col-6 mb-2">
<span class="font-bold text-color-secondary">시작일:</span>
<span class="ml-2">{{ formattedStartDate }}</span>
</div>
<div class="col-12 md:col-6">
<p><strong>최대 참가자 :</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p>
<p><strong>현재 참가자 :</strong> {{ eventModel.participants?.length || 0 }}</p>
<div class="col-12 md:col-6 mb-2">
<span class="font-bold text-color-secondary">종료일:</span>
<span class="ml-2">{{ formattedEndDate }}</span>
</div>
<div class="col-12 md:col-6 mb-2">
<span class="font-bold text-color-secondary">장소:</span>
<span class="ml-2">{{ eventModel.location }}</span>
</div>
<div class="col-12 md:col-6 mb-2">
<span class="font-bold text-color-secondary">참가자 :</span>
<span class="ml-2">{{ eventModel.currentParticipants }} / {{ eventModel.maxParticipants }}</span>
</div>
<div class="col-12 md:col-6 mb-2 flex align-items-center">
<span class="font-bold text-color-secondary">공개 URL:</span>
<InputText :value="publicEventUrl" class="ml-2 w-full" readonly />
<Button icon="pi pi-copy" class="ml-2" @click="copyPublicUrl" />
</div>
<div class="col-12 md:col-6 mb-2">
<span class="font-bold text-color-secondary">공개 비밀번호:</span>
<span class="ml-2">{{ eventModel.publicPassword }}</span>
</div>
</div>
</div>
<div class="event-details-section">
<h4>설명</h4>
<p>{{ eventModel.description || '설명이 없습니다.' }}</p>
<div class="card surface-100 p-3">
<span class="font-bold text-color-secondary">설명</span>
<div class="mt-2">{{ eventModel.description }}</div>
</div>
</div>
</div>
</TabPanel>
</TabPanel>
<!-- 참가자 관리 -->
<TabPanel header="참가자 관리">
<ParticipantManagement
:eventId="eventModel.id"
:availableMembers="availableMembers"
@participant-updated="handleParticipantUpdated"
/>
</TabPanel>
<!-- 참가자 관리 -->
<TabPanel value="참가자 관리">
<ParticipantManagement
:eventId="eventModel.id"
:availableMembers="availableMembers"
@participant-updated="handleParticipantUpdated"
/>
</TabPanel>
<!-- 점수 관리 -->
<TabPanel header="점수 관리">
<ScoreManagement
:eventId="eventModel.id"
:participants="eventModel.participants || []"
:maxGames="eventModel.gameCount"
@score-updated="$emit('score-updated')"
/>
</TabPanel>
<!-- 편성 -->
<TabPanel header="팀 편성">
<TeamGeneration :participants="confirmedParticipants" :teams="teams" @update:teams="teams = $event" @teams-generated="teams = $event" @save-teams="handleSaveTeams" />
</TabPanel>
</TabView>
<!-- 점수 관리 -->
<TabPanel value="점수 관리">
<ScoreManagement
:eventId="eventModel.id"
:participants="eventModel.participants || []"
:maxGames="eventModel.gameCount"
@score-updated="$emit('score-updated')"
/>
</TabPanel>
<!-- 편성 -->
<TabPanel value="팀 편성">
<TeamGeneration :participants="confirmedParticipants" :teams="teams" @save-teams="handleSaveTeams" />
</TabPanel>
</TabPanels>
</Tabs>
<template #footer>
<div class="dialog-footer">
@@ -76,16 +95,8 @@
import { ref, computed, watch } from 'vue';
import TeamGeneration from './TeamGeneration.vue';
import Dialog from 'primevue/dialog';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import ParticipantManagement from './ParticipantManagement.vue';
import ScoreManagement from './ScoreManagement.vue';
import dayjs from 'dayjs';
const props = defineProps({
visible: { type: Boolean, required: true },
@@ -94,6 +105,11 @@ const props = defineProps({
getStatusName: { type: Function, required: true },
getStatusSeverity: { type: Function, required: true },
formatDate: { type: Function, required: true },
// (권장) 상태명이 매칭 안 될 경우 실제 상태 문자열 반환
// getStatusName 함수 예시:
// (status) => STATUS_LABELS[status] || status
availableMembers: { type: Array, default: () => [] }
});
@@ -103,6 +119,20 @@ const eventModel = computed(() => props.event || {});
const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate));
const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDate));
// 공개 URL 전체 경로
const publicEventUrl = computed(() => {
if (!eventModel.value.publicHash) return '';
// 실제 서비스 도메인에 맞게 수정 가능
return `${window.location.origin}/public/${eventModel.value.publicHash}`;
});
function copyPublicUrl() {
if (!publicEventUrl.value) return;
navigator.clipboard.writeText(publicEventUrl.value).then(() => {
toast.add({ severity: 'info', summary: 'URL 복사', detail: '공개 URL이 복사되었습니다.', life: 1500 });
});
}
const hideDialog = () => {
emit('hide');
};
@@ -118,11 +148,13 @@ watch(() => eventModel.value.id, async (eventId) => {
}, { immediate: true });
// 참가확정 참가자 목록을 계산하는 computed 속성
const confirmedParticipants = computed(() => {
const arr = eventModel.value.EventParticipants?.filter(p => p.status === '참가확정') || [];
console.log('[상위] 참가확정:', arr);
return arr;
});
const confirmedParticipants = ref([]);
function updateConfirmedParticipants() {
const arr = eventModel.value.EventParticipants?.filter(p => p.status === '참가확정' || p.status === '참가예정') || [];
confirmedParticipants.value = arr;
}
// 최초 및 eventModel 변경 시 동기화
watch(() => eventModel.value.EventParticipants, updateConfirmedParticipants, { immediate: true, deep: true });
// 참가확정 인원 수
const confirmedCount = computed(() => confirmedParticipants.value.length);
@@ -150,11 +182,12 @@ async function handleSaveTeams(savedTeams) {
function handleParticipantUpdated(data) {
// 부모 컴포넌트에 이벤트 전달
emit('participant-updated', data);
// 전체 참가자 목록이 있는 경우 직접 업데이트
if (data.allParticipants) {
// EventParticipants 속성을 완전히 대체
eventModel.value.EventParticipants = [...data.allParticipants];
// 참가확정 참가자도 즉시 반영
updateConfirmedParticipants();
}
};
+78 -47
View File
@@ -9,84 +9,82 @@
>
<div class="grid">
<div class="col-12">
<div class="field">
<label for="title">제목 *</label>
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" required autofocus />
<label for="title">이벤트 제목 *</label>
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" variant="filled" required autofocus />
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
</div>
</div>
<div class="col-12">
<div class="field">
<label for="description">설명</label>
<Textarea id="description" v-model="eventModel.description" rows="3" autoResize />
</div>
<label for="description">이벤트 설명</label>
<Textarea id="description" v-model="eventModel.description" variant="filled" rows="3" autoResize class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="eventType">이벤트 유형 *</label>
<Dropdown id="eventType" v-model="eventModel.eventType" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType}" />
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</div>
<label for="eventType">이벤트 유형 *</label>
<Select id="eventType" v-model="eventModel.eventType" variant="filled" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType, 'w-full': true}"/>
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="status">상태 *</label>
<Dropdown id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status}" />
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</div>
<label for="status">상태 *</label>
<Select id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="startDate">시작일 *</label>
<Calendar id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.startDate}" />
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</div>
<label for="startDate">시작일 *</label>
<Datepicker id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.startDate, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="endDate">종료일</label>
<Calendar id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.endDate}" />
</div>
<label for="endDate">종료일</label>
<Datepicker id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.endDate, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.endDate">종료일은 필수입니다.</small>
</div>
<div class="col-12">
<div class="field">
<label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location}" />
</div>
<label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location, 'w-full': true}" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" showButtons />
</div>
<label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" showButtons />
</div>
<label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.entryFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" />
</div>
<label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.participantFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="registrationDeadline">등록 마감일</label>
<Calendar id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" />
</div>
<label for="registrationDeadline">신청마감</label>
<Datepicker id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.registrationDeadline, 'w-full': true}" />
</div>
<div class="col-12 md:col-6">
<label for="publicHash">공개 URL</label>
<InputGroup>
<InputText id="publicHash" v-model="eventModel.publicHash" readonly placeholder="생성 후 표시" />
<Button
type="button"
icon="pi pi-refresh"
@click="regeneratePublicHash"
:disabled="isNew || loadingHash"
class="p-button-sm"
:aria-label="eventModel.publicHash ? '공개 URL 재생성' : '공개 URL 생성'"
/>
</InputGroup>
</div>
<div class="col-12 md:col-6">
<label for="accessPassword">공개 비밀번호(선택)</label>
<InputText id="accessPassword" v-model="eventModel.accessPassword" type="text" maxlength="32" placeholder="공개 페이지 접근용 비밀번호(선택)" style="width:100%;" />
</div>
</div>
@@ -99,6 +97,7 @@
<script setup>
import { ref, watch, toRefs, computed } from 'vue';
import InputGroup from 'primevue/inputgroup';
import dayjs from 'dayjs';
const statusOptions = [
@@ -142,6 +141,10 @@ const eventTypeOptions = [
// 이벤트 모델
const eventModel = ref({ ...props.event });
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const loadingHash = ref(false);
// 날짜 형식 변환
const formatDate = (date) => {
@@ -160,6 +163,26 @@ watch(() => props.event, (newValue) => {
eventModel.value = formattedEvent;
}, { deep: true });
// 공개 URL(공개 해시) 재생성
const regeneratePublicHash = async () => {
if (props.isNew) return;
loadingHash.value = true;
try {
const res = await fetch(`/api/club/events/${eventModel.value.id}/publicHash`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('공개 URL 생성/재생성에 실패했습니다.');
const data = await res.json();
eventModel.value.publicHash = data.publicHash;
toast.add({ severity: 'info', summary: '공개 URL 재생성', detail: '저장 버튼을 눌러야 최종 반영됩니다.', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: '오류', detail: e.message });
} finally {
loadingHash.value = false;
}
};
// 이벤트 저장
const saveEvent = () => {
const eventData = { ...eventModel.value };
@@ -172,6 +195,7 @@ const saveEvent = () => {
if (eventData.registrationDeadline) {
eventData.registrationDeadline = dayjs(eventData.registrationDeadline).toDate();
}
emit('save', eventData);
};
@@ -223,4 +247,11 @@ const hideDialog = () => {
:deep(.p-inputtext) {
width: 100%;
}
.public-hash-btn.p-button-icon-only {
min-width:28px;
height:28px;
padding:0;
font-size:1.1em;
}
</style>
@@ -55,7 +55,7 @@
<Dialog v-model:visible="participantDialog" :header="dialogTitle" modal class="p-fluid" :style="{width: '450px'}">
<div class="field">
<label for="member">회원</label>
<Dropdown
<Select
id="member"
v-model="editingParticipant.memberId"
:options="props.availableMembers"
@@ -69,7 +69,7 @@
</div>
<div class="field">
<label for="status">참가 상태</label>
<Dropdown
<Select
id="status"
v-model="editingParticipant.status"
:options="participantStatusOptions"
@@ -82,7 +82,7 @@
</div>
<div class="field">
<label for="paymentStatus">결제 상태</label>
<Dropdown
<Select
id="paymentStatus"
v-model="editingParticipant.paymentStatus"
:options="paymentStatusOptions"
@@ -118,13 +118,6 @@ import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService';
import dayjs from 'dayjs';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import Badge from 'primevue/badge';
import Toast from 'primevue/toast';
const props = defineProps({
eventId: {
@@ -47,7 +47,7 @@
<Dialog v-model:visible="scoreDialog" :header="scoreDialogTitle" modal class="p-fluid" :style="{width: '600px'}">
<div class="field">
<label for="participant">참가자</label>
<Dropdown
<Select
id="participant"
v-model="editingScore.participantId"
:options="participants"
@@ -72,6 +72,7 @@
:max="300"
:placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'"
@update:modelValue="() => onScoreChange(game)"
class="w-full"
/>
</div>
</div>
@@ -85,6 +86,7 @@
:max="300"
placeholder="핸디캡 입력"
@update:modelValue="() => onScoreChange(game)"
class="w-full"
/>
</div>
</div>
@@ -120,7 +122,6 @@
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import eventService from '@/services/eventService';
import dayjs from 'dayjs';
const props = defineProps({
eventId: {
@@ -44,13 +44,13 @@
<span class="badge bg-primary text-white mr-1">{{ mIdx + 1 }}</span>
<span>{{ member.name }}</span>
<span v-if="getMemberAverage(member) !== null" class="text-xs text-gray-700 ml-1">({{ getMemberAverage(member) }})</span>
<Button icon="pi pi-times" class="p-button-text p-button-danger p-0" @click="removeMemberFromTeam(idx, mIdx)" v-if="team.members.length > 1" />
<Button icon="pi pi-times" class="p-button-text p-button-danger p-0" @click="removeMemberFromTeam(idx, mIdx)" v-if="team.members.length > 0" />
</li>
</template>
</draggable>
</ul>
<div class="flex align-items-center gap-2 mt-2">
<Dropdown
<Select
v-model="team._selectedMember"
:options="availableMembersForTeam(idx)"
optionLabel="name"
@@ -71,7 +71,7 @@
</span>
</span>
</template>
</Dropdown>
</Select>
<Button icon="pi pi-plus" class="p-button-text p-0" :disabled="!team._selectedMember" @click="addMemberToTeam(idx)" />
</div>
</div>
@@ -89,11 +89,7 @@
<script setup>
import draggable from 'vuedraggable'; // npm install vuedraggable@next 필요 (vue3)
import { ref, computed, watch, unref } from 'vue';
import InputNumber from 'primevue/inputnumber';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
// 참가자 에버리지 구하기
function getMemberAverage(member) {
@@ -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', 'update:visible']);
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>
@@ -0,0 +1,85 @@
<template>
<div class="team-title-row">
<span class="team-name-strong">{{ title }}</span>
<span v-if="teamHandicap" class="team-hc"> 핸디캡: {{ teamHandicap }}</span>
</div>
<div class="member-list">
<div v-for="member in 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 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, teamNumber)"
@blur="onScoreBlur(member, n, teamNumber)"
@keydown.enter="onScoreEnter(member, n, 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>
</template>
<script setup>
import { toRefs } from 'vue'
const props = defineProps({
title: String,
members: Array,
teamHandicap: [Number, String],
teamNumber: [Number, String, null],
gameCount: Number,
canInputScore: Boolean,
scoresProxy: Object,
scoreStates: Object,
onScoreInput: Function,
onScoreBlur: Function,
onScoreEnter: Function,
getMemberTotalScore: Function,
getMemberAverageScore: Function,
scoreColorClass: Function
})
</script>
+23
View File
@@ -0,0 +1,23 @@
<template>
<div class="public-layout">
<main>
<slot />
</main>
</div>
</template>
<script setup>
//
</script>
<style scoped>
.public-layout,
.public-layout > main {
min-height: 100vh;
min-width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>
+37 -10
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/base.css'
// dayjs configuration
import dayjs from 'dayjs'
@@ -14,8 +15,7 @@ dayjs.tz.setDefault('Asia/Seoul')
// PrimeVue
import PrimeVue from 'primevue/config'
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import Aura from '@primeuix/themes/aura';
import 'primeicons/primeicons.css'
import 'primeflex/primeflex.css'
@@ -25,12 +25,11 @@ import InputText from 'primevue/inputtext'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import Dropdown from 'primevue/dropdown'
import Select from 'primevue/select'
import MultiSelect from 'primevue/multiselect'
import Toast from 'primevue/toast'
import ToastService from 'primevue/toastservice'
import Card from 'primevue/card'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Menu from 'primevue/menu'
import Menubar from 'primevue/menubar'
import PanelMenu from 'primevue/panelmenu'
@@ -40,30 +39,56 @@ import Checkbox from 'primevue/checkbox'
import Password from 'primevue/password'
import Textarea from 'primevue/textarea'
import Badge from 'primevue/badge'
import Calendar from 'primevue/calendar'
import InputNumber from 'primevue/inputnumber'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import Datepicker from 'primevue/datepicker'
import Tag from 'primevue/tag'
import FloatLabel from 'primevue/floatlabel';
import Tooltip from 'primevue/tooltip';
import Tabs from 'primevue/tabs';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import {ko} from 'primelocale/js/ko.js';
import AppHeader from './components/AppHeader.vue';
const app = createApp(App)
// Register PrimeVue
app.use(PrimeVue)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: false
},
locale: ko
}
})
app.directive('tooltip', Tooltip)
app.use(ToastService)
app.use(createPinia())
app.use(router)
// Register custom components
app.component('AppHeader', AppHeader);
// Register PrimeVue Components
app.component('Button', Button)
app.component('InputText', InputText)
app.component('DataTable', DataTable)
app.component('Column', Column)
app.component('Dialog', Dialog)
app.component('Dropdown', Dropdown)
app.component('Select', Select)
app.component('MultiSelect', MultiSelect)
app.component('Toast', Toast)
app.component('Card', Card)
app.component('TabView', TabView)
app.component('Tabs', Tabs)
app.component('TabPanels', TabPanels)
app.component('TabPanel', TabPanel)
app.component('TabList', TabList)
app.component('Tab', Tab)
app.component('Menu', Menu)
app.component('Menubar', Menubar)
app.component('PanelMenu', PanelMenu)
@@ -73,9 +98,11 @@ app.component('Checkbox', Checkbox)
app.component('Password', Password)
app.component('Textarea', Textarea)
app.component('Badge', Badge)
app.component('Calendar', Calendar)
app.component('InputNumber', InputNumber)
app.component('Divider', Divider)
app.component('ProgressSpinner', ProgressSpinner)
app.component('Datepicker', Datepicker)
app.component('Tag', Tag)
app.component('FloatLabel', FloatLabel);
app.mount('#app')
+8
View File
@@ -22,12 +22,20 @@ const UserClubCreate = () => import('@/views/club/ClubCreate.vue');
// 구독 관리 컴포넌트 import
const ClubSubscriptionManagement = () => import('@/views/club/subscription/SubscriptionManagement.vue');
const EventPublicPage = () => import('@/views/event/EventPublicPage.vue');
const routes = [
// 공개 경로
{
path: '/',
redirect: '/login'
},
{
path: '/public/:publicHash',
name: 'EventPublicPage',
component: EventPublicPage,
meta: { requiresAuth: false, layout: 'public' }
},
{
path: '/login',
name: 'Login',
@@ -0,0 +1,84 @@
// PublicEventService.js
// 모든 public API 호출을 담당하는 서비스 (토큰 자동 관리)
const STORAGE_KEY = 'event_token_';
function getToken(publicHash) {
return sessionStorage.getItem(STORAGE_KEY + publicHash);
}
function setToken(publicHash, token) {
sessionStorage.setItem(STORAGE_KEY + publicHash, token);
}
function removeToken(publicHash) {
sessionStorage.removeItem(STORAGE_KEY + publicHash);
}
async function fetchEvent(publicHash, password) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
let body = undefined;
if (!token && password) {
body = JSON.stringify({ password });
}
const res = await fetch(`/api/public/${publicHash}`, {
method: 'POST',
headers,
body
});
if (res.status === 403 || res.status === 401 || res.status === 404) {
removeToken(publicHash);
}
const data = await res.json();
if (data.token) {
setToken(publicHash, data.token);
}
return { status: res.status, data };
}
async function updateScore(publicHash, participantId, gameNumber, score, handicap, teamNumber = null) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(`/api/public/${publicHash}/score`, {
method: 'PUT',
headers,
body: JSON.stringify({ participantId, gameNumber, score, handicap, teamNumber })
});
return res;
}
async function registerParticipant(publicHash, payload) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(`/api/public/${publicHash}/participant`, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
return res;
}
async function createGuest(publicHash, payload) {
let token = getToken(publicHash);
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(`/api/public/${publicHash}/guest`, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
return await res.json();
}
export default {
fetchEvent,
updateScore,
registerParticipant,
createGuest,
getToken,
setToken,
removeToken
};
+39 -21
View File
@@ -23,31 +23,26 @@
v-model:filters="filters"
class="mt-4"
>
<Column field="id" header="ID" sortable style="width: 5%"></Column>
<Column field="title" header="제목" sortable style="width: 20%">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="제목으로 검색" />
</template>
</Column>
<Column field="eventType" header="유형" sortable style="width: 10%">
<template #body="slotProps">
<Badge :value="getEventTypeName(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
<Select v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
</template>
</Column>
<Column field="title" header="제목" sortable>
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="제목으로 검색" />
</template>
</Column>
<Column field="startDate" header="시작일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.startDate) }}
</template>
<template #filter="{ filterModel, filterCallback }">
<Calendar v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
</template>
</Column>
<Column field="endDate" header="종료일" sortable style="width: 12%">
<template #body="slotProps">
{{ formatDate(slotProps.data.endDate) }}
<Datepicker v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
</template>
</Column>
<Column field="location" header="장소" sortable style="width: 15%"></Column>
@@ -236,15 +231,20 @@ const getEventTypeName = (type) => {
};
//
const getStatusName = (status) => {
switch (status) {
case '준비': return '준비';
case '진행중': return '진행중';
case '완료': return '완료';
case '취소': return '취소';
default: return '알 수 없음';
}
const STATUS_LABELS = {
'준비': '준비',
'진행중': '진행중',
'완료': '완료',
'취소': '취소',
'예정됨': '예정됨',
'in_progress': '진행중',
'completed': '완료',
'cancelled': '취소',
'활성': '활성',
'비활성': '비활성',
'삭제': '삭제',
};
const getStatusName = (status) => STATUS_LABELS[status] || status;
// Badge
const getStatusSeverity = (status) => {
@@ -468,4 +468,22 @@ const navigateToCalendar = () => {
:deep(.p-column-filter) {
width: 100%;
}
/* 테이블 헤더(정렬/필터 버튼) 한 줄 정렬 및 공간 최소화 */
:deep(.p-column-header-content) {
white-space: nowrap;
align-items: center;
}
:deep(.p-sortable-column-icon),
:deep(.p-column-filter-menu-button) {
margin-left: 2px !important;
margin-right: 2px !important;
padding: 0 !important;
font-size: 13px !important;
vertical-align: middle;
}
:deep(.p-column-title) {
vertical-align: middle;
}
</style>
+21 -35
View File
@@ -61,42 +61,35 @@
<Dialog v-model:visible="memberDialog" :style="{width: '450px'}"
:header="dialogMode === 'new' ? '새 회원 추가' : '회원 정보 수정'"
:modal="true" class="p-fluid">
<div class="field">
<label for="name">이름</label>
<InputText id="name" v-model="member.name" required autofocus />
<!-- 회원 정보 다이얼로그 내부 예시 -->
<div class="field mb-3">
<label for="gender" class="mb-1">성별</label>
<Select id="gender" v-model="member.gender" :options="genderOptions" optionLabel="name" placeholder="성별 선택" class="w-full" />
</div>
<div class="field">
<label for="gender">성별</label>
<Dropdown id="gender" v-model="member.gender" :options="genderOptions"
optionLabel="name" placeholder="성별 선택" />
<div class="field mb-3">
<label for="memberType" class="mb-1">회원 유형</label>
<Select id="memberType" v-model="member.memberTypeObj" :options="memberTypes" optionLabel="name" placeholder="회원 유형 선택" class="w-full" />
</div>
<div class="field">
<label for="memberType">회원 유형</label>
<Dropdown id="memberType" v-model="member.memberTypeObj" :options="memberTypes"
optionLabel="name" placeholder="회원 유형 선택" />
<div class="field mb-3">
<label for="phone" class="mb-1">전화번호</label>
<InputText id="phone" v-model="member.phone" class="w-full" />
</div>
<div class="field">
<label for="phone">전화번호</label>
<InputText id="phone" v-model="member.phone" />
<div class="field mb-3">
<label for="email" class="mb-1">이메일</label>
<InputText id="email" v-model="member.email" class="w-full" />
</div>
<div class="field">
<label for="email">이메일</label>
<InputText id="email" v-model="member.email" />
<div class="field mb-3">
<label for="handicap" class="mb-1">핸디캡</label>
<InputText id="handicap" v-model="member.handicap" type="number" min="0" max="50" class="w-full" />
</div>
<div class="field">
<label for="handicap">핸디캡</label>
<InputText id="handicap" v-model="member.handicap" type="number" min="0" max="50" />
<div class="field mb-3">
<label for="status" class="mb-1">상태</label>
<Select id="status" v-model="member.status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="상태 선택" class="w-full" />
</div>
<div class="field">
<label for="status">상태</label>
<Dropdown id="status" v-model="member.status" :options="statusOptions"
optionLabel="label" optionValue="value" placeholder="상태 선택" />
</div>
<template #footer>
<div class="flex justify-content-end gap-2 mt-4">
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveMember" />
</template>
</div>
</Dialog>
<!-- 삭제 확인 다이얼로그 -->
@@ -177,13 +170,6 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber';
import Dialog from 'primevue/dialog';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import apiClient from '@/services/api';
import clubService from '@/services/clubService';
+4 -4
View File
@@ -28,7 +28,7 @@
</span>
</div>
<div class="col-12 md:col-4">
<Dropdown v-model="filters.team" :options="teamOptions" optionLabel="name"
<Select v-model="filters.team" :options="teamOptions" optionLabel="name"
placeholder="팀 선택" class="w-full" />
</div>
<div class="col-12 md:col-4">
@@ -118,11 +118,11 @@
<div class="ranking-filters mb-3">
<div class="grid">
<div class="col-12 md:col-4">
<Dropdown v-model="rankingType" :options="rankingOptions" optionLabel="name"
<Select v-model="rankingType" :options="rankingOptions" optionLabel="name"
placeholder="순위 유형" class="w-full" />
</div>
<div class="col-12 md:col-4">
<Dropdown v-model="rankingGame" :options="gameRankingOptions" optionLabel="name"
<Select v-model="rankingGame" :options="gameRankingOptions" optionLabel="name"
placeholder="게임 선택" class="w-full" />
</div>
</div>
@@ -177,7 +177,7 @@
<div class="export-options">
<div class="field">
<label for="exportFormat">파일 형식</label>
<Dropdown id="exportFormat" v-model="exportFormat" :options="exportFormatOptions"
<Select id="exportFormat" v-model="exportFormat" :options="exportFormatOptions"
optionLabel="name" placeholder="형식 선택" class="w-full" />
</div>
<div class="field">
@@ -0,0 +1,509 @@
.event-public-wrapper {
width: 100%;
max-width: 700px;
margin: 0 auto;
padding: 1rem;
background-color: var(--p-gray-50);
}
.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: 0
}
.team-score-table-wrapper .p-card-body {
padding: 0;
}
.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;
}
.team-title-row {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 7px;
}
.team-name-strong {
font-size: 2rem;
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,582 @@
<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>
<div class="event-public-card">
<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">
<Card>
<template #content>
<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>
</template>
</Card>
</div>
<div v-else>
<template v-if="event">
<Divider class="p-mb-4" />
<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="ml-1" />
</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>
<Divider />
<!-- 팀별 게임별 점수/등수 -->
</div>
<template v-if="teams.length > 0">
<Card class="team-score-table-wrapper mb-4">
<template #content>
<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>
</template>
</Card>
<div v-for="team in getAllTeamsWithUnassigned()" :key="team.id" class="mb-4">
<Card>
<template #content>
<MemberListCard
:title="parseInt(team.teamNumber) ? '팀 ' + team.teamNumber : '미배정 인원'"
:members="team.members"
:teamHandicap="team.handicap"
:teamNumber="team.teamNumber"
:gameCount="event.gameCount"
:canInputScore="canInputScore"
:scoresProxy="scoresProxy"
:scoreStates="scoreStates"
:onScoreInput="onScoreInput"
:onScoreBlur="onScoreBlur"
:onScoreEnter="onScoreEnter"
:getMemberTotalScore="getMemberTotalScore"
:getMemberAverageScore="getMemberAverageScore"
:scoreColorClass="scoreColorClass"
/>
<!-- 카드 요약(총점/등수/게임별 점수) '팀' 노출 -->
<Divider />
<div v-if="team.teamNumber" 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>
</template>
<template v-else>
<Card>
<template #content>
<MemberListCard
title="참가자"
:members="sortedParticipants"
:gameCount="event.gameCount"
:canInputScore="canInputScore"
:scoresProxy="scoresProxy"
:scoreStates="scoreStates"
:onScoreInput="onScoreInput"
:onScoreBlur="onScoreBlur"
:onScoreEnter="onScoreEnter"
:getMemberTotalScore="getMemberTotalScore"
:getMemberAverageScore="getMemberAverageScore"
:scoreColorClass="scoreColorClass"
/>
</template>
</Card>
</template>
</template>
</div>
</div>
<JoinDialog v-if="showJoinDialog && canJoin" v-model:visible="showJoinDialog" :publicHash="publicHash"
:participants="participants"
:availableMembers="availableMembers" :eventStatus="event.status"
@close="showJoinDialog = false" @updated="reload" />
</div>
</template>
<script setup>
import { ref, computed, watch, reactive, onMounted } from 'vue';
import { debounce } from 'lodash-es'; // lodash ( )
import { useRoute } from 'vue-router';
import MemberListCard from '@/components/public/MemberListCard.vue';
import JoinDialog from '@/components/public/JoinDialog.vue';
import dayjs from 'dayjs';
import PublicEventService from '@/services/PublicEventService';
import Panel from 'primevue/panel';
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 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) {
error.value = '이벤트에 접근할 수 없습니다.';
PublicEventService.removeToken(publicHash);
event.value = null;
return;
}
if (status === 401) {
isAuthenticated.value = false;
PublicEventService.removeToken(publicHash);
event.value = null;
return;
}
if (status === 404 || (data && data.message === '이벤트를 찾을 수 없습니다.')) {
error.value = data && data.message ? data.message : '존재하지 않는 이벤트입니다.';
event.value = null;
return;
}
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;
event.value = null;
} 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>