public 기능 완료

This commit is contained in:
2025-05-07 21:02:50 +09:00
parent 283c8a7f4a
commit 1684fbf5d1
28 changed files with 2522 additions and 194 deletions
@@ -24,6 +24,14 @@
<p><strong>시작일:</strong> {{ formattedStartDate }}</p>
<p><strong>종료일:</strong> {{ formattedEndDate }}</p>
<p><strong>장소:</strong> {{ eventModel.location }}</p>
<p v-if="eventModel.publicHash">
<strong>공개 URL:</strong>
<InputText :value="publicEventUrl" readonly style="width:70%" class="mr-2" />
<Button icon="pi pi-copy" @click="copyPublicUrl" class="p-button-sm" v-tooltip.bottom="'복사'" />
</p>
<p v-if="eventModel.accessPassword">
<strong>공개 비밀번호:</strong> {{ eventModel.accessPassword }}
</p>
</div>
<div class="col-12 md:col-6">
<p><strong>최대 참가자 :</strong> {{ eventModel.maxParticipants || '제한 없음' }}</p>
@@ -81,7 +89,6 @@ import TabPanel from 'primevue/tabpanel';
import Badge from 'primevue/badge';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import ParticipantManagement from './ParticipantManagement.vue';
import ScoreManagement from './ScoreManagement.vue';
@@ -94,6 +101,11 @@ const props = defineProps({
getStatusName: { type: Function, required: true },
getStatusSeverity: { type: Function, required: true },
formatDate: { type: Function, required: true },
// (권장) 상태명이 매칭 안 될 경우 실제 상태 문자열 반환
// getStatusName 함수 예시:
// (status) => STATUS_LABELS[status] || status
availableMembers: { type: Array, default: () => [] }
});
@@ -103,6 +115,20 @@ const eventModel = computed(() => props.event || {});
const formattedStartDate = computed(() => props.formatDate(eventModel.value.startDate));
const formattedEndDate = computed(() => props.formatDate(eventModel.value.endDate));
// 공개 URL 전체 경로
const publicEventUrl = computed(() => {
if (!eventModel.value.publicHash) return '';
// 실제 서비스 도메인에 맞게 수정 가능
return `${window.location.origin}/public/${eventModel.value.publicHash}`;
});
function copyPublicUrl() {
if (!publicEventUrl.value) return;
navigator.clipboard.writeText(publicEventUrl.value).then(() => {
toast.add({ severity: 'info', summary: 'URL 복사', detail: '공개 URL이 복사되었습니다.', life: 1500 });
});
}
const hideDialog = () => {
emit('hide');
};
+78 -47
View File
@@ -9,84 +9,82 @@
>
<div class="grid">
<div class="col-12">
<div class="field">
<label for="title">제목 *</label>
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" required autofocus />
<label for="title">이벤트 제목 *</label>
<InputText id="title" v-model="eventModel.title" :class="{'p-invalid': submitted && !eventModel.title}" variant="filled" required autofocus />
<small class="p-error" v-if="submitted && !eventModel.title">제목은 필수입니다.</small>
</div>
</div>
<div class="col-12">
<div class="field">
<label for="description">설명</label>
<Textarea id="description" v-model="eventModel.description" rows="3" autoResize />
</div>
<label for="description">이벤트 설명</label>
<Textarea id="description" v-model="eventModel.description" variant="filled" rows="3" autoResize class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="eventType">이벤트 유형 *</label>
<Dropdown id="eventType" v-model="eventModel.eventType" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType}" />
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</div>
<label for="eventType">이벤트 유형 *</label>
<Select id="eventType" v-model="eventModel.eventType" variant="filled" :options="eventTypeOptions" optionLabel="name" optionValue="value" placeholder="유형 선택" :class="{'p-invalid': submitted && !eventModel.eventType, 'w-full': true}"/>
<small class="p-error" v-if="submitted && !eventModel.eventType">이벤트 유형은 필수입니다.</small>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="status">상태 *</label>
<Dropdown id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status}" />
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</div>
<label for="status">상태 *</label>
<Select id="status" v-model="eventModel.status" :options="statusOptions" optionLabel="name" optionValue="value" placeholder="상태 선택" :class="{'p-invalid': submitted && !eventModel.status, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.status">상태는 필수입니다.</small>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="startDate">시작일 *</label>
<Calendar id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.startDate}" />
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</div>
<label for="startDate">시작일 *</label>
<Datepicker id="startDate" v-model="eventModel.startDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.startDate, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.startDate">시작일은 필수입니다.</small>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="endDate">종료일</label>
<Calendar id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :class="{'p-invalid': submitted && !eventModel.endDate}" />
</div>
<label for="endDate">종료일</label>
<Datepicker id="endDate" v-model="eventModel.endDate" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.endDate, 'w-full': true}" />
<small class="p-error" v-if="submitted && !eventModel.endDate">종료일은 필수입니다.</small>
</div>
<div class="col-12">
<div class="field">
<label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location}" />
</div>
<label for="location">장소</label>
<InputText id="location" v-model="eventModel.location" :class="{'p-invalid': submitted && !eventModel.location, 'w-full': true}" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" showButtons />
</div>
<label for="maxParticipants">최대 참가자 </label>
<InputNumber id="maxParticipants" v-model="eventModel.maxParticipants" :min="0" class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" showButtons />
</div>
<label for="gameCount">게임 </label>
<InputNumber id="gameCount" v-model="eventModel.gameCount" :min="1" class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.entryFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" />
</div>
<label for="participantFee">참가비</label>
<InputNumber id="participantFee" v-model="eventModel.participantFee" :min="0" mode="currency" currency="KRW" locale="ko-KR" class="w-full" />
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="registrationDeadline">등록 마감일</label>
<Calendar id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" />
</div>
<label for="registrationDeadline">신청마감</label>
<Datepicker id="registrationDeadline" v-model="eventModel.registrationDeadline" :showTime="true" dateFormat="yy-mm-dd" timeFormat="24" hourFormat="24" :showIcon="true" :stepMinute="5" :class="{'p-invalid': submitted && !eventModel.registrationDeadline, 'w-full': true}" />
</div>
<div class="col-12 md:col-6">
<label for="publicHash">공개 URL</label>
<InputGroup>
<InputText id="publicHash" v-model="eventModel.publicHash" readonly placeholder="생성 후 표시" />
<Button
type="button"
icon="pi pi-refresh"
@click="regeneratePublicHash"
:disabled="isNew || loadingHash"
class="p-button-sm"
:aria-label="eventModel.publicHash ? '공개 URL 재생성' : '공개 URL 생성'"
/>
</InputGroup>
</div>
<div class="col-12 md:col-6">
<label for="accessPassword">공개 비밀번호(선택)</label>
<InputText id="accessPassword" v-model="eventModel.accessPassword" type="text" maxlength="32" placeholder="공개 페이지 접근용 비밀번호(선택)" style="width:100%;" />
</div>
</div>
@@ -99,6 +97,7 @@
<script setup>
import { ref, watch, toRefs, computed } from 'vue';
import InputGroup from 'primevue/inputgroup';
import dayjs from 'dayjs';
const statusOptions = [
@@ -142,6 +141,10 @@ const eventTypeOptions = [
// 이벤트 모델
const eventModel = ref({ ...props.event });
import { useToast } from 'primevue/usetoast';
const toast = useToast();
const loadingHash = ref(false);
// 날짜 형식 변환
const formatDate = (date) => {
@@ -160,6 +163,26 @@ watch(() => props.event, (newValue) => {
eventModel.value = formattedEvent;
}, { deep: true });
// 공개 URL(공개 해시) 재생성
const regeneratePublicHash = async () => {
if (props.isNew) return;
loadingHash.value = true;
try {
const res = await fetch(`/api/club/events/${eventModel.value.id}/publicHash`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('공개 URL 생성/재생성에 실패했습니다.');
const data = await res.json();
eventModel.value.publicHash = data.publicHash;
toast.add({ severity: 'info', summary: '공개 URL 재생성', detail: '저장 버튼을 눌러야 최종 반영됩니다.', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: '오류', detail: e.message });
} finally {
loadingHash.value = false;
}
};
// 이벤트 저장
const saveEvent = () => {
const eventData = { ...eventModel.value };
@@ -172,6 +195,7 @@ const saveEvent = () => {
if (eventData.registrationDeadline) {
eventData.registrationDeadline = dayjs(eventData.registrationDeadline).toDate();
}
emit('save', eventData);
};
@@ -223,4 +247,11 @@ const hideDialog = () => {
:deep(.p-inputtext) {
width: 100%;
}
.public-hash-btn.p-button-icon-only {
min-width:28px;
height:28px;
padding:0;
font-size:1.1em;
}
</style>
@@ -72,6 +72,7 @@
:max="300"
:placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'"
@update:modelValue="() => onScoreChange(game)"
class="w-full"
/>
</div>
</div>
@@ -85,6 +86,7 @@
:max="300"
placeholder="핸디캡 입력"
@update:modelValue="() => onScoreChange(game)"
class="w-full"
/>
</div>
</div>
@@ -44,7 +44,7 @@
<span class="badge bg-primary text-white mr-1">{{ mIdx + 1 }}</span>
<span>{{ member.name }}</span>
<span v-if="getMemberAverage(member) !== null" class="text-xs text-gray-700 ml-1">({{ getMemberAverage(member) }})</span>
<Button icon="pi pi-times" class="p-button-text p-button-danger p-0" @click="removeMemberFromTeam(idx, mIdx)" v-if="team.members.length > 1" />
<Button icon="pi pi-times" class="p-button-text p-button-danger p-0" @click="removeMemberFromTeam(idx, mIdx)" v-if="team.members.length > 0" />
</li>
</template>
</draggable>