Merge pull request '게스트-기능' (#2) from 게스트-기능 into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -7,6 +7,7 @@ const socketIo = require('socket.io');
|
|||||||
const adminRoutes = require('./routes/adminRoutes');
|
const adminRoutes = require('./routes/adminRoutes');
|
||||||
const authRoutes = require('./routes/authRoutes');
|
const authRoutes = require('./routes/authRoutes');
|
||||||
const eventRoutes = require('./routes/eventRoutes');
|
const eventRoutes = require('./routes/eventRoutes');
|
||||||
|
const publicEventRoutes = require('./routes/publicEventRoutes');
|
||||||
const clubRoutes = require('./routes/clubRoutes');
|
const clubRoutes = require('./routes/clubRoutes');
|
||||||
const userRoutes = require('./routes/userRoutes');
|
const userRoutes = require('./routes/userRoutes');
|
||||||
const notificationRoutes = require('./routes/notificationRoutes');
|
const notificationRoutes = require('./routes/notificationRoutes');
|
||||||
@@ -75,6 +76,7 @@ app.use('/api/auth', authRoutes);
|
|||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/club', clubRoutes);
|
app.use('/api/club', clubRoutes);
|
||||||
app.use('/api/club/events', eventRoutes);
|
app.use('/api/club/events', eventRoutes);
|
||||||
|
app.use('/api/public', publicEventRoutes);
|
||||||
app.use('/api/features', featureRoutes);
|
app.use('/api/features', featureRoutes);
|
||||||
app.use('/api/subscriptions', subscriptionRoutes);
|
app.use('/api/subscriptions', subscriptionRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const sequelize = new Sequelize(
|
|||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: process.env.DB_PORT,
|
port: process.env.DB_PORT,
|
||||||
dialect: 'mysql',
|
dialect: 'mysql',
|
||||||
logging: process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화
|
logging: false, //process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화
|
||||||
pool: {
|
pool: {
|
||||||
max: 5,
|
max: 5,
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ exports.createEvent = async (req, res) => {
|
|||||||
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
const endDateVal = endDate ? new Date(endDate) : null;
|
const endDateVal = endDate ? new Date(endDate) : null;
|
||||||
|
// 랜덤 해시 생성 함수
|
||||||
|
const generateRandomHash = async () => {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let hash;
|
||||||
|
let exists = true;
|
||||||
|
while (exists) {
|
||||||
|
hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||||
|
// 중복 체크
|
||||||
|
exists = await Event.findOne({ where: { publicHash: hash } });
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicHash = await generateRandomHash();
|
||||||
|
const accessPassword = req.body.accessPassword || null;
|
||||||
|
|
||||||
const event = await Event.create({
|
const event = await Event.create({
|
||||||
clubId,
|
clubId,
|
||||||
title,
|
title,
|
||||||
@@ -153,7 +169,9 @@ exports.createEvent = async (req, res) => {
|
|||||||
entryFee: entryFee || 0,
|
entryFee: entryFee || 0,
|
||||||
gameCount: gameCount || 3,
|
gameCount: gameCount || 3,
|
||||||
laneCount: laneCount || 1,
|
laneCount: laneCount || 1,
|
||||||
status: status || '활성'
|
status: status || '활성',
|
||||||
|
publicHash,
|
||||||
|
accessPassword
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdEvent = await Event.findOne({
|
const createdEvent = await Event.findOne({
|
||||||
@@ -180,6 +198,248 @@ exports.createEvent = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 공개 이벤트 조회 및 비밀번호 인증
|
||||||
|
exports.getEventByPublicHash = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
const { password } = req.body || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 이벤트 조회
|
||||||
|
const event = await Event.findOne({
|
||||||
|
where: { publicHash },
|
||||||
|
include: [{
|
||||||
|
model: Club,
|
||||||
|
attributes: ['id', 'name']
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
|
||||||
|
|
||||||
|
// 2. 비밀번호 필요 여부 및 인증
|
||||||
|
if (event.accessPassword) {
|
||||||
|
if (!password) return res.status(401).json({ needPassword: true, message: '비밀번호 필요' });
|
||||||
|
if (event.accessPassword !== password) return res.status(401).json({ needPassword: true, message: '비밀번호 불일치' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 전체 회원 명단 추출 (clubId 사용, clubId는 응답에 포함 X)
|
||||||
|
const allMembers = await Member.findAll({
|
||||||
|
where: { clubId: event.clubId },
|
||||||
|
attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average']
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 참가자 명단 추출 (참가자 테이블 + Member 조인)
|
||||||
|
const participants = await EventParticipant.findAll({
|
||||||
|
where: { eventId: event.id, status: { [Op.ne]: '취소' } },
|
||||||
|
include: [
|
||||||
|
{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// 5. 참가 신청 가능 명단(아직 참가하지 않은 회원)
|
||||||
|
// Sequelize 인스턴스에서 dataValues 추출
|
||||||
|
const allMemberObjs = allMembers.map(m => m.dataValues);
|
||||||
|
// '취소'가 아닌 status가 하나라도 있으면 참가자로 간주 (중복 제거)
|
||||||
|
const participantMemberIds = [
|
||||||
|
...new Set(
|
||||||
|
participants
|
||||||
|
.filter(p => p.dataValues.status !== '취소')
|
||||||
|
.map(p => parseInt(p.dataValues.memberId))
|
||||||
|
)
|
||||||
|
];
|
||||||
|
const availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId)));
|
||||||
|
// 6. 팀 정보 등 추가 조회 (기존 코드 유지)
|
||||||
|
const teams = await EventTeam.findAll({
|
||||||
|
where: { eventId: event.id },
|
||||||
|
order: [['teamNumber', 'ASC']],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: EventTeamMember,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: EventParticipant,
|
||||||
|
include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 점수
|
||||||
|
const scores = await EventScore.findAll({ where: { eventId: event.id } });
|
||||||
|
|
||||||
|
// 팀별 그룹핑
|
||||||
|
let teamList = [];
|
||||||
|
if (teams.length > 0) {
|
||||||
|
teamList = teams.map(team => ({
|
||||||
|
teamId: team.id,
|
||||||
|
teamNumber: team.teamNumber,
|
||||||
|
handicap: team.handicap,
|
||||||
|
members: (team.EventTeamMembers || []).map(tm => {
|
||||||
|
const participant = tm.EventParticipant;
|
||||||
|
const member = participant?.Member;
|
||||||
|
const scoreObj = scores.find(s => s.participantId === participant?.id);
|
||||||
|
return {
|
||||||
|
participantId: participant?.id,
|
||||||
|
memberId: member?.memberId,
|
||||||
|
name: member?.name || `참가자 ${participant?.id}`,
|
||||||
|
gender: member?.gender,
|
||||||
|
memberType: member?.memberType,
|
||||||
|
average: member?.average || 0,
|
||||||
|
handicap: member?.handicap || 0,
|
||||||
|
status: participant?.status,
|
||||||
|
paymentStatus: participant?.paymentStatus,
|
||||||
|
comment: participant?.comment,
|
||||||
|
isGuest: member?.memberType === '게스트',
|
||||||
|
score: scoreObj ? scoreObj.score : null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참가자 리스트(팀 없는 경우)
|
||||||
|
let participantList = [];
|
||||||
|
participantList = participants.map(p => {
|
||||||
|
const member = p.Member;
|
||||||
|
const scoreObj = scores.find(s => s.participantId === p.id);
|
||||||
|
return {
|
||||||
|
participantId: p.id,
|
||||||
|
memberId: p.memberId,
|
||||||
|
name: member?.name || `참가자 ${p.id}`,
|
||||||
|
gender: member?.gender,
|
||||||
|
memberType: member?.memberType,
|
||||||
|
handicap: member?.handicap || 0,
|
||||||
|
average: member?.average || 0,
|
||||||
|
status: p.status,
|
||||||
|
paymentStatus: p.paymentStatus,
|
||||||
|
comment: p.comment,
|
||||||
|
isGuest: member?.memberType === '게스트',
|
||||||
|
score: scoreObj ? scoreObj.score : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// 반환 데이터
|
||||||
|
res.json({
|
||||||
|
clubName: event.Club.name,
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
eventType: event.eventType,
|
||||||
|
startDate: event.startDate,
|
||||||
|
endDate: event.endDate,
|
||||||
|
location: event.location,
|
||||||
|
gameCount: event.gameCount,
|
||||||
|
registrationDeadline: event.registrationDeadline,
|
||||||
|
maxParticipants: event.maxParticipants,
|
||||||
|
participantFee: event.participantFee,
|
||||||
|
status: event.status
|
||||||
|
},
|
||||||
|
teams: teamList,
|
||||||
|
participants: participantList,
|
||||||
|
availableMembers,
|
||||||
|
needPassword: !!event.accessPassword
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 이벤트 조회 오류:', e);
|
||||||
|
res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 URL(publicHash) 재생성
|
||||||
|
exports.regeneratePublicHash = async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const event = await Event.findOne({ where: { id } });
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
// 랜덤 해시 생성 (중복 없는 값)
|
||||||
|
const generateRandomHash = async () => {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let hash;
|
||||||
|
let exists = true;
|
||||||
|
while (exists) {
|
||||||
|
hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||||
|
exists = await Event.findOne({ where: { publicHash: hash } });
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
const newHash = await generateRandomHash();
|
||||||
|
await event.update({ publicHash: newHash });
|
||||||
|
res.json({ publicHash: newHash });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 URL 재생성 오류:', e);
|
||||||
|
res.status(500).json({ message: '공개 URL 재생성 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 참가자 등록/수정
|
||||||
|
exports.registerPublicParticipant = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
const { participantId, memberId, status, paymentStatus, comment } = req.body || {};
|
||||||
|
try {
|
||||||
|
const event = await Event.findOne({ where: { publicHash } });
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
|
||||||
|
// 기존 참가자 있는지 검사(이름+연락처 기준)
|
||||||
|
let member = await Member.findOne({ where: { id:memberId } });
|
||||||
|
// if (!member) {
|
||||||
|
// member = await Member.create({ memberId, memberType: '일반' });
|
||||||
|
// }
|
||||||
|
let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } });
|
||||||
|
if (!participant) {
|
||||||
|
participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus });
|
||||||
|
} else {
|
||||||
|
await participant.update({ comment, status, paymentStatus });
|
||||||
|
}
|
||||||
|
res.json({ message: '참가 신청/수정이 완료되었습니다.' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 참가자 등록 오류:', e);
|
||||||
|
res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 게스트 추가
|
||||||
|
exports.addPublicGuest = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
const { name, password } = req.body || {};
|
||||||
|
try {
|
||||||
|
const event = await Event.findOne({ where: { publicHash } });
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
|
||||||
|
if (event.accessPassword) {
|
||||||
|
if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
// 게스트 멤버 생성
|
||||||
|
const member = await Member.create({ name, memberType: '게스트' });
|
||||||
|
await EventParticipant.create({ eventId: event.id, memberId: member.id });
|
||||||
|
res.json({ message: '게스트가 추가되었습니다.' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 게스트 추가 오류:', e);
|
||||||
|
res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 점수 수정
|
||||||
|
exports.updatePublicScore = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
const { participantId, score, password } = req.body || {};
|
||||||
|
try {
|
||||||
|
const event = await Event.findOne({ where: { publicHash } });
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
if (event.status !== '준비' && event.status !== '활성') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' });
|
||||||
|
if (event.accessPassword) {
|
||||||
|
if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId } });
|
||||||
|
if (!eventScore) {
|
||||||
|
eventScore = await EventScore.create({ eventId: event.id, participantId, score });
|
||||||
|
} else {
|
||||||
|
await eventScore.update({ score });
|
||||||
|
}
|
||||||
|
res.json({ message: '점수가 저장되었습니다.' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 점수 수정 오류:', e);
|
||||||
|
res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 이벤트 정보 업데이트
|
// 이벤트 정보 업데이트
|
||||||
exports.updateEvent = async (req, res) => {
|
exports.updateEvent = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -194,7 +454,9 @@ exports.updateEvent = async (req, res) => {
|
|||||||
entryFee,
|
entryFee,
|
||||||
gameCount,
|
gameCount,
|
||||||
laneCount,
|
laneCount,
|
||||||
status
|
status,
|
||||||
|
publicHash,
|
||||||
|
accessPassword
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const eventId = req.body.id;
|
const eventId = req.body.id;
|
||||||
let endDate = req.body.endDate;
|
let endDate = req.body.endDate;
|
||||||
@@ -228,9 +490,10 @@ exports.updateEvent = async (req, res) => {
|
|||||||
entryFee: entryFee !== undefined ? entryFee : event.entryFee,
|
entryFee: entryFee !== undefined ? entryFee : event.entryFee,
|
||||||
gameCount: gameCount || event.gameCount,
|
gameCount: gameCount || event.gameCount,
|
||||||
laneCount: laneCount || event.laneCount,
|
laneCount: laneCount || event.laneCount,
|
||||||
status: status || event.status
|
status: status || event.status,
|
||||||
|
publicHash: publicHash || event.publicHash,
|
||||||
|
accessPassword: accessPassword || null
|
||||||
};
|
};
|
||||||
|
|
||||||
await event.update(updateData);
|
await event.update(updateData);
|
||||||
|
|
||||||
const updatedEvent = await Event.findOne({
|
const updatedEvent = await Event.findOne({
|
||||||
@@ -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,35 +1327,41 @@ 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,
|
||||||
// 참가자 수 제한 확인
|
|
||||||
if (event.maxParticipants > 0) {
|
|
||||||
const currentParticipants = await EventParticipant.count({
|
|
||||||
where: {
|
|
||||||
eventId,
|
eventId,
|
||||||
status: { [Op.ne]: '취소' }
|
memberId,
|
||||||
}
|
clubId,
|
||||||
|
status: status || existingParticipant.status,
|
||||||
|
paymentStatus: paymentStatus || existingParticipant.paymentStatus,
|
||||||
|
comment: comment || existingParticipant.comment
|
||||||
});
|
});
|
||||||
|
participant = await EventParticipant.findOne({
|
||||||
if (currentParticipants >= event.maxParticipants) {
|
where: { id: existingParticipant.id },
|
||||||
return res.status(400).json({ message: '참가자 수가 초과되었습니다.' });
|
include: [
|
||||||
|
{
|
||||||
|
model: Member,
|
||||||
|
attributes: ['id', 'name']
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
|
});
|
||||||
const participant = await EventParticipant.create({
|
isUpdate = true;
|
||||||
|
} else {
|
||||||
|
// 없으면 create
|
||||||
|
participant = await EventParticipant.create({
|
||||||
eventId,
|
eventId,
|
||||||
memberId,
|
memberId,
|
||||||
status: status || '참가예정',
|
status: status || '참가예정',
|
||||||
paymentStatus: paymentStatus || '미납',
|
paymentStatus: paymentStatus || '미납',
|
||||||
|
comment: comment || null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
});
|
});
|
||||||
|
participant = await EventParticipant.findOne({
|
||||||
// 참가자 정보 조회
|
|
||||||
const participantWithDetails = await EventParticipant.findOne({
|
|
||||||
where: { id: participant.id },
|
where: { id: participant.id },
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
@@ -1085,14 +1370,15 @@ exports.addEventParticipant = async (req, res) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// publicController.js
|
||||||
|
// 공개 이벤트 관련 API만 담당 (비밀번호 인증, 점수입력 등)
|
||||||
|
const { Event, EventParticipant, Member, Club, EventScore, ClubMember, EventTeam, EventTeamMember, sequelize } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const { signPublicEventToken, verifyPublicEventToken } = require('../utils/publicJwt');
|
||||||
|
|
||||||
|
// 공개 이벤트 최초 진입 (토큰 또는 비밀번호 인증)
|
||||||
|
exports.publicEventEntry = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
const { password } = req.body || {};
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = await Event.findOne({
|
||||||
|
where: { publicHash },
|
||||||
|
include: [{ model: Club, attributes: ['id', 'name'] }]
|
||||||
|
});
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
if (event.status === '취소') {
|
||||||
|
return res.status(403).json({ message: '취소된 이벤트입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
let isAuthenticated = false;
|
||||||
|
|
||||||
|
// 1. 토큰 인증 우선
|
||||||
|
if (auth && auth.startsWith('Bearer ')) {
|
||||||
|
try {
|
||||||
|
const payload = verifyPublicEventToken(auth.slice(7));
|
||||||
|
if (payload.publicHash === event.publicHash) {
|
||||||
|
isAuthenticated = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 토큰 인증 실패: isAuthenticated는 false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 토큰 인증 실패 시
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// 비밀번호가 필요한 이벤트
|
||||||
|
if (event.accessPassword) {
|
||||||
|
if (!password) {
|
||||||
|
return res.status(401).json({ message: '비밀번호가 필요합니다.' });
|
||||||
|
}
|
||||||
|
if (password !== event.accessPassword) {
|
||||||
|
return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 비밀번호가 없거나(공개 이벤트) or 비밀번호 인증 성공
|
||||||
|
isAuthenticated = true;
|
||||||
|
token = signPublicEventToken({ publicHash: event.publicHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 인증 성공 시 데이터 반환
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const data = await exports.getEventByPublicHash(event, token);
|
||||||
|
return res.json(data);
|
||||||
|
}
|
||||||
|
// 4. 나머지는 모두 인증 실패
|
||||||
|
return res.status(401).json({ message: '인증이 필요합니다.' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[publicEventEntry] 공개 이벤트 최초 진입 오류', { publicHash, error: e });
|
||||||
|
res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 이벤트 조회 (내부 데이터 반환 전용)
|
||||||
|
exports.getEventByPublicHash = async (event, token = null) => {
|
||||||
|
try {
|
||||||
|
const allMembers = await Member.findAll({
|
||||||
|
where: { clubId: event.clubId },
|
||||||
|
attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average']
|
||||||
|
});
|
||||||
|
const participants = await EventParticipant.findAll({
|
||||||
|
where: { eventId: event.id, status: { [Op.ne]: '취소' } },
|
||||||
|
include: [ { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] } ]
|
||||||
|
});
|
||||||
|
const allMemberObjs = allMembers.map(m => m.dataValues);
|
||||||
|
const participantMemberIds = [
|
||||||
|
...new Set(participants.filter(p => p.dataValues.status !== '취소').map(p => parseInt(p.dataValues.memberId)))
|
||||||
|
];
|
||||||
|
// 상태별로 미참가자/게스트 추가 제한
|
||||||
|
let availableMembers = [];
|
||||||
|
if (event.status === '준비') {
|
||||||
|
availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId)));
|
||||||
|
} // 준비 아닐 땐 빈 배열(신청/게스트 추가 불가)
|
||||||
|
// 팀 정보
|
||||||
|
const teams = await EventTeam.findAll({
|
||||||
|
where: { eventId: event.id },
|
||||||
|
order: [['teamNumber', 'ASC']],
|
||||||
|
include: [{
|
||||||
|
model: EventTeamMember,
|
||||||
|
include: [{
|
||||||
|
model: EventParticipant,
|
||||||
|
where: { status: { [Op.ne]: '취소' } },
|
||||||
|
include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
const scores = await EventScore.findAll({ where: { eventId: event.id } });
|
||||||
|
// 참가자 id → 참가자 객체 Map
|
||||||
|
const participantMap = new Map();
|
||||||
|
participants.forEach(p => participantMap.set(p.id, p));
|
||||||
|
|
||||||
|
// 팀에 소속된 참가자 id Set
|
||||||
|
const assignedParticipantIds = new Set();
|
||||||
|
const teamList = teams.map(team => ({
|
||||||
|
teamId: team.id,
|
||||||
|
teamNumber: team.teamNumber,
|
||||||
|
handicap: team.handicap,
|
||||||
|
members: (team.EventTeamMembers || []).map(tm => {
|
||||||
|
const participant = tm.EventParticipant;
|
||||||
|
if (participant?.id) assignedParticipantIds.add(participant.id);
|
||||||
|
const member = participant?.Member;
|
||||||
|
// 각 게임별 점수 배열 생성
|
||||||
|
const scoresArr = Array(event.gameCount).fill(null);
|
||||||
|
scores.filter(s => s.participantId === participant?.id).forEach(s => {
|
||||||
|
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
|
||||||
|
scoresArr[s.gameNumber - 1] = s.score;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
participantId: participant?.id,
|
||||||
|
memberId: member?.memberId,
|
||||||
|
name: member?.name || `참가자 ${participant?.id}`,
|
||||||
|
gender: member?.gender,
|
||||||
|
memberType: member?.memberType,
|
||||||
|
average: member?.average || 0,
|
||||||
|
handicap: member?.handicap || 0,
|
||||||
|
status: participant?.status,
|
||||||
|
paymentStatus: participant?.paymentStatus,
|
||||||
|
comment: participant?.comment,
|
||||||
|
isGuest: member?.memberType === '게스트',
|
||||||
|
scores: scoresArr
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 팀에 소속되지 않은 참가자(미배정)
|
||||||
|
const unassignedMembers = participants
|
||||||
|
.filter(p => !assignedParticipantIds.has(p.id))
|
||||||
|
.map(p => {
|
||||||
|
const member = p.Member;
|
||||||
|
// 각 게임별 점수 배열 생성
|
||||||
|
const scoresArr = Array(event.gameCount).fill(null);
|
||||||
|
scores.filter(s => s.participantId === p.id).forEach(s => {
|
||||||
|
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
|
||||||
|
scoresArr[s.gameNumber - 1] = s.score;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
participantId: p.id,
|
||||||
|
memberId: p.memberId,
|
||||||
|
name: member?.name || `참가자 ${p.id}`,
|
||||||
|
gender: member?.gender,
|
||||||
|
memberType: member?.memberType,
|
||||||
|
handicap: member?.handicap || 0,
|
||||||
|
average: member?.average || 0,
|
||||||
|
status: p.status,
|
||||||
|
paymentStatus: p.paymentStatus,
|
||||||
|
comment: p.comment,
|
||||||
|
isGuest: member?.memberType === '게스트',
|
||||||
|
scores: scoresArr
|
||||||
|
};
|
||||||
|
});
|
||||||
|
let participantList = participants.map(p => {
|
||||||
|
const member = p.Member;
|
||||||
|
// 각 게임별 점수 배열 생성
|
||||||
|
const scoresArr = Array(event.gameCount).fill(null);
|
||||||
|
scores.filter(s => s.participantId === p.id).forEach(s => {
|
||||||
|
if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) {
|
||||||
|
scoresArr[s.gameNumber - 1] = s.score;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
participantId: p.id,
|
||||||
|
memberId: p.memberId,
|
||||||
|
name: member?.name || `참가자 ${p.id}`,
|
||||||
|
gender: member?.gender,
|
||||||
|
memberType: member?.memberType,
|
||||||
|
handicap: member?.handicap || 0,
|
||||||
|
average: member?.average || 0,
|
||||||
|
status: p.status,
|
||||||
|
paymentStatus: p.paymentStatus,
|
||||||
|
comment: p.comment,
|
||||||
|
isGuest: member?.memberType === '게스트',
|
||||||
|
scores: scoresArr
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
clubName: event.Club.name,
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
eventType: event.eventType,
|
||||||
|
startDate: event.startDate,
|
||||||
|
endDate: event.endDate,
|
||||||
|
location: event.location,
|
||||||
|
gameCount: event.gameCount,
|
||||||
|
registrationDeadline: event.registrationDeadline,
|
||||||
|
maxParticipants: event.maxParticipants,
|
||||||
|
participantFee: event.participantFee,
|
||||||
|
status: event.status
|
||||||
|
},
|
||||||
|
teams: teamList,
|
||||||
|
participants: participantList,
|
||||||
|
unassignedMembers,
|
||||||
|
availableMembers,
|
||||||
|
needPassword: !!event.accessPassword,
|
||||||
|
...(token ? { token } : {})
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 이벤트 조회 오류:', e);
|
||||||
|
throw new Error('이벤트 정보를 가져오는 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 참가자 등록/수정
|
||||||
|
exports.registerPublicParticipant = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
const { participantId, memberId, status, paymentStatus, comment } = req.body || {};
|
||||||
|
try {
|
||||||
|
const event = await Event.findOne({ where: { publicHash } });
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
|
||||||
|
let member = await Member.findOne({ where: { id: memberId } });
|
||||||
|
let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } });
|
||||||
|
if (!participant) {
|
||||||
|
participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus });
|
||||||
|
} else {
|
||||||
|
await participant.update({ comment, status, paymentStatus });
|
||||||
|
}
|
||||||
|
res.json({ message: '참가 신청/수정이 완료되었습니다.' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 참가자 등록 오류:', e);
|
||||||
|
res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 게스트 추가
|
||||||
|
exports.addPublicGuest = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
const { name, phone, gender, average } = req.body || {};
|
||||||
|
try {
|
||||||
|
const event = await Event.findOne({ where: { publicHash } });
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' });
|
||||||
|
// publicAuth 미들웨어로 인증됨
|
||||||
|
const member = await Member.create({
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
gender,
|
||||||
|
clubId: event.clubId,
|
||||||
|
memberType: '게스트',
|
||||||
|
average: average || 0
|
||||||
|
});
|
||||||
|
const participant = await EventParticipant.create({ eventId: event.id, memberId: member.id });
|
||||||
|
res.json({
|
||||||
|
message: '게스트가 추가되었습니다.',
|
||||||
|
data: {
|
||||||
|
memberId: member.id,
|
||||||
|
participantId: participant.id,
|
||||||
|
name: member.name,
|
||||||
|
phone: member.phone,
|
||||||
|
gender: member.gender,
|
||||||
|
memberType: member.memberType,
|
||||||
|
handicap: member.handicap,
|
||||||
|
average: member.average,
|
||||||
|
group: '미신청'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 게스트 추가 오류:', e);
|
||||||
|
res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공개 점수 수정
|
||||||
|
exports.updatePublicScore = async (req, res) => {
|
||||||
|
const { publicHash } = req.params;
|
||||||
|
let { participantId, gameNumber, score, handicap, teamNumber } = req.body || {};
|
||||||
|
score = Number(score);
|
||||||
|
handicap = Number(handicap) || 0; // undefined/null이면 0
|
||||||
|
teamNumber = teamNumber ?? null;
|
||||||
|
try {
|
||||||
|
const event = await Event.findOne({ where: { publicHash } });
|
||||||
|
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
|
||||||
|
if (event.status !== '준비' && event.status !== '활성') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' });
|
||||||
|
// publicAuth 미들웨어로 인증됨
|
||||||
|
let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId, gameNumber } });
|
||||||
|
if (!eventScore) {
|
||||||
|
eventScore = await EventScore.create({ eventId: event.id, participantId, gameNumber, teamNumber, score, handicap, totalScore: score + handicap });
|
||||||
|
} else {
|
||||||
|
await eventScore.update({ teamNumber, score, handicap, totalScore: score + handicap });
|
||||||
|
}
|
||||||
|
res.json({ message: '점수가 저장되었습니다.' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('공개 점수 수정 오류:', e);
|
||||||
|
res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// middleware/publicAuth.js
|
||||||
|
const { verifyPublicEventToken } = require('../utils/publicJwt');
|
||||||
|
|
||||||
|
module.exports = (req, res, next) => {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ message: '토큰 필요' });
|
||||||
|
try {
|
||||||
|
req.publicAuth = verifyPublicEventToken(auth.slice(7));
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(401).json({ message: '토큰 인증 실패' });
|
||||||
|
}
|
||||||
|
};
|
||||||
+21
-10
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
+23
-14
@@ -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'
|
||||||
}
|
|
||||||
},
|
},
|
||||||
requiredSubscriptionTier: {
|
comment: '연결된 기능 코드'
|
||||||
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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -31,4 +31,9 @@ router.delete('/:id/participants/:participantId/scores', authenticateJWT, requir
|
|||||||
// 사용자별 이벤트 관리
|
// 사용자별 이벤트 관리
|
||||||
router.get('/user/events', authenticateJWT, requireFeature('event_management'), eventController.getUserEvents);
|
router.get('/user/events', authenticateJWT, requireFeature('event_management'), eventController.getUserEvents);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 공개 URL(publicHash) 재생성
|
||||||
|
router.patch('/:id/publicHash', eventController.regeneratePublicHash);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const publicController = require('../controllers/publicController');
|
||||||
|
const publicAuth = require('../middleware/publicAuth');
|
||||||
|
|
||||||
|
// 공개 이벤트 최초 진입 (비밀번호 인증 또는 토큰 인증)
|
||||||
|
router.post('/:publicHash', publicController.publicEventEntry);
|
||||||
|
// 공개 참가자 등록/수정
|
||||||
|
router.post('/:publicHash/participant', publicAuth, publicController.registerPublicParticipant);
|
||||||
|
// 공개 게스트 추가
|
||||||
|
router.post('/:publicHash/guest', publicAuth, publicController.addPublicGuest);
|
||||||
|
// 공개 점수 수정
|
||||||
|
router.put('/:publicHash/score', publicAuth, publicController.updatePublicScore);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// utils/publicJwt.js
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const SECRET = process.env.PUBLIC_EVENT_JWT_SECRET || 'default_public_event_secret';
|
||||||
|
|
||||||
|
exports.signPublicEventToken = (payload) => {
|
||||||
|
const token = jwt.sign(payload, SECRET, { expiresIn: '2h' });
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
exports.verifyPublicEventToken = (token) => {
|
||||||
|
const decoded = jwt.verify(token, SECRET);
|
||||||
|
return decoded;
|
||||||
|
};
|
||||||
Generated
+119
-11
@@ -13,16 +13,20 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
"@fullcalendar/vue3": "^6.1.15",
|
"@fullcalendar/vue3": "^6.1.15",
|
||||||
|
"@primeuix/themes": "^1.0.3",
|
||||||
|
"@primevue/themes": "^4.3.3",
|
||||||
"@vuelidate/core": "^2.0.3",
|
"@vuelidate/core": "^2.0.3",
|
||||||
"@vuelidate/validators": "^2.0.4",
|
"@vuelidate/validators": "^2.0.4",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"primeflex": "^3.3.1",
|
"primeflex": "^3.3.1",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^3.49.1",
|
"primelocale": "^2.1.2",
|
||||||
|
"primevue": "^4.3.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vee-validate": "^4.12.5",
|
"vee-validate": "^4.12.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
@@ -1045,6 +1049,87 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@primeuix/styled": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-5Ftw/KSauDPClQ8F2qCyCUF7cIUEY4yLNikf0rKV7Vsb8zGYNK0dahQe7CChaR6M2Kn+NA2DSBSk76ZXqj6Uog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/utils": "^0.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@primeuix/styles": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-yHj/Q+fosJ1736Ty5lRbpqhKa9piou+xZPPppNHUDshq0+XhrFwDGggvPGmDAJyUIM+ChM/Nj8lPY/AwTNXAkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/styled": "^0.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@primeuix/themes": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-f/1qadrv5TFMHfvtVv4Y9zjrkeDP2BO/cuzbHBO9DYxKL6YBIPT9BjKec2K4Kg8PcfGm6CAvxAvICadJSWejRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/styled": "^0.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@primeuix/utils": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-7SGh7734wcF1/uK6RzO6Z6CBjGQ97GDHfpyl2F1G/c7R0z9hkT/V72ypDo82AWcCS7Ta07oIjDpOCTkSVZuEGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@primevue/core": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-kSkN5oourG7eueoFPIqiNX3oDT/f0I5IRK3uOY/ytz+VzTZp5yuaCN0Nt42ZQpVXjDxMxDvUhIdaXVrjr58NhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/styled": "^0.5.0",
|
||||||
|
"@primeuix/utils": "^0.5.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.11.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@primevue/icons": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-ouQaxHyeFB6MSfEGGbjaK5Qv9efS1xZGetZoU5jcPm090MSYLFtroP1CuK3lZZAQals06TZ6T6qcoNukSHpK5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/utils": "^0.5.1",
|
||||||
|
"@primevue/core": "4.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@primevue/themes": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@primevue/themes/-/themes-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-LiYlSXsHeA8DFm8+yGyiDFQc3SEQwHcESTN1/rV+rrZ+UPuPisHY9fNIGRFQKA5XUQPDTQDQjtwYGx25Jikwhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/styled": "^0.5.0",
|
||||||
|
"@primeuix/themes": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/pluginutils": {
|
"node_modules/@rollup/pluginutils": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
||||||
@@ -2561,6 +2646,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -2844,18 +2935,35 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/primeicons": {
|
"node_modules/primeicons": {
|
||||||
"version": "6.0.1",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
|
||||||
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==",
|
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/primevue": {
|
"node_modules/primelocale": {
|
||||||
"version": "3.53.1",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.53.1.tgz",
|
"resolved": "https://registry.npmjs.org/primelocale/-/primelocale-2.1.2.tgz",
|
||||||
"integrity": "sha512-Bp4peZPdhfKYXwvtsOGGh5dfgmTelm+LZEZKGs/c5mOHhsUJ6xi3EcOZoQVI6oklS946ayMQvgD5L0S7itGO0g==",
|
"integrity": "sha512-sTDkqu/QBwpqzq86oZGXaU/QbPLKLZ3Qix2gbqHXr410B7tfQCx39HSWjM9Hsnpzqn9KVdXJzk1DH1yW1iNNPg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"engines": {
|
||||||
"vue": "^3.0.0"
|
"node": ">=18.0.0",
|
||||||
|
"npm": ">=8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/primevue": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/primevue/-/primevue-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-nooYVoEz5CdP3EhUkD6c3qTdRmpLHZh75fBynkUkl46K8y5rksHTjdSISiDijwTA5STQIOkyqLb+RM+HQ6nC1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/styled": "^0.5.0",
|
||||||
|
"@primeuix/styles": "^1.0.0",
|
||||||
|
"@primeuix/utils": "^0.5.1",
|
||||||
|
"@primevue/core": "4.3.3",
|
||||||
|
"@primevue/icons": "4.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/property-expr": {
|
"node_modules/property-expr": {
|
||||||
|
|||||||
@@ -15,16 +15,20 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
"@fullcalendar/vue3": "^6.1.15",
|
"@fullcalendar/vue3": "^6.1.15",
|
||||||
|
"@primeuix/themes": "^1.0.3",
|
||||||
|
"@primevue/themes": "^4.3.3",
|
||||||
"@vuelidate/core": "^2.0.3",
|
"@vuelidate/core": "^2.0.3",
|
||||||
"@vuelidate/validators": "^2.0.4",
|
"@vuelidate/validators": "^2.0.4",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"primeflex": "^3.3.1",
|
"primeflex": "^3.3.1",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^3.49.1",
|
"primelocale": "^2.1.2",
|
||||||
|
"primevue": "^4.3.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vee-validate": "^4.12.5",
|
"vee-validate": "^4.12.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
|||||||
+36
-21
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<template v-if="isRouterReady">
|
||||||
|
<AppPublicLayout v-if="isPublicLayout">
|
||||||
|
<router-view />
|
||||||
|
</AppPublicLayout>
|
||||||
|
<div v-else class="app">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<div class="app-content">
|
<div class="app-content">
|
||||||
<!-- 사이드바 오버레이 추가 -->
|
|
||||||
<div v-if="isSidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div>
|
<div v-if="isSidebarVisible && isMobileView" class="sidebar-overlay" @click="closeSidebar"></div>
|
||||||
|
|
||||||
<!-- 사이드바에 모바일 클래스 추가 -->
|
|
||||||
<aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': isSidebarVisible, 'mobile-hidden': !isSidebarVisible && isMobileView }">
|
<aside v-if="isLoggedIn" class="sidebar" :class="{ 'mobile-visible': isSidebarVisible, 'mobile-hidden': !isSidebarVisible && isMobileView }">
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<!-- 클럽 정보 -->
|
<!-- 클럽 정보 -->
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 클럽 선택 기능 (관리자 또는 클럽이 2개 이상) -->
|
<!-- 클럽 선택 기능 (관리자 또는 클럽이 2개 이상) -->
|
||||||
<div v-if="isAdmin || clubs.length > 1" class="club-selector">
|
<div v-if="isAdmin || clubs.length > 1" class="club-selector">
|
||||||
<label for="club-select">클럽 선택:</label>
|
<label for="club-select">클럽 선택:</label>
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
|
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}({{ club.memberType }})</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 커스텀 사이드바 메뉴 -->
|
<!-- 커스텀 사이드바 메뉴 -->
|
||||||
<div class="custom-sidebar-menu">
|
<div class="custom-sidebar-menu">
|
||||||
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
|
<template v-for="(menuGroup, groupIndex) in menuItems" :key="groupIndex">
|
||||||
@@ -44,7 +42,6 @@
|
|||||||
<span>{{ menuGroup.label }}</span>
|
<span>{{ menuGroup.label }}</span>
|
||||||
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
|
<i class="pi" :class="expandedGroups[groupIndex] ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
|
<div class="menu-items" v-if="expandedGroups[groupIndex]" v-show="menuGroup.items && menuGroup.items.length">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="(item, itemIndex) in menuGroup.items"
|
v-for="(item, itemIndex) in menuGroup.items"
|
||||||
@@ -61,7 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 그룹이 없는 단일 메뉴 아이템 -->
|
<!-- 그룹이 없는 단일 메뉴 아이템 -->
|
||||||
<template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex">
|
<template v-for="(menuItem, itemIndex) in singleMenuItems" :key="'single-'+itemIndex">
|
||||||
<router-link
|
<router-link
|
||||||
@@ -78,23 +74,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main-content" :class="{ 'full-width': !isLoggedIn }">
|
<main class="main-content" :class="{ 'full-width': !isLoggedIn }">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>© {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
|
<p>© {{ new Date().getFullYear() }} 볼링 클럽 매니저. All rights reserved.</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, provide } from 'vue';
|
import { ref, computed, onMounted, onBeforeUnmount, provide } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import AppHeader from './components/AppHeader.vue';
|
import AppHeader from './components/AppHeader.vue';
|
||||||
|
import AppPublicLayout from './layouts/AppPublicLayout.vue';
|
||||||
import apiClient from './services/api';
|
import apiClient from './services/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const layoutComponent = computed(() => route.meta.layout === 'public' ? AppPublicLayout : 'div');
|
||||||
|
const isPublicLayout = computed(() => {
|
||||||
|
// 라우트 meta.layout이 public이거나, 경로가 /public/로 시작하면 public 레이아웃으로 간주
|
||||||
|
return route.meta.layout === 'public' || route.path.startsWith('/public/');
|
||||||
|
});
|
||||||
import authService from './services/authService';
|
import authService from './services/authService';
|
||||||
|
|
||||||
// 상태 변수
|
// 상태 변수
|
||||||
@@ -111,7 +114,7 @@ const selectedClubId = ref(localStorage.getItem('clubId') || null); // 선택된
|
|||||||
const selectedClub = ref(null);
|
const selectedClub = ref(null);
|
||||||
const memberCount = ref(0);
|
const memberCount = ref(0);
|
||||||
const ownerName = ref('');
|
const ownerName = ref('');
|
||||||
|
const isRouterReady = ref(false);
|
||||||
const userRole = computed(() => user.value?.role || '');
|
const userRole = computed(() => user.value?.role || '');
|
||||||
const isAdmin = computed(() => userRole.value === 'admin' || userRole.value === 'superadmin');
|
const isAdmin = computed(() => userRole.value === 'admin' || userRole.value === 'superadmin');
|
||||||
|
|
||||||
@@ -156,6 +159,7 @@ const initializeExpandedGroups = () => {
|
|||||||
|
|
||||||
// 클럽 목록 가져오기
|
// 클럽 목록 가져오기
|
||||||
const fetchClubs = async () => {
|
const fetchClubs = async () => {
|
||||||
|
if (isPublicLayout.value) return;
|
||||||
try {
|
try {
|
||||||
const userId = user.value?.id;
|
const userId = user.value?.id;
|
||||||
const userRole = localStorage.getItem('userRole');
|
const userRole = localStorage.getItem('userRole');
|
||||||
@@ -182,6 +186,7 @@ const fetchClubs = async () => {
|
|||||||
|
|
||||||
// 클럽 정보 가져오기
|
// 클럽 정보 가져오기
|
||||||
const fetchClubInfo = async () => {
|
const fetchClubInfo = async () => {
|
||||||
|
if (isPublicLayout.value) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(`/api/club`, { clubId: selectedClubId.value ?? clubs.value[0].id });
|
const response = await apiClient.post(`/api/club`, { clubId: selectedClubId.value ?? clubs.value[0].id });
|
||||||
|
|
||||||
@@ -201,6 +206,7 @@ const fetchClubInfo = async () => {
|
|||||||
|
|
||||||
// 클럽 선택
|
// 클럽 선택
|
||||||
const setSelectedClub = async (club) => {
|
const setSelectedClub = async (club) => {
|
||||||
|
if (isPublicLayout.value) return;
|
||||||
// 1. localStorage에 저장
|
// 1. localStorage에 저장
|
||||||
localStorage.setItem('clubId', club.id);
|
localStorage.setItem('clubId', club.id);
|
||||||
localStorage.setItem('userClubRole', club.memberType || '');
|
localStorage.setItem('userClubRole', club.memberType || '');
|
||||||
@@ -237,6 +243,7 @@ const onClubChange = async () => {
|
|||||||
|
|
||||||
// 메뉴 아이템 로드
|
// 메뉴 아이템 로드
|
||||||
const loadMenuItems = async () => {
|
const loadMenuItems = async () => {
|
||||||
|
if (isPublicLayout.value) return;
|
||||||
try {
|
try {
|
||||||
const role = localStorage.getItem('userRole');
|
const role = localStorage.getItem('userRole');
|
||||||
const clubId = selectedClubId.value;
|
const clubId = selectedClubId.value;
|
||||||
@@ -324,10 +331,19 @@ const handleUserLoggedIn = async () => {
|
|||||||
|
|
||||||
// 컴포넌트 마운트 시 데이터 로드
|
// 컴포넌트 마운트 시 데이터 로드
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await router.isReady?.()
|
||||||
|
isRouterReady.value = true
|
||||||
|
// public 레이아웃이면 내부 데이터 로딩/초기화 완전 스킵
|
||||||
|
if (isPublicLayout.value) {
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
window.addEventListener('user-logged-in', handleUserLoggedIn);
|
||||||
|
initializeExpandedGroups();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 내부(클럽) 페이지만 내부 데이터 로딩
|
||||||
await loadUserData();
|
await loadUserData();
|
||||||
const clubId = localStorage.getItem('clubId');
|
const clubId = localStorage.getItem('clubId');
|
||||||
if (clubId) {
|
if (clubId) {
|
||||||
// 서버 세션에 clubId를 동기화
|
|
||||||
apiClient.post('/api/club/select-club', { clubId });
|
apiClient.post('/api/club/select-club', { clubId });
|
||||||
}
|
}
|
||||||
if (isLoggedIn.value) {
|
if (isLoggedIn.value) {
|
||||||
@@ -335,14 +351,8 @@ onMounted(async () => {
|
|||||||
await fetchClubInfo();
|
await fetchClubInfo();
|
||||||
loadMenuItems();
|
loadMenuItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 크기 변경 감지
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
// 로그인 성공 이벤트 감지
|
|
||||||
window.addEventListener('user-logged-in', handleUserLoggedIn);
|
window.addEventListener('user-logged-in', handleUserLoggedIn);
|
||||||
|
|
||||||
// 초기 expandedGroups 설정
|
|
||||||
initializeExpandedGroups();
|
initializeExpandedGroups();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,7 +362,12 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('user-logged-in', handleUserLoggedIn);
|
window.removeEventListener('user-logged-in', handleUserLoggedIn);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* App.vue 스타일 */
|
/* App.vue 스타일 */
|
||||||
.app {
|
.app {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--color-background: var(--vt-c-black);
|
--color-background: var(--vt-c-black);
|
||||||
@@ -49,13 +50,13 @@
|
|||||||
--color-text: var(--vt-c-text-dark-2);
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -7,41 +7,59 @@
|
|||||||
: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"
|
||||||
@@ -50,7 +68,7 @@
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- 점수 관리 탭 -->
|
<!-- 점수 관리 탭 -->
|
||||||
<TabPanel header="점수 관리">
|
<TabPanel value="점수 관리">
|
||||||
<ScoreManagement
|
<ScoreManagement
|
||||||
:eventId="eventModel.id"
|
:eventId="eventModel.id"
|
||||||
:participants="eventModel.participants || []"
|
:participants="eventModel.participants || []"
|
||||||
@@ -59,10 +77,11 @@
|
|||||||
/>
|
/>
|
||||||
</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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,84 +9,82 @@
|
|||||||
>
|
>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="field">
|
<label for="title">이벤트 제목 *</label>
|
||||||
<label for="title">제목 *</label>
|
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" variant="filled" required autofocus />
|
||||||
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" required autofocus />
|
|
||||||
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
|
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="field">
|
<label for="description">이벤트 설명</label>
|
||||||
<label for="description">설명</label>
|
<Textarea id="description" v-model="eventModel.description" variant="filled" rows="3" autoResize class="w-full" />
|
||||||
<Textarea id="description" v-model="eventModel.description" rows="3" autoResize />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
|
||||||
<label for="eventType">이벤트 유형 *</label>
|
<label for="eventType">이벤트 유형 *</label>
|
||||||
<Dropdown id="eventType" v-model="eventModel.eventType" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType}" />
|
<Select id="eventType" v-model="eventModel.eventType" variant="filled" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType, 'w-full': true}"/>
|
||||||
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
|
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
|
||||||
<label for="status">상태 *</label>
|
<label for="status">상태 *</label>
|
||||||
<Dropdown id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status}" />
|
<Select id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status, 'w-full': true}" />
|
||||||
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
|
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
|
||||||
<label for="startDate">시작일 *</label>
|
<label for="startDate">시작일 *</label>
|
||||||
<Calendar id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.startDate}" />
|
<Datepicker id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.startDate, 'w-full': true}" />
|
||||||
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
|
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
|
||||||
<label for="endDate">종료일</label>
|
<label for="endDate">종료일</label>
|
||||||
<Calendar id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.endDate}" />
|
<Datepicker id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.endDate, 'w-full': true}" />
|
||||||
</div>
|
<small class="p-error" v-if="submitted && !eventModel.endDate">종료일은 필수입니다.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="field">
|
|
||||||
<label for="location">장소</label>
|
<label for="location">장소</label>
|
||||||
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location}" />
|
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location, 'w-full': true}" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
|
||||||
<label for="maxParticipants">최대 참가자 수</label>
|
<label for="maxParticipants">최대 참가자 수</label>
|
||||||
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" showButtons />
|
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" class="w-full" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
|
||||||
<label for="gameCount">게임 수</label>
|
<label for="gameCount">게임 수</label>
|
||||||
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" showButtons />
|
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" class="w-full" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
|
||||||
<label for="participantFee">참가비</label>
|
<label for="participantFee">참가비</label>
|
||||||
<InputNumber id="participantFee" v-model="eventModel.entryFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" />
|
<InputNumber id="participantFee" v-model="eventModel.participantFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" class="w-full" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-6">
|
<div class="col-12 md:col-6">
|
||||||
<div class="field">
|
<label for="registrationDeadline">신청마감</label>
|
||||||
<label for="registrationDeadline">등록 마감일</label>
|
<Datepicker id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.registrationDeadline, 'w-full': true}" />
|
||||||
<Calendar id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6">
|
||||||
|
<label for="publicHash">공개 URL</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputText id="publicHash" v-model="eventModel.publicHash" readonly placeholder="생성 후 표시" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
@click="regeneratePublicHash"
|
||||||
|
:disabled="isNew || loadingHash"
|
||||||
|
class="p-button-sm"
|
||||||
|
:aria-label="eventModel.publicHash ? '공개 URL 재생성' : '공개 URL 생성'"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-6">
|
||||||
|
<label for="accessPassword">공개 비밀번호(선택)</label>
|
||||||
|
<InputText id="accessPassword" v-model="eventModel.accessPassword" type="text" maxlength="32" placeholder="공개 페이지 접근용 비밀번호(선택)" style="width:100%;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,6 +97,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, toRefs, computed } from 'vue';
|
import { ref, watch, toRefs, computed } from 'vue';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@@ -142,6 +141,10 @@ const eventTypeOptions = [
|
|||||||
|
|
||||||
// 이벤트 모델
|
// 이벤트 모델
|
||||||
const eventModel = ref({ ...props.event });
|
const eventModel = ref({ ...props.event });
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
const toast = useToast();
|
||||||
|
const loadingHash = ref(false);
|
||||||
|
|
||||||
|
|
||||||
// 날짜 형식 변환
|
// 날짜 형식 변환
|
||||||
const formatDate = (date) => {
|
const formatDate = (date) => {
|
||||||
@@ -160,6 +163,26 @@ watch(() => props.event, (newValue) => {
|
|||||||
eventModel.value = formattedEvent;
|
eventModel.value = formattedEvent;
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
|
// 공개 URL(공개 해시) 재생성
|
||||||
|
const regeneratePublicHash = async () => {
|
||||||
|
if (props.isNew) return;
|
||||||
|
loadingHash.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/club/events/${eventModel.value.id}/publicHash`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('공개 URL 생성/재생성에 실패했습니다.');
|
||||||
|
const data = await res.json();
|
||||||
|
eventModel.value.publicHash = data.publicHash;
|
||||||
|
toast.add({ severity: 'info', summary: '공개 URL 재생성', detail: '저장 버튼을 눌러야 최종 반영됩니다.', life: 2000 });
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: '오류', detail: e.message });
|
||||||
|
} finally {
|
||||||
|
loadingHash.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 이벤트 저장
|
// 이벤트 저장
|
||||||
const saveEvent = () => {
|
const saveEvent = () => {
|
||||||
const eventData = { ...eventModel.value };
|
const eventData = { ...eventModel.value };
|
||||||
@@ -172,6 +195,7 @@ const saveEvent = () => {
|
|||||||
if (eventData.registrationDeadline) {
|
if (eventData.registrationDeadline) {
|
||||||
eventData.registrationDeadline = dayjs(eventData.registrationDeadline).toDate();
|
eventData.registrationDeadline = dayjs(eventData.registrationDeadline).toDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('save', eventData);
|
emit('save', eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,4 +247,11 @@ const hideDialog = () => {
|
|||||||
:deep(.p-inputtext) {
|
:deep(.p-inputtext) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.public-hash-btn.p-button-icon-only {
|
||||||
|
min-width:28px;
|
||||||
|
height:28px;
|
||||||
|
padding:0;
|
||||||
|
font-size:1.1em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user