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