public 기능 완료
This commit is contained in:
@@ -23,31 +23,26 @@
|
||||
v-model:filters="filters"
|
||||
class="mt-4"
|
||||
>
|
||||
<Column field="id" header="ID" sortable style="width: 5%"></Column>
|
||||
<Column field="title" header="제목" sortable style="width: 20%">
|
||||
<template #filter="{ filterModel, filterCallback }">
|
||||
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="제목으로 검색" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="eventType" header="유형" sortable style="width: 10%">
|
||||
<template #body="slotProps">
|
||||
<Badge :value="getEventTypeName(slotProps.data.eventType)" :severity="getEventTypeSeverity(slotProps.data.eventType)" />
|
||||
</template>
|
||||
<template #filter="{ filterModel, filterCallback }">
|
||||
<Dropdown v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
|
||||
<Select v-model="filterModel.value" @change="filterCallback()" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" class="p-column-filter" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="title" header="제목" sortable>
|
||||
<template #filter="{ filterModel, filterCallback }">
|
||||
<InputText v-model="filterModel.value" @input="filterCallback()" class="p-column-filter" placeholder="제목으로 검색" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="startDate" header="시작일" sortable style="width: 12%">
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.startDate) }}
|
||||
</template>
|
||||
<template #filter="{ filterModel, filterCallback }">
|
||||
<Calendar v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="endDate" header="종료일" sortable style="width: 12%">
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.endDate) }}
|
||||
<Datepicker v-model="filterModel.value" @date-select="filterCallback()" dateFormat="yy-mm-dd" placeholder="날짜 선택" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="location" header="장소" sortable style="width: 15%"></Column>
|
||||
@@ -236,15 +231,20 @@ const getEventTypeName = (type) => {
|
||||
};
|
||||
|
||||
// 이벤트 상태 이름 반환
|
||||
const getStatusName = (status) => {
|
||||
switch (status) {
|
||||
case '준비': return '준비';
|
||||
case '진행중': return '진행중';
|
||||
case '완료': return '완료';
|
||||
case '취소': return '취소';
|
||||
default: return '알 수 없음';
|
||||
}
|
||||
const STATUS_LABELS = {
|
||||
'준비': '준비',
|
||||
'진행중': '진행중',
|
||||
'완료': '완료',
|
||||
'취소': '취소',
|
||||
'예정됨': '예정됨',
|
||||
'in_progress': '진행중',
|
||||
'completed': '완료',
|
||||
'cancelled': '취소',
|
||||
'활성': '활성',
|
||||
'비활성': '비활성',
|
||||
'삭제': '삭제',
|
||||
};
|
||||
const getStatusName = (status) => STATUS_LABELS[status] || status;
|
||||
|
||||
// 이벤트 상태에 따른 Badge 스타일
|
||||
const getStatusSeverity = (status) => {
|
||||
@@ -468,4 +468,22 @@ const navigateToCalendar = () => {
|
||||
:deep(.p-column-filter) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 테이블 헤더(정렬/필터 버튼) 한 줄 정렬 및 공간 최소화 */
|
||||
:deep(.p-column-header-content) {
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
:deep(.p-sortable-column-icon),
|
||||
:deep(.p-column-filter-menu-button) {
|
||||
margin-left: 2px !important;
|
||||
margin-right: 2px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 13px !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
:deep(.p-column-title) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
.event-public-wrapper {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.event-public-wrapper>.p-card>.p-card-body {
|
||||
padding: var(--p-card-body-padding) 0.25rem !important;
|
||||
}
|
||||
|
||||
h1.p-text-lg {
|
||||
font-size: 2.5rem !important;
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 0.75rem !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2.p-text-lg span.p-tag {
|
||||
margin-left: 0.25rem !important;
|
||||
}
|
||||
|
||||
.event-title-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.event-public-wrapper {
|
||||
max-width: 98vw;
|
||||
padding: 0 2vw;
|
||||
}
|
||||
}
|
||||
|
||||
.event-password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0 32px 0;
|
||||
border-radius: 12px;
|
||||
|
||||
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.event-password-lock {
|
||||
font-size: 2.5rem !important;
|
||||
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-password-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.event-password-btn {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.event-password-form .pi-lock {
|
||||
font-size: 2.8rem !important;
|
||||
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-password-form .p-text-lg {
|
||||
font-size: 1.14rem;
|
||||
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.event-password-form .p-inputtext {
|
||||
font-size: 1.1rem;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
|
||||
margin-bottom: 8px;
|
||||
width: 220px;
|
||||
|
||||
transition: border 0.2s;
|
||||
}
|
||||
|
||||
.event-password-form .p-inputtext:focus {
|
||||
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.event-password-form .p-button {
|
||||
width: 220px;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
.event-password-form .p-error {
|
||||
font-size: 1rem;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
margin-bottom: 4px !important;
|
||||
color: var(--p-gray-700);
|
||||
}
|
||||
|
||||
.info-under-name {
|
||||
font-size: 14px;
|
||||
margin-top: 2px;
|
||||
color: var(--p-gray-700);
|
||||
}
|
||||
|
||||
.status-cancel {
|
||||
font-weight: 600;
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.payment-unpaid {
|
||||
font-weight: 600;
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
|
||||
padding: 10px 0;
|
||||
font-size: 1.22rem;
|
||||
}
|
||||
|
||||
.member-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.member-info-block {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.member-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.member-meta {
|
||||
font-size: 14px;
|
||||
color: var(--p-gray-700);
|
||||
}
|
||||
|
||||
.score-inputs-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.score-input {
|
||||
width: 60px !important;
|
||||
height: 22px !important;
|
||||
padding: 0 6px !important;
|
||||
font-size: 15px !important;
|
||||
text-align: center;
|
||||
border-radius: 6px !important;
|
||||
|
||||
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.score-gray {
|
||||
color: var(--p-gray-500);
|
||||
}
|
||||
.score-blue {
|
||||
color: var(--p-blue-500);
|
||||
}
|
||||
.score-red {
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
.score-black {
|
||||
color: var(--p-black-500);
|
||||
}
|
||||
|
||||
.team-hc-small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.team-total-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.team-total-main {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 3px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.team-total-label {
|
||||
margin-right: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--p-blue-500);
|
||||
}
|
||||
|
||||
.team-total-scores {
|
||||
margin-left: 12px;
|
||||
letter-spacing: 2px;
|
||||
margin: 0 16px 0 8px;
|
||||
color: var(--p-blue-500);
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.team-score-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border-radius: 10px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.event-public-card {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.score-flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.score-main {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 순위별 색상 */
|
||||
.rank-badge.rank-1 {
|
||||
color: var(--p-yellow-500);
|
||||
}
|
||||
.rank-badge.rank-2 {
|
||||
color: var(--p-gray-400);
|
||||
}
|
||||
.rank-badge.rank-3 {
|
||||
color: var(--p-orange-500);
|
||||
}
|
||||
.rank-badge.rank-4 {
|
||||
color: var(--p-blue-400);
|
||||
}
|
||||
.rank-badge.rank-5 {
|
||||
color: var(--p-green-400);
|
||||
}
|
||||
/* 필요시 더 추가 가능 */
|
||||
|
||||
.team-score-table {
|
||||
|
||||
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 15px;
|
||||
|
||||
}
|
||||
|
||||
.team-score-table th,
|
||||
.team-score-table td {
|
||||
text-align: center !important;
|
||||
}
|
||||
.team-score-table td .score-main,
|
||||
.team-score-table td .rank-badge {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
.team-score-table th,
|
||||
.team-score-table .p-datatable-thead > tr > th > .p-datatable-column-header-content {
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--p-yellow-500);
|
||||
}
|
||||
|
||||
.score-table th,
|
||||
.score-table td {
|
||||
font-size: 12px !important;
|
||||
padding: 3px 2px !important;
|
||||
min-width: unset !important;
|
||||
word-break: keep-all;
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.score-table tr {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.score-table th,
|
||||
.score-table td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.team-card {
|
||||
border-radius: 16px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
}
|
||||
|
||||
.member-row {
|
||||
padding: 8px 0;
|
||||
font-size: 1.22rem;
|
||||
|
||||
}
|
||||
|
||||
.member-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.team-total-row-block {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.team-games-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0 12px;
|
||||
margin-top: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team-game-cell {
|
||||
|
||||
|
||||
font-size: 1.13rem;
|
||||
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
padding: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.team-game-cell b {
|
||||
|
||||
font-weight: 700;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.team-game-cell .rank-badge {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin-left: 3px;
|
||||
vertical-align: middle;
|
||||
color: var(--p-purple-500);
|
||||
}
|
||||
|
||||
.team-title-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.team-name-strong {
|
||||
font-size: 2.3rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 2px;
|
||||
color: var(--p-blue-500);
|
||||
}
|
||||
|
||||
.team-hc {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.member-meta {
|
||||
font-size: 0.98rem;
|
||||
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.team-total-row-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.score-input-hc-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.score-input-status-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 110px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.score-input-hc-wrap > .score-input,
|
||||
.score-input-hc-wrap > .score-hc-value {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.score-input {
|
||||
flex: 0 0 60px;
|
||||
min-width: 48px;
|
||||
max-width: 70px;
|
||||
text-align: right;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.score-hc-value {
|
||||
flex: 0 0 auto;
|
||||
min-width: 32px;
|
||||
margin-left: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
padding-right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score-status-icon {
|
||||
flex-shrink: 0;
|
||||
min-width: 18px;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.score-hc-value:empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.member-score-summary {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.member-score-avg {
|
||||
margin-left: 8px;
|
||||
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<div class="event-public-wrapper">
|
||||
<h1 v-if="clubName" class="p-text-primary p-text-bold p-text-lg p-mb-2">{{ clubName }}</h1>
|
||||
<Divider class="p-mb-4" />
|
||||
<Card class="p-shadow-8 p-p-5 p-rounded-xl event-public-card">
|
||||
<template #content>
|
||||
<div v-if="loading" class="p-text-center p-mb-4">로딩 중...</div>
|
||||
<div v-else-if="error" class="p-text-center p-mb-4">{{ error }}</div>
|
||||
<div v-else-if="!isAuthenticated">
|
||||
<div class="event-password-form flex flex-column p-ai-center p-mt-6 p-mb-6">
|
||||
<i class="pi pi-lock p-mb-3 event-password-lock"></i>
|
||||
<div class="p-text-center p-mb-3 p-text-lg event-password-desc">이 이벤트는 비밀번호가 필요합니다.<br>비밀번호를 입력해 주세요.</div>
|
||||
<InputText v-model="inputPassword" type="password" placeholder="비밀번호 입력" class="p-mb-2 event-password-input"
|
||||
@keyup.enter="verifyPassword" autofocus />
|
||||
<Button label="확인" @click="verifyPassword" class="p-mb-2 event-password-btn" :loading="loading" />
|
||||
<div v-if="passwordError" class="p-error p-mt-1 event-password-error">{{ passwordError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="event-tag">
|
||||
<Tag :value="event.eventType" class="event-type-tag" />
|
||||
<Tag :value="event.status"
|
||||
:severity="event.status === '완료' ? 'success' : event.status === '활성' ? 'info' : 'warning'"
|
||||
class="p-ml-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-content-end gap-2">
|
||||
<Button :label="joinButtonLabel" class="p-button-lg p-button-primary" @click="showJoinDialog = true" v-if="canJoin" />
|
||||
</div>
|
||||
<div class="flex justify-content-start p-ai-center p-mb-4">
|
||||
<h2 class="event-title-inline p-text-lg p-text-bold">
|
||||
{{ event.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-text-secondary p-mb-3">
|
||||
<div class="info-row">
|
||||
<i class="pi pi-calendar p-mr-5" /><strong>기간</strong>
|
||||
{{ formatDate(event.startDate) }}
|
||||
<template v-if="event.endDate">~ {{ formatDate(event.endDate) }}</template>
|
||||
</div>
|
||||
<div v-if="event.registrationDeadline" class="info-row">
|
||||
<i class="pi pi-calendar-times p-mr-2" /><strong>마감</strong> {{ formatDate(event.registrationDeadline)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="event.location" class="info-row">
|
||||
<i class="pi pi-map-marker p-mr-2" /><strong>장소</strong> {{ event.location }}
|
||||
</div>
|
||||
<div v-if="event.participantFee" class="info-row">
|
||||
<i class="pi pi-credit-card p-mr-2" /><strong>참가비</strong> {{
|
||||
Number(event.participantFee).toLocaleString('ko-KR') }}원
|
||||
</div>
|
||||
<div v-if="event.maxParticipants" class="info-row">
|
||||
<i class="pi pi-user p-mr-2" /><strong>참여</strong> {{ participants.length }}/{{ event.maxParticipants
|
||||
}}명
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-text-secondary p-mb-4">{{ event.description || '' }}</div>
|
||||
|
||||
<!-- 팀별 게임별 점수/등수 표: 하단으로 이동 -->
|
||||
<div v-if="teams.length > 0 || unassignedMembers.length > 0" class="team-score-table-wrapper p-mt-4">
|
||||
<DataTable :value="getAllTeamsWithUnassigned()" class="team-score-table" :responsiveLayout="'scroll'">
|
||||
<Column header="팀">
|
||||
<template #body="slotProps">
|
||||
팀 {{ slotProps.data.teamNumber }}
|
||||
<span v-if="slotProps.data.handicap" class="team-hc-small">
|
||||
팀 핸디캡: {{ slotProps.data.handicap ?? 0 }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column v-for="n in event.gameCount" :key="n" :header="n + 'G'">
|
||||
<template #body="slotProps">
|
||||
<span class="score-main">{{ getTeamTotalScore(slotProps.data, n) }}</span>
|
||||
<span class="block rank-badge"
|
||||
v-if="getRanksByGame(n)[(slotProps.data.id || slotProps.data.teamNumber) + ''] !== undefined"
|
||||
:class="'rank-badge rank-' + getRanksByGame(n)[(slotProps.data.id || slotProps.data.teamNumber) + '']">
|
||||
({{ getRanksByGame(n)[(slotProps.data.id || slotProps.data.teamNumber) + ''] }}위)
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="총점">
|
||||
<template #body="slotProps">
|
||||
{{Array.from({ length: event.gameCount }, (_, i) => getTeamTotalScore(slotProps.data, i +
|
||||
1)).reduce((a, b) => a + b, 0) }}
|
||||
<span class="block rank-badge" :class="'rank-' + getTeamOverallRank(slotProps.data)">
|
||||
({{ getTeamOverallRank(slotProps.data) }}위)
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
<div v-for="team in getAllTeamsWithUnassigned()" :key="team.id" class="team-card">
|
||||
<Card class=" p-rounded-lg p-shadow-1 p-p-3 p-mb-4">
|
||||
<template #title>
|
||||
<div class="team-title-row">
|
||||
<span class="team-name-strong">{{ parseInt(team.teamNumber) ? '팀 ' + team.teamNumber : '미배정 인원'}}</span>
|
||||
<span class="team-hc" v-if="team.handicap">팀 핸디캡: {{ team.handicap ?? 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="member-list">
|
||||
<div v-for="member in team.members" :key="member.participantId" class="member-row">
|
||||
<div class="member-info-block">
|
||||
<div class="member-header-row">
|
||||
<span class="member-name">{{ member.name }}</span>
|
||||
<Tag v-if="member.isGuest" value="G" severity="warning" class="ml-2" />
|
||||
</div>
|
||||
<div class="info-under-name">
|
||||
{{ member.gender || '-' }}
|
||||
<span v-if="member.status" :class="{
|
||||
'status-cancel': member.status === '취소',
|
||||
'status-pending': member.status === '참가예정'
|
||||
}">
|
||||
· {{ member.status }}
|
||||
</span>
|
||||
<span v-if="member.paymentStatus" :class="{
|
||||
'payment-unpaid': member.paymentStatus === '미납'
|
||||
}">
|
||||
· {{ member.paymentStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="member-meta">핸디캡: {{ member.handicap ?? '-' }} / 에버리지: {{ member.average ?? '-' }}
|
||||
</div>
|
||||
<div class="member-meta">{{ member.comment }}</div>
|
||||
</div>
|
||||
<div class="score-inputs-vertical">
|
||||
<template v-for="n in event.gameCount" :key="n">
|
||||
<div class="score-input-hc-wrap">
|
||||
<div class="score-input-status-wrap">
|
||||
<InputText
|
||||
v-if="canInputScore"
|
||||
v-model.number="scoresProxy[member.participantId + '-' + n]"
|
||||
type="number"
|
||||
class="score-input"
|
||||
:placeholder="n + 'G'"
|
||||
maxlength="3"
|
||||
@input="onScoreInput(member, n, team.teamNumber)"
|
||||
@blur="onScoreBlur(member, n, team.teamNumber)"
|
||||
@keydown.enter="onScoreEnter(member, n, team.teamNumber)"
|
||||
/>
|
||||
<span v-else>{{ member.scores?.[n - 1] ?? '-' }}</span>
|
||||
<span :class="scoreColorClass(scoreStates[member.participantId + '-' + n], scoresProxy[member.participantId + '-' + n], member.handicap)"
|
||||
class="score-hc-value score-status-text"
|
||||
:key="scoreStates[member.participantId + '-' + n] + '-' + scoresProxy[member.participantId + '-' + n]">
|
||||
{{ (scoresProxy[member.participantId + '-' + n] !== undefined && scoresProxy[member.participantId + '-' + n] !== '') ?
|
||||
Number(scoresProxy[member.participantId + '-' + n]) + (Number(member.handicap) || 0) : ''
|
||||
}}
|
||||
<i v-if="scoreStates[member.participantId + '-' + n] === 'saving'" class="pi pi-spin pi-spinner score-status-icon" />
|
||||
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'saved'" class="pi pi-check score-status-icon" />
|
||||
<i v-else-if="scoreStates[member.participantId + '-' + n] === 'error'" class="pi pi-times score-status-icon" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="member-score-summary">
|
||||
<span>총점: {{ getMemberTotalScore(member) }}</span>
|
||||
<span class="member-score-avg">/ 에버리지: {{ getMemberAverageScore(member) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div class="team-game-scores">
|
||||
<div class="team-total-row team-total-row-responsive team-total-row-block">
|
||||
<div class="team-games-grid"
|
||||
:style="{ gridTemplateColumns: `repeat(${event.gameCount > 3 ? 3 : event.gameCount}, 1fr)` }">
|
||||
<template v-for="n in event.gameCount" :key="n">
|
||||
<div class="team-game-cell">
|
||||
<b>{{ n }}G:</b> {{ getTeamTotalScore(team, n) }}
|
||||
<span v-if="getRanksByGame(n)[(team.id || team.teamNumber) + ''] !== undefined"
|
||||
class="rank-badge"
|
||||
:class="'rank-' + getRanksByGame(n)[(team.id || team.teamNumber) + '']">
|
||||
({{ getRanksByGame(n)[(team.id || team.teamNumber) + ''] }}위)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="team-total-main">
|
||||
<span class="team-total-label">총점</span>
|
||||
<span class="team-total-scores">{{Array.from({ length: event.gameCount }, (_, i) =>
|
||||
getTeamTotalScore(team, i + 1)).reduce((a, b) => a + b, 0)}}</span>
|
||||
<span v-if="getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })"
|
||||
class="rank-badge"
|
||||
:class="'rank-' + getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' })">
|
||||
{{ getTeamOverallRank({ ...team, id: (team.id || team.teamNumber) + '' }) }}위
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
<JoinDialog v-if="showJoinDialog && canJoin" v-model:visible="showJoinDialog" :publicHash="publicHash"
|
||||
:password="needPassword ? inputPassword : ''" :participants="participants"
|
||||
:availableMembers="availableMembers" :eventStatus="event.status"
|
||||
@close="showJoinDialog = false" @updated="reload" />
|
||||
<GuestDialog v-if="showGuestDialog && canAddGuest" v-model:visible="showGuestDialog" :publicHash="publicHash"
|
||||
:password="needPassword ? inputPassword : ''" @close="showGuestDialog = false" @updated="reload" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, reactive, onMounted } from 'vue';
|
||||
import { debounce } from 'lodash-es'; // lodash 설치 필요 (이미 있다면 무시)
|
||||
import { useRoute } from 'vue-router';
|
||||
import JoinDialog from './JoinDialog.vue';
|
||||
import dayjs from 'dayjs';
|
||||
import PublicEventService from '@/services/PublicEventService';
|
||||
|
||||
const route = useRoute();
|
||||
const publicHash = route.params.publicHash;
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const clubName = ref('');
|
||||
const event = ref({});
|
||||
const teams = ref([]);
|
||||
const participants = ref([]);
|
||||
const isAuthenticated = ref(false);
|
||||
const inputPassword = ref('');
|
||||
const passwordError = ref('');
|
||||
const showJoinDialog = ref(false);
|
||||
const showGuestDialog = ref(false);
|
||||
|
||||
const allMembers = ref([]); // 전체 클럽 멤버 목록
|
||||
const availableMembers = ref([]); // 참가 신청 가능 명단
|
||||
// ===== 이벤트 상태별 권한 제어 =====
|
||||
const isReady = computed(() => event.value.status === '준비');
|
||||
const isActive = computed(() => event.value.status === '활성');
|
||||
const isFinished = computed(() => event.value.status === '완료');
|
||||
|
||||
const canJoin = computed(() => (isReady.value || isActive.value) && isAuthenticated.value);
|
||||
const canAddGuest = computed(() => isReady.value && isAuthenticated.value);
|
||||
const canEditParticipant = computed(() => (isReady.value || isActive.value) && isAuthenticated.value);
|
||||
const canInputScore = computed(() => isActive.value && isAuthenticated.value);
|
||||
|
||||
const joinButtonLabel = computed(() => {
|
||||
if (isReady.value) return '참가 신청/수정';
|
||||
if (isActive.value) return '참가자 정보 수정';
|
||||
return '참가자 정보';
|
||||
});
|
||||
// ==============================
|
||||
|
||||
|
||||
// 점수 상태 관리: idle, saving, saved, error
|
||||
const scoreStates = reactive({});
|
||||
|
||||
// 점수 색상 클래스 계산
|
||||
// 점수 색상 클래스 계산 (핸디캡 포함)
|
||||
function scoreColorClass(state, value, handicap) {
|
||||
if (state === 'saving' || state === undefined) return 'score-gray';
|
||||
const baseScore = Number(value);
|
||||
const hc = Number(handicap) || 0;
|
||||
const total = baseScore + hc;
|
||||
if (state === 'saved') {
|
||||
if (value === '' || value === null || isNaN(baseScore)) return 'score-gray';
|
||||
return total >= 200 ? 'score-red' : 'score-black';
|
||||
}
|
||||
if (state === 'idle') {
|
||||
// 값이 있으면 색상 유지, 없으면 회색
|
||||
if (value === '' || value === null || isNaN(baseScore)) return 'score-gray';
|
||||
return total >= 200 ? 'score-red' : 'score-black';
|
||||
}
|
||||
if (state === 'error') return 'score-gray';
|
||||
return 'score-gray';
|
||||
}
|
||||
|
||||
// 점수 저장 로직 (API 연동)
|
||||
const saveScore = debounce(async (member, n, oldValue, newValue, teamNumber) => {
|
||||
const key = member.participantId + '-' + n;
|
||||
if (String(oldValue) === String(newValue)) return; // 변경 없으면 저장 X
|
||||
scoreStates[key] = 'saving';
|
||||
try {
|
||||
// 실제 API 연동
|
||||
const res = await PublicEventService.updateScore(publicHash, member.participantId, n, Number(newValue), Number(member.handicap || 0), teamNumber ?? null);
|
||||
if (!res.ok) throw new Error('점수 저장 실패');
|
||||
scoreStates[key] = 'saved';
|
||||
// 프론트 점수 동기화
|
||||
if (Array.isArray(member.scores)) {
|
||||
member.scores[n-1] = Number(newValue);
|
||||
}
|
||||
setTimeout(() => { scoreStates[key] = 'idle'; }, 1200); // 일정 시간 후 아이콘 사라지게
|
||||
} catch (e) {
|
||||
scoreStates[key] = 'error';
|
||||
setTimeout(() => { scoreStates[key] = 'idle'; }, 2000);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
|
||||
function onScoreBlur(member, n, teamNumber) {
|
||||
const key = member.participantId + '-' + n;
|
||||
const val = (scoresProxy.value[key] === undefined || scoresProxy.value[key] === null) ? '' : String(scoresProxy.value[key]);
|
||||
// 화면상의 이전 값(마지막 저장된 값)을 별도로 기억해야 함
|
||||
const oldVal = (onScoreBlur.lastValues && onScoreBlur.lastValues[key] !== undefined) ? String(onScoreBlur.lastValues[key]) : '';
|
||||
if (oldVal === val) return;
|
||||
saveScore(member, n, oldVal, val, teamNumber);
|
||||
// 저장 후 이전 값 갱신
|
||||
if (!onScoreBlur.lastValues) onScoreBlur.lastValues = {};
|
||||
onScoreBlur.lastValues[key] = val;
|
||||
}
|
||||
// onScoreInput에서 입력값을 기록해두고, blur에서 비교
|
||||
function onScoreInput(member, n, teamNumber) {
|
||||
const key = member.participantId + '-' + n;
|
||||
scoreStates[key] = 'idle'; // 입력 시작 시 상태 초기화(회색)
|
||||
const val = String(scoresProxy.value[key] ?? '');
|
||||
// 기존 저장된 값과 다를 때만 저장
|
||||
const oldVal = member.scores?.[n-1] ?? '';
|
||||
if (val.length === 3 && String(oldVal) !== val) {
|
||||
saveScore(member, n, oldVal, val, teamNumber);
|
||||
}
|
||||
}
|
||||
function onScoreEnter(member, n, teamNumber) {
|
||||
const key = member.participantId + '-' + n;
|
||||
const val = String(scoresProxy.value[key] ?? '');
|
||||
saveScore(member, n, member.scores?.[n-1] ?? '', val, teamNumber);
|
||||
}
|
||||
|
||||
// 멤버별 평균 점수 계산 (입력된 점수만 평균, 소수점 1자리)
|
||||
function getMemberAverageScore(member) {
|
||||
if (!member || !event.value.gameCount) return 0;
|
||||
let total = 0, count = 0;
|
||||
for (let i = 1; i <= event.value.gameCount; i++) {
|
||||
const score = Number(scoresProxy.value[member.participantId + '-' + i]);
|
||||
const hc = Number(member.handicap) || 0;
|
||||
if (!isNaN(score) && score > 0) {
|
||||
total += score + hc;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count > 0 ? Math.round((total / count) * 10) / 10 : 0;
|
||||
}
|
||||
|
||||
// 멤버별 핸디캡 포함 총점 계산
|
||||
function getMemberTotalScore(member) {
|
||||
if (!member || !event.value.gameCount) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i <= event.value.gameCount; i++) {
|
||||
const score = Number(scoresProxy.value[member.participantId + '-' + i]) || 0;
|
||||
const hc = Number(member.handicap) || 0;
|
||||
total += score + hc;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// 각 참가자별로 점수 배열(scores) 생성 (점수 미정시 null)
|
||||
const sortedParticipants = computed(() => {
|
||||
return [...participants.value].map(p => {
|
||||
// 기존 score가 단일 값이면 배열로 변환
|
||||
let scores = [];
|
||||
if (Array.isArray(p.scores)) {
|
||||
scores = p.scores;
|
||||
} else if (typeof p.score !== 'undefined') {
|
||||
// 백엔드에서 배열이 안 내려올 경우 단일값을 1게임으로 간주
|
||||
scores = [p.score];
|
||||
} else {
|
||||
// 여러 게임 점수 미존재시 null로 채움
|
||||
scores = Array(event.value.gameCount).fill(null);
|
||||
}
|
||||
// 점수 개수 맞추기
|
||||
if (scores.length < event.value.gameCount) {
|
||||
scores = [...scores, ...Array(event.value.gameCount - scores.length).fill(null)];
|
||||
}
|
||||
return { ...p, scores };
|
||||
}).sort((a, b) => (a.name || '').localeCompare(b.name || '', 'ko'));
|
||||
});
|
||||
|
||||
// 점수 입력용 프록시 (v-model에서 계산식 사용 회피)
|
||||
const scoresProxy = ref({});
|
||||
|
||||
// 팀 미배정 인원 (백엔드 응답을 그대로 사용)
|
||||
const unassignedMembers = ref([]);
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '-';
|
||||
const d = dayjs(date);
|
||||
const ampm = d.format('A') === 'AM' ? '오전' : '오후';
|
||||
return `${d.format('YYYY-MM-DD')} ${ampm} ${d.format('hh:mm')}`;
|
||||
}
|
||||
|
||||
async function fetchEventData(password) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { status, data } = await PublicEventService.fetchEvent(publicHash, password);
|
||||
if (status === 403) {
|
||||
console.log('[fetchEventData] 403', { publicHash, password });
|
||||
error.value = '이벤트에 접근할 수 없습니다.';
|
||||
PublicEventService.removeToken(publicHash);
|
||||
return;
|
||||
}
|
||||
if (status === 401) {
|
||||
console.log('[fetchEventData] 401', { publicHash, password });
|
||||
isAuthenticated.value = false;
|
||||
PublicEventService.removeToken(publicHash);
|
||||
return;
|
||||
}
|
||||
console.log('[fetchEventData] 200', data);
|
||||
clubName.value = data.clubName || '';
|
||||
event.value = data.event;
|
||||
teams.value = data.teams || [];
|
||||
participants.value = data.participants || [];
|
||||
unassignedMembers.value = data.unassignedMembers || [];
|
||||
availableMembers.value = data.availableMembers || [];
|
||||
isAuthenticated.value = true;
|
||||
// [추가] 기존 점수를 scoresProxy에 반영
|
||||
const proxy = {};
|
||||
[...(data.teams || [])].forEach(team => {
|
||||
(team.members || []).forEach(member => {
|
||||
if (Array.isArray(member.scores)) {
|
||||
member.scores.forEach((score, idx) => {
|
||||
const key = member.participantId + '-' + (idx + 1);
|
||||
proxy[key] = score !== null && score !== undefined ? String(score) : '';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
(data.unassignedMembers || []).forEach(member => {
|
||||
if (Array.isArray(member.scores)) {
|
||||
member.scores.forEach((score, idx) => {
|
||||
const key = member.participantId + '-' + (idx + 1);
|
||||
proxy[key] = score !== null && score !== undefined ? String(score) : '';
|
||||
});
|
||||
}
|
||||
});
|
||||
scoresProxy.value = proxy;
|
||||
// [추가] 점수 상태도 초기화 (핸디캡 포함 점수 기준 색상 적용)
|
||||
Object.keys(proxy).forEach(key => {
|
||||
// 점수 상태는 항상 'idle'로 초기화 (저장됨 아이콘이 안나오게)
|
||||
scoreStates[key] = 'idle';
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPassword() {
|
||||
passwordError.value = '';
|
||||
PublicEventService.removeToken(publicHash); // 항상 먼저 토큰 제거
|
||||
fetchEventData(inputPassword.value).then(() => {
|
||||
if (isAuthenticated.value) {
|
||||
inputPassword.value = '';
|
||||
} else {
|
||||
passwordError.value = '비밀번호가 올바르지 않습니다.';
|
||||
PublicEventService.removeToken(publicHash);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateScore(team, member) {
|
||||
if (!canEdit.value) return;
|
||||
if (!isAuthenticated.value) return;
|
||||
try {
|
||||
await PublicEventService.updateScore(publicHash, member.id, member.score);
|
||||
reload();
|
||||
} catch (e) {
|
||||
// 에러 핸들링
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
fetchEventData();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const token = sessionStorage.getItem('event_token_' + publicHash);
|
||||
if (token) {
|
||||
fetchEventData();
|
||||
} else {
|
||||
fetchEventData();
|
||||
}
|
||||
});
|
||||
|
||||
// (핸디캡 제외) 팀별 게임별 실제 점수 합계
|
||||
function getTeamRawScore(team, gameIdx) {
|
||||
let total = 0;
|
||||
if (!team || !team.members) return 0;
|
||||
team.members.forEach(member => {
|
||||
const score = Number(scoresProxy.value[member.participantId + '-' + gameIdx]) || 0;
|
||||
total += score;
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
// 팀별 게임별 합산 점수 계산 (computed)
|
||||
// 팀별 게임별 합산 점수 계산 함수 (단순 값 반환)
|
||||
function getAllTeamsWithUnassigned() {
|
||||
// 멤버 1명 이상인 팀만 등수 산정에 포함
|
||||
const teamList = (teams.value || [])
|
||||
.filter(team => team.members && team.members.length > 0)
|
||||
.map(team => ({
|
||||
...team,
|
||||
id: team.id || team.teamNumber, // id 없으면 teamNumber 사용
|
||||
isUnassigned: false
|
||||
}));
|
||||
if (unassignedMembers.value && unassignedMembers.value.length > 0) {
|
||||
teamList.push({
|
||||
id: 'unassigned',
|
||||
teamNumber: '미배정',
|
||||
members: unassignedMembers.value,
|
||||
handicap: 0,
|
||||
isUnassigned: true
|
||||
});
|
||||
}
|
||||
return teamList;
|
||||
}
|
||||
|
||||
function getTeamTotalScore(team, gameIdx) {
|
||||
let total = 0;
|
||||
if (!team || !team.members) return 0;
|
||||
team.members.forEach(member => {
|
||||
const score = Number(scoresProxy.value[member.participantId + '-' + gameIdx]) || 0;
|
||||
const memberHc = Number(member.handicap) || 0;
|
||||
total += score + memberHc;
|
||||
});
|
||||
total += Number(team.handicap) || 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
// 팀별 핸디캡 합산 계산 함수 (단순 값 반환)
|
||||
function getTeamTotalHandicap(team) {
|
||||
let total = 0;
|
||||
if (!team || !team.members) return 0;
|
||||
team.members.forEach(member => {
|
||||
const memberHc = Number(member.handicap) || 0;
|
||||
total += memberHc;
|
||||
});
|
||||
total += Number(team.handicap) || 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
// 전체 게임 합계 기준 팀별 등수 계산 함수
|
||||
function getTeamOverallRank(team) {
|
||||
if (!team || !team.id) return '';
|
||||
const allTeams = getAllTeamsWithUnassigned();
|
||||
const teamTotals = allTeams.map(t => ({
|
||||
teamId: t.id || t.teamNumber,
|
||||
total: Array.from({ length: event.value.gameCount }, (_, i) => getTeamTotalScore(t, i + 1)).reduce((a, b) => a + b, 0),
|
||||
handicapSum: getTeamTotalHandicap(t),
|
||||
memberCount: (t.members ? t.members.length : 0)
|
||||
}));
|
||||
teamTotals.sort((a, b) => {
|
||||
if (b.total !== a.total) return b.total - a.total;
|
||||
if (a.handicapSum !== b.handicapSum) return a.handicapSum - b.handicapSum;
|
||||
return b.memberCount - a.memberCount;
|
||||
});
|
||||
let rank = 1;
|
||||
for (let i = 0; i < teamTotals.length; i++) {
|
||||
if (
|
||||
i > 0 && (
|
||||
teamTotals[i].total !== teamTotals[i - 1].total ||
|
||||
teamTotals[i].handicapSum !== teamTotals[i - 1].handicapSum ||
|
||||
teamTotals[i].memberCount !== teamTotals[i - 1].memberCount
|
||||
)
|
||||
) {
|
||||
rank = i + 1;
|
||||
}
|
||||
if ((teamTotals[i].teamId + '') === (team.id + '')) {
|
||||
return rank;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 게임별 등수 계산 함수 (computed)
|
||||
// 통합 등수 산정 함수
|
||||
function getRanksByGame(gameIdx) {
|
||||
const allTeams = getAllTeamsWithUnassigned();
|
||||
const scores = allTeams.map(team => ({
|
||||
teamId: team.id,
|
||||
score: getTeamTotalScore(team, gameIdx),
|
||||
handicapSum: getTeamTotalHandicap(team),
|
||||
memberCount: (team.members ? team.members.length : 0)
|
||||
}));
|
||||
scores.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.handicapSum !== b.handicapSum) return a.handicapSum - b.handicapSum;
|
||||
return b.memberCount - a.memberCount;
|
||||
});
|
||||
const ranks = {};
|
||||
let rank = 1;
|
||||
scores.forEach((item, i) => {
|
||||
if (
|
||||
i > 0 && (
|
||||
item.score !== scores[i - 1].score ||
|
||||
item.handicapSum !== scores[i - 1].handicapSum ||
|
||||
item.memberCount !== scores[i - 1].memberCount
|
||||
)
|
||||
) {
|
||||
rank = i + 1;
|
||||
}
|
||||
ranks[item.teamId] = rank;
|
||||
});
|
||||
return ranks;
|
||||
}
|
||||
</script>
|
||||
<style src="./EventPublicPage.css"></style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="val => $emit('update:visible', val)"
|
||||
modal
|
||||
header="참가 신청/수정"
|
||||
:style="{ width: '400px' }"
|
||||
:closable="true"
|
||||
@hide="$emit('close')"
|
||||
>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- 회원 선택 -->
|
||||
<div class="w-full min-w-[200px]">
|
||||
<label>회원</label>
|
||||
<Select
|
||||
v-model="selectedMember"
|
||||
:options="memberOptions"
|
||||
optionLabel="name"
|
||||
placeholder="회원 선택"
|
||||
class="w-full"
|
||||
filter
|
||||
filterPlaceholder="이름으로 검색"
|
||||
>
|
||||
<!-- 선택된 값 표시 -->
|
||||
<template #value="slotProps">
|
||||
<div v-if="slotProps.value && slotProps.value.isNewGuest" class="flex align-items-center text-primary font-bold">
|
||||
<i class="pi pi-user-plus mr-2" /> 신규 게스트 추가
|
||||
</div>
|
||||
<div v-else-if="slotProps.value" class="flex align-items-center">
|
||||
<Tag :value="slotProps.value.group" :severity="slotProps.value.group === '참가자' ? 'success' : 'info'" class="mr-2" />
|
||||
<span style="font-weight:bold;">{{ slotProps.value.name }}</span>
|
||||
<span style="color:#888; font-size:0.93em; margin-left: 0.5em;">
|
||||
{{ slotProps.value.memberType }} / {{ slotProps.value.gender }} / 에버리지: {{ slotProps.value.average ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>
|
||||
{{ slotProps.placeholder }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 옵션 목록 표시 -->
|
||||
<template #option="slotProps">
|
||||
<div v-if="slotProps.option.isNewGuest" class="flex align-items-center text-primary font-bold">
|
||||
<i class="pi pi-user-plus mr-2" /> 신규 게스트 추가
|
||||
</div>
|
||||
<div v-else class="flex align-items-center">
|
||||
<Tag :value="slotProps.option.group" :severity="slotProps.option.group === '참가자' ? 'success' : 'info'" class="mr-2" />
|
||||
<span style="font-weight:bold;">{{ slotProps.option.name }}</span>
|
||||
<span style="color:#888; font-size:0.93em; margin-left: 0.5em;">
|
||||
{{ slotProps.option.memberType }} / {{ slotProps.option.gender }} / 에버리지: {{ slotProps.option.average ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
<small v-if="submitted && !selectedMemberId" class="p-error">회원을 선택해주세요.</small>
|
||||
|
||||
<!-- 신규 게스트 추가 입력폼 -->
|
||||
<div v-if="selectedMember && selectedMember.isNewGuest" class="w-full border p-3 rounded bg-gray-50 mt-2">
|
||||
<div class="mb-2">
|
||||
<label>이름</label>
|
||||
<InputText v-model="newGuest.name" class="w-full" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label>연락처</label>
|
||||
<InputText v-model="newGuest.phone" class="w-full" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label>성별</label>
|
||||
<Select v-model="newGuest.gender" :options="genderOptions" optionLabel="label" optionValue="value" placeholder="성별 선택" class="w-full" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label>에버리지</label>
|
||||
<InputText v-model="newGuest.average" class="w-full" />
|
||||
</div>
|
||||
<Button label="게스트 추가" class="mt-1" @click="addNewGuest" :disabled="!newGuest.name || !newGuest.phone || !newGuest.gender" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 참가 상태 -->
|
||||
<div class="w-full min-w-[200px]">
|
||||
<label>참가 상태</label>
|
||||
<Select
|
||||
v-model="status"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="상태를 선택하세요"
|
||||
checkmark
|
||||
class="w-full"
|
||||
/>
|
||||
<small v-if="submitted && !status" class="p-error">참가 상태를 선택해주세요.</small>
|
||||
</div>
|
||||
<!-- 회비 납부 상태 -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label>회비 납부 상태</label>
|
||||
<Select
|
||||
v-model="paymentStatus"
|
||||
:options="paymentStatusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="납부 상태를 선택하세요"
|
||||
checkmark
|
||||
class="w-full"
|
||||
/>
|
||||
<small v-if="submitted && !paymentStatus" class="p-error">납부 상태를 선택해주세요.</small>
|
||||
</div>
|
||||
<!-- 비고 -->
|
||||
<div class="w-full">
|
||||
<label>비고</label>
|
||||
<InputText v-model="comment" class="w-full" />
|
||||
</div>
|
||||
<div v-if="error" class="w-full p-error p-mb-2">{{ error }}</div>
|
||||
<div class="w-full flex justify-content-end gap-2 mt-3">
|
||||
<Button type="submit" label="확인" @click="submit" />
|
||||
<Button type="button" label="취소" severity="secondary" @click="$emit('close')" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Tag from 'primevue/tag';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Select from 'primevue/select';
|
||||
import Button from 'primevue/button';
|
||||
const props = defineProps({
|
||||
publicHash: String,
|
||||
participants: Array,
|
||||
availableMembers: Array,
|
||||
visible: Boolean, // Dialog의 v-model:visible과 연결
|
||||
eventStatus: String
|
||||
});
|
||||
const emits = defineEmits(['close', 'updated']);
|
||||
|
||||
const selectedMember = ref(null);
|
||||
const memberOptions = computed(() => {
|
||||
const baseOptions = [
|
||||
...(props.participants || []).map(m => ({ ...m, group: '참가자', id: m.participantId, participantId: m.participantId, memberId: m.memberId })),
|
||||
...(props.availableMembers || []).map(m => ({ ...m, group: '미신청', id: m.memberId, participantId: null, memberId: m.memberId }))
|
||||
].sort((a, b) => {
|
||||
const nameA = a.name || '';
|
||||
const nameB = b.name || '';
|
||||
return nameA.localeCompare(nameB, 'ko');
|
||||
});
|
||||
if (props.eventStatus === '준비') {
|
||||
baseOptions.push({ name: '신규 게스트 추가', isNewGuest: true });
|
||||
}
|
||||
return baseOptions;
|
||||
});
|
||||
|
||||
|
||||
const selectedParticipantId = ref('');
|
||||
const selectedMemberId = ref('');
|
||||
watch(selectedMember, (val) => {
|
||||
selectedParticipantId.value = val ? val.participantId : '';
|
||||
selectedMemberId.value = val ? val.memberId : '';
|
||||
|
||||
// 참가자라면 기존 정보 자동 세팅
|
||||
if (val) {
|
||||
const found = (props.participants || []).find(m => m.memberId === val.memberId);
|
||||
status.value = found?.status || '';
|
||||
paymentStatus.value = found?.paymentStatus || '';
|
||||
comment.value = found?.comment || '';
|
||||
}
|
||||
});
|
||||
|
||||
const comment = ref('');
|
||||
const status = ref('');
|
||||
const paymentStatus = ref('');
|
||||
const error = ref('');
|
||||
const submitted = ref(false);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '참가예정', value: '참가예정' },
|
||||
{ label: '참가확정', value: '참가확정' },
|
||||
{ label: '취소', value: '취소' }
|
||||
];
|
||||
const paymentStatusOptions = [
|
||||
{ label: '미납', value: '미납' },
|
||||
{ label: '납부완료', value: '납부완료' }
|
||||
];
|
||||
|
||||
|
||||
import PublicEventService from '@/services/PublicEventService';
|
||||
|
||||
const newGuest = ref({ name: '', phone: '', gender: '' });
|
||||
const genderOptions = [
|
||||
{ label: '남', value: '남성' },
|
||||
{ label: '여', value: '여성' }
|
||||
];
|
||||
|
||||
async function addNewGuest() {
|
||||
error.value = '';
|
||||
if (!newGuest.value.name || !newGuest.value.phone || !newGuest.value.gender) {
|
||||
error.value = '이름, 연락처, 성별을 모두 입력하세요.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 실제 API 경로/파라미터는 상황에 맞게 수정 필요
|
||||
const res = await PublicEventService.createGuest(props.publicHash, {
|
||||
name: newGuest.value.name,
|
||||
phone: newGuest.value.phone,
|
||||
gender: newGuest.value.gender,
|
||||
average: newGuest.value.average
|
||||
});
|
||||
if (!res || !res.data || !res.data.memberId) throw new Error('게스트 등록 실패');
|
||||
// 새 게스트를 멤버 옵션에 추가하고 자동 선택
|
||||
const guest = {
|
||||
...res.data,
|
||||
group: '참가자',
|
||||
id: res.data.participantId,
|
||||
participantId: res.data.participantId,
|
||||
memberId: res.data.memberId
|
||||
};
|
||||
// options에 직접 push (reactivity 보장 위해 availableMembers에도 추가)
|
||||
props.availableMembers.push(guest);
|
||||
// 참가자 목록에도 추가
|
||||
props.participants.push(guest);
|
||||
// 선택값 세팅
|
||||
selectedMember.value = guest;
|
||||
selectedParticipantId.value = guest.participantId;
|
||||
selectedMemberId.value = guest.memberId;
|
||||
// 입력폼 초기화
|
||||
newGuest.value = { name: '', phone: '', gender: '', average: '' };
|
||||
} catch (e) {
|
||||
error.value = e.message || '게스트 등록 중 오류 발생';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function submit() {
|
||||
submitted.value = true;
|
||||
error.value = '';
|
||||
if (!selectedMemberId.value || !status.value || !paymentStatus.value) {
|
||||
error.value = '모든 항목을 입력해주세요.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await PublicEventService.registerParticipant(props.publicHash, {
|
||||
participantId: selectedParticipantId.value,
|
||||
memberId: selectedMemberId.value,
|
||||
comment: comment.value,
|
||||
status: status.value,
|
||||
paymentStatus: paymentStatus.value
|
||||
});
|
||||
if (!res.ok) throw new Error('참가 신청에 실패했습니다.');
|
||||
emits('updated');
|
||||
emits('close');
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user