|
|
@@ -0,0 +1,746 @@
|
|
|
+<template>
|
|
|
+ <div class="admin--page-content">
|
|
|
+ <div class="admin--form">
|
|
|
+ <form @submit.prevent="handleSubmit">
|
|
|
+
|
|
|
+ <!-- ============================
|
|
|
+ 퀘스트 기본 정보
|
|
|
+ ============================ -->
|
|
|
+ <div class="admin--quest--tab--wrap">
|
|
|
+ <div class="tab--wrap" :class="{ 'is-right': questType === 'challenge' }">
|
|
|
+ <div class="quest--tab__indicator"></div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="quest--tab"
|
|
|
+ :class="{ 'is-active': questType === 'solo' }"
|
|
|
+ @click="questType = 'solo'"
|
|
|
+ >단독진행</button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="quest--tab"
|
|
|
+ :class="{ 'is-active': questType === 'challenge' }"
|
|
|
+ @click="questType = 'challenge'"
|
|
|
+ >챌린지 연동</button>
|
|
|
+ </div>
|
|
|
+ <p class="" v-if="questType === 'challenge'">
|
|
|
+ <span class="color--yellow">🔗</span> 진행중 챌린지의 마지막 단계 진출자만 모아 진행 - 일반 신청자 모집 안 함
|
|
|
+ </p>
|
|
|
+ <p v-else>
|
|
|
+ <span>🎯</span> 퀘스트만 단독 진행 - 모든 사용자 신청ㆍ참여 가능
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <table class="admin--form--table">
|
|
|
+ <colgroup>
|
|
|
+ <col style="width: 140px;">
|
|
|
+ <col>
|
|
|
+ </colgroup>
|
|
|
+ <tbody>
|
|
|
+ <tr v-if="questType == 'challenge'">
|
|
|
+ <th>연동 챌린지 <span class="admin--required">*</span></th>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <select class="admin--form-select" required>
|
|
|
+ <option value="">선택하세요</option>
|
|
|
+ <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <p class="yellow--desc mt--4">진행중 챌린지만 노출되며, 선택한 챌린지의 마지막 단계(최종 라운드) 진출자 명단이 자동 참여 대상이 됩니다.</p>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr v-if="questType == 'challenge'">
|
|
|
+ <th>참여 대상</th>
|
|
|
+ <td>
|
|
|
+ <p class="green--desc">
|
|
|
+ 마지막 단계 진출자 00명
|
|
|
+ </p>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <th><div>퀘스트명 <span class="admin--required">*</span></div></th>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <th><div>기간 <span class="admin--required">*</span></div></th>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
|
|
|
+ <span class="admin--date-separator">-</span>
|
|
|
+ <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <th><div>지역 설정 <span class="admin--required">*</span></div></th>
|
|
|
+ <td>
|
|
|
+ <p>낚시분야·지역(장소)·제휴 구분을 행 단위로 지정하고, 각 행의 적용 단계(1~5)와 아이템을 설정합니다. [+추가]로 여러 조합을 등록할 수 있습니다.</p>
|
|
|
+ <div class="admin--inner--table--wrap">
|
|
|
+ <table class="admin--quest--table">
|
|
|
+ <colgroup>
|
|
|
+ <col>
|
|
|
+ </colgroup>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>낚시분야</th>
|
|
|
+ <th>지역(장소)</th>
|
|
|
+ <th>제휴 구분</th>
|
|
|
+ <th>장소 구분</th>
|
|
|
+ <th>선상ㆍ낚시터</th>
|
|
|
+ <th>적용 단계</th>
|
|
|
+ <th>아이템</th>
|
|
|
+ <th>관리</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <select name="" id="" class="admin--form-select">
|
|
|
+ <option value="">전체 분야</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <select name="" id="" class="admin--form-select">
|
|
|
+ <option value="">전체 지역</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <select name="" id="" class="admin--form-select">
|
|
|
+ <option value="">전체</option>
|
|
|
+ <option value="Y">제휴</option>
|
|
|
+ <option value="N">비제휴</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <select name="" id="" class="admin--form-select">
|
|
|
+ <option value="">전체 장소</option>
|
|
|
+ <option value="onboard">선상</option>
|
|
|
+ <option value="fishing">낚시터</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <div></div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ <p>예) 바다낚시터·전체·비제휴 → 1·2단계 아이템 지정 / 낚시어선·제주도·제휴 → 3단계 지정</p>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <th><div>참가비 <span class="admin--required">*</span></div></th>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <input
|
|
|
+ v-model="formData.fee"
|
|
|
+ type="number"
|
|
|
+ min="0"
|
|
|
+ class="admin--form-input w--200"
|
|
|
+ :placeholder="isFree ? '0 (무료)' : '예: 10000'"
|
|
|
+ :disabled="isFree"
|
|
|
+ required
|
|
|
+ />
|
|
|
+ <span>원</span>
|
|
|
+ <label class="admin--checkbox-label">
|
|
|
+ <input type="checkbox" v-model="isFree" @change="onFreeChange" /> 무료
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr v-if="questType !== 'challenge'">
|
|
|
+ <th><div>최대 참가자 <span class="admin--required">*</span></div></th>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <input v-model="formData.max_participants" type="number" min="100" max="999999" class="admin--form-input w--120" placeholder="100 ~ 999999" required />
|
|
|
+ <span>명</span>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <th><div>상세내용</div></th>
|
|
|
+ <td>
|
|
|
+ <ClientOnly>
|
|
|
+ <SunEditor
|
|
|
+ v-model="formData.description"
|
|
|
+ height="400px"
|
|
|
+ placeholder="퀘스트 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)"
|
|
|
+ />
|
|
|
+ </ClientOnly>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <th><div>타이틀 이미지</div></th>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <input
|
|
|
+ ref="imageInput"
|
|
|
+ type="file"
|
|
|
+ accept="image/*"
|
|
|
+ class="admin--form-file-hidden"
|
|
|
+ @change="onImageChange"
|
|
|
+ />
|
|
|
+ <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
|
|
|
+ 이미지 선택
|
|
|
+ </button>
|
|
|
+ <span v-if="image" class="ml--16">{{ image.file.name }}</span>
|
|
|
+ </div>
|
|
|
+ <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
|
|
|
+ <div v-if="image" class="onboard--photo-grid mt--10">
|
|
|
+ <div class="onboard--photo-item">
|
|
|
+ <img :src="image.preview" alt="미리보기" />
|
|
|
+ <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ <tr>
|
|
|
+ <th><div>상태 <span class="admin--required">*</span></div></th>
|
|
|
+ <td>
|
|
|
+ <div class="input--wrap">
|
|
|
+ <label class="admin--radio-label">
|
|
|
+ <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
|
|
|
+ </label>
|
|
|
+ <label class="admin--radio-label ml--16">
|
|
|
+ <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ <!-- 버튼 영역 -->
|
|
|
+ <div class="admin--form-actions">
|
|
|
+ <button type="button" class="admin--btn" @click="goToList">
|
|
|
+ ← 목록으로
|
|
|
+ </button>
|
|
|
+ <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
|
|
|
+ {{ isSaving ? "저장 중..." : "저장" }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 성공/에러 메시지 -->
|
|
|
+ <div v-if="successMessage" class="admin--alert admin--alert-success">
|
|
|
+ {{ successMessage }}
|
|
|
+ </div>
|
|
|
+ <div v-if="errorMessage" class="admin--alert admin--alert-error">
|
|
|
+ {{ errorMessage }}
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ============================
|
|
|
+ 아이템 선택 모달
|
|
|
+ ============================ -->
|
|
|
+ <ClientOnly>
|
|
|
+ <Teleport to="body">
|
|
|
+ <div
|
|
|
+ v-if="itemModal.isOpen"
|
|
|
+ class="admin--modal-overlay admin--alert-overlay"
|
|
|
+ @click.self="closeItemModal"
|
|
|
+ >
|
|
|
+ <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
|
|
|
+ <div class="admin--modal-header">
|
|
|
+ <h4>아이템 선택</h4>
|
|
|
+ <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
|
|
|
+ </div>
|
|
|
+ <div class="admin--modal-body">
|
|
|
+ <div class="admin--item-modal__search mb--16">
|
|
|
+ <input
|
|
|
+ v-model="itemModal.searchKeyword"
|
|
|
+ type="text"
|
|
|
+ class="admin--form-input w--full"
|
|
|
+ placeholder="🔍 아이템명 검색"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
|
|
|
+ <li
|
|
|
+ v-for="it in filteredItems()"
|
|
|
+ :key="it.id"
|
|
|
+ class="admin--item-modal__card"
|
|
|
+ :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
|
|
|
+ >
|
|
|
+ <label>
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ :checked="itemModal.tempSelected.includes(it.id)"
|
|
|
+ @change="toggleItemInModal(it.id)"
|
|
|
+ />
|
|
|
+ <div class="admin--item-modal__thumb">
|
|
|
+ <img
|
|
|
+ v-if="it.file_path"
|
|
|
+ :src="getImageUrl(it.file_path)"
|
|
|
+ :alt="it.name"
|
|
|
+ />
|
|
|
+ <div v-else class="admin--item-modal__no-img">🎁</div>
|
|
|
+ </div>
|
|
|
+ <div class="admin--item-modal__name">{{ it.name }}</div>
|
|
|
+ <div class="admin--item-modal__meta">
|
|
|
+ <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
|
|
|
+ <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
|
|
|
+ </div>
|
|
|
+ </label>
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+
|
|
|
+ <div v-else class="admin--item-modal__empty">
|
|
|
+ {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="admin--modal-footer">
|
|
|
+ <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
|
|
|
+ <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
|
|
|
+ <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Teleport>
|
|
|
+ </ClientOnly>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+ import { ref, onMounted, onBeforeUnmount } from "vue";
|
|
|
+ import { useRouter } from "vue-router";
|
|
|
+ import DatePicker from "~/components/admin/DatePicker.vue";
|
|
|
+ import SunEditor from "~/components/admin/SunEditor.vue";
|
|
|
+
|
|
|
+ definePageMeta({
|
|
|
+ layout: "admin",
|
|
|
+ middleware: ["auth"],
|
|
|
+ });
|
|
|
+
|
|
|
+ const router = useRouter();
|
|
|
+ const { get, post, upload } = useApi();
|
|
|
+ const { getImageUrl } = useImage();
|
|
|
+
|
|
|
+ const isSaving = ref(false);
|
|
|
+ const successMessage = ref("");
|
|
|
+ const errorMessage = ref("");
|
|
|
+ const questType = ref("solo"); // 'solo' | 'challenge'
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 옵션 데이터
|
|
|
+ // ============================
|
|
|
+ const fieldOptions = ref([]);
|
|
|
+ const areaOptions = ref([]);
|
|
|
+ const placesAll = ref([]); // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
|
|
|
+ const itemsAll = ref([]); // 아이템 모달용 전체 아이템
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 퀘스트 기본 정보
|
|
|
+ // ============================
|
|
|
+ const formData = ref({
|
|
|
+ name: "",
|
|
|
+ fee: "",
|
|
|
+ max_participants: "",
|
|
|
+ status_YN: "Y",
|
|
|
+ description: "",
|
|
|
+ });
|
|
|
+ const startDate = ref("");
|
|
|
+ const endDate = ref("");
|
|
|
+ const isFree = ref(false);
|
|
|
+
|
|
|
+ // 무료 체크박스 토글
|
|
|
+ const onFreeChange = () => {
|
|
|
+ if (isFree.value) {
|
|
|
+ formData.value.fee = "0";
|
|
|
+ } else {
|
|
|
+ formData.value.fee = "";
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 이미지 업로드
|
|
|
+ // ============================
|
|
|
+ const imageInput = ref(null);
|
|
|
+ const image = ref(null);
|
|
|
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
|
|
|
+
|
|
|
+ const triggerImageInput = () => imageInput.value?.click();
|
|
|
+
|
|
|
+ const onImageChange = (e) => {
|
|
|
+ const file = (e.target.files || [])[0];
|
|
|
+ e.target.value = "";
|
|
|
+ if (!file) return;
|
|
|
+ if (!file.type.startsWith("image/")) {
|
|
|
+ errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (file.size > MAX_IMAGE_SIZE) {
|
|
|
+ errorMessage.value = "이미지가 5MB를 초과합니다.";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (image.value) URL.revokeObjectURL(image.value.preview);
|
|
|
+ image.value = { file, preview: URL.createObjectURL(file) };
|
|
|
+ };
|
|
|
+
|
|
|
+ const removeImage = () => {
|
|
|
+ if (image.value) {
|
|
|
+ URL.revokeObjectURL(image.value.preview);
|
|
|
+ image.value = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 라운드/장소 동적 배열
|
|
|
+ // ============================
|
|
|
+ let _keySeq = 0;
|
|
|
+ const nextKey = () => ++_keySeq;
|
|
|
+
|
|
|
+ function createPlace() {
|
|
|
+ return {
|
|
|
+ _key: nextKey(),
|
|
|
+ field_id: "",
|
|
|
+ area_id: "",
|
|
|
+ partnership_YN: "",
|
|
|
+ onboards: [], // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
|
|
|
+ items: [], // [{ item_id, name, qty }] — Phase 2
|
|
|
+ // UI 상태
|
|
|
+ dropdownOpen: false,
|
|
|
+ searchKeyword: "",
|
|
|
+ tempSelected: [], // 드롭다운 내 임시 체크 (장소 키 배열)
|
|
|
+ };
|
|
|
+ }
|
|
|
+ function createRound(no) {
|
|
|
+ return {
|
|
|
+ _key: nextKey(),
|
|
|
+ round_no: no,
|
|
|
+ place_mode: "all",
|
|
|
+ qualified: "",
|
|
|
+ items: [], // [{ item_id, name, qty }] — Phase 2
|
|
|
+ places: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const rounds = ref([createRound(1), createRound(2)]);
|
|
|
+
|
|
|
+ function renumberRounds() {
|
|
|
+ rounds.value.forEach((r, i) => { r.round_no = i + 1; });
|
|
|
+ }
|
|
|
+
|
|
|
+ function addRound() {
|
|
|
+ if (rounds.value.length >= 5) return;
|
|
|
+ rounds.value.push(createRound(rounds.value.length + 1));
|
|
|
+ }
|
|
|
+ function removeRound(idx) {
|
|
|
+ if (rounds.value.length <= 2) return;
|
|
|
+ rounds.value.splice(idx, 1);
|
|
|
+ renumberRounds();
|
|
|
+ }
|
|
|
+ function changePlaceMode(round, mode) {
|
|
|
+ round.place_mode = mode;
|
|
|
+ // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
|
|
|
+ if (mode === "specific" && round.places.length === 0) {
|
|
|
+ round.places.push(createPlace());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ function addPlace(round) {
|
|
|
+ round.places.push(createPlace());
|
|
|
+ }
|
|
|
+ function removePlace(round, idx) {
|
|
|
+ round.places.splice(idx, 1);
|
|
|
+ // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
|
|
|
+ if (round.places.length === 0) {
|
|
|
+ round.places.push(createPlace());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 장소(선상+낚시터) 검색 드롭다운
|
|
|
+ // ============================
|
|
|
+ // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
|
|
|
+ const placeKey = (p) => `${p._placeType}-${p.id}`;
|
|
|
+ const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
|
|
|
+ const placeNameByKey = (k) => placeByKey(k)?.name || "?";
|
|
|
+ const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
|
|
|
+
|
|
|
+ function closeAllDropdowns() {
|
|
|
+ rounds.value.forEach((r) =>
|
|
|
+ r.places.forEach((p) => { p.dropdownOpen = false; })
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ function openDropdown(place) {
|
|
|
+ closeAllDropdowns();
|
|
|
+ place.tempSelected = [...place.onboards];
|
|
|
+ place.dropdownOpen = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function filteredPlaces(place) {
|
|
|
+ return placesAll.value.filter((p) => {
|
|
|
+ if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
|
|
|
+ if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
|
|
|
+ if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
|
|
|
+ if (place.searchKeyword) {
|
|
|
+ const kw = place.searchKeyword.toLowerCase();
|
|
|
+ if (!String(p.name || "").toLowerCase().includes(kw)) return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function togglePlaceInTemp(place, key) {
|
|
|
+ const idx = place.tempSelected.indexOf(key);
|
|
|
+ if (idx === -1) place.tempSelected.push(key);
|
|
|
+ else place.tempSelected.splice(idx, 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ function isAllFilteredSelected(place) {
|
|
|
+ const filtered = filteredPlaces(place);
|
|
|
+ if (filtered.length === 0) return false;
|
|
|
+ return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleAll(place) {
|
|
|
+ const filtered = filteredPlaces(place);
|
|
|
+ const filteredKeys = filtered.map(placeKey);
|
|
|
+ if (isAllFilteredSelected(place)) {
|
|
|
+ const set = new Set(filteredKeys);
|
|
|
+ place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
|
|
|
+ } else {
|
|
|
+ const merged = new Set([...place.tempSelected, ...filteredKeys]);
|
|
|
+ place.tempSelected = [...merged];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 지역별 그룹화: [{area, items: [...]}, ...]
|
|
|
+ function groupedFilteredPlaces(place) {
|
|
|
+ const filtered = filteredPlaces(place);
|
|
|
+ const map = new Map();
|
|
|
+ filtered.forEach((p) => {
|
|
|
+ const area = p.area_name || "미분류";
|
|
|
+ if (!map.has(area)) map.set(area, []);
|
|
|
+ map.get(area).push(p);
|
|
|
+ });
|
|
|
+ return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
|
|
|
+ }
|
|
|
+
|
|
|
+ function isAllInGroupSelected(place, items) {
|
|
|
+ if (!items || items.length === 0) return false;
|
|
|
+ return items.every((p) => place.tempSelected.includes(placeKey(p)));
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleAllInGroup(place, items) {
|
|
|
+ const keys = items.map(placeKey);
|
|
|
+ if (isAllInGroupSelected(place, items)) {
|
|
|
+ const set = new Set(keys);
|
|
|
+ place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
|
|
|
+ } else {
|
|
|
+ const merged = new Set([...place.tempSelected, ...keys]);
|
|
|
+ place.tempSelected = [...merged];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyDropdown(place) {
|
|
|
+ place.onboards = [...place.tempSelected];
|
|
|
+ place.dropdownOpen = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ function removePlaceChip(place, key) {
|
|
|
+ place.onboards = place.onboards.filter((k) => k !== key);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 외부 클릭 시 모든 드롭다운 닫기
|
|
|
+ function handleDocumentClick() {
|
|
|
+ closeAllDropdowns();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 아이템 선택 모달
|
|
|
+ // ============================
|
|
|
+ const itemModal = ref({
|
|
|
+ isOpen: false,
|
|
|
+ target: null, // round 또는 place 객체 (둘 다 .items 배열 가짐)
|
|
|
+ tempSelected: [], // 임시 선택된 item id 배열
|
|
|
+ searchKeyword: "",
|
|
|
+ });
|
|
|
+
|
|
|
+ function openItemModal(target) {
|
|
|
+ itemModal.value.target = target;
|
|
|
+ itemModal.value.tempSelected = target.items.map((i) => i.item_id);
|
|
|
+ itemModal.value.searchKeyword = "";
|
|
|
+ itemModal.value.isOpen = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeItemModal() {
|
|
|
+ itemModal.value.isOpen = false;
|
|
|
+ itemModal.value.target = null;
|
|
|
+ itemModal.value.tempSelected = [];
|
|
|
+ itemModal.value.searchKeyword = "";
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleItemInModal(itemId) {
|
|
|
+ const idx = itemModal.value.tempSelected.indexOf(itemId);
|
|
|
+ if (idx === -1) itemModal.value.tempSelected.push(itemId);
|
|
|
+ else itemModal.value.tempSelected.splice(idx, 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ function filteredItems() {
|
|
|
+ if (!itemModal.value.searchKeyword) return itemsAll.value;
|
|
|
+ const kw = itemModal.value.searchKeyword.toLowerCase();
|
|
|
+ return itemsAll.value.filter((i) =>
|
|
|
+ String(i.name || "").toLowerCase().includes(kw)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyItemModal() {
|
|
|
+ const target = itemModal.value.target;
|
|
|
+ if (!target) return;
|
|
|
+ target.items = itemModal.value.tempSelected.map((id) => {
|
|
|
+ const it = itemsAll.value.find((x) => x.id === id);
|
|
|
+ return {
|
|
|
+ item_id: id,
|
|
|
+ name: it?.name || "?",
|
|
|
+ type: it?.type || "",
|
|
|
+ point: it?.point ?? null,
|
|
|
+ };
|
|
|
+ });
|
|
|
+ closeItemModal();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 데이터 로드
|
|
|
+ // ============================
|
|
|
+ async function loadOptions() {
|
|
|
+ try {
|
|
|
+ const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
|
|
|
+ get("/field/list", { params: { per_page: 1000 } }),
|
|
|
+ get("/area/list", { params: { per_page: 1000 } }),
|
|
|
+ get("/onboard/list", { params: { per_page: 1000 } }),
|
|
|
+ get("/fishing/list", { params: { per_page: 1000 } }),
|
|
|
+ get("/item/list", { params: { per_page: 1000, status: "Y" } }),
|
|
|
+ ]);
|
|
|
+ if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
|
|
|
+ if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
|
|
|
+
|
|
|
+ // 선상 + 낚시터 통합 (_placeType으로 구분)
|
|
|
+ const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
|
|
|
+ .map((o) => ({ ...o, _placeType: "onboard" }));
|
|
|
+ const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
|
|
|
+ .map((f) => ({ ...f, _placeType: "fishing" }));
|
|
|
+ placesAll.value = [...onboards, ...fishings];
|
|
|
+
|
|
|
+ if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
|
|
|
+ } catch (e) {
|
|
|
+ console.error("Load options error:", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================
|
|
|
+ // 폼 제출
|
|
|
+ // ============================
|
|
|
+ async function handleSubmit() {
|
|
|
+ errorMessage.value = "";
|
|
|
+ successMessage.value = "";
|
|
|
+
|
|
|
+ // 프론트 1차 검증
|
|
|
+ if (!formData.value.name.trim()) return (errorMessage.value = "퀘스트명을 입력하세요.");
|
|
|
+ if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
|
|
|
+ if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
|
|
|
+ if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
|
|
|
+ if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
|
|
|
+
|
|
|
+ for (let i = 0; i < rounds.value.length; i++) {
|
|
|
+ const r = rounds.value[i];
|
|
|
+ if (!r.qualified) {
|
|
|
+ return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
|
|
|
+ }
|
|
|
+ if (r.place_mode === "specific") {
|
|
|
+ if (r.places.length === 0) {
|
|
|
+ return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
|
|
|
+ }
|
|
|
+ for (let j = 0; j < r.places.length; j++) {
|
|
|
+ if (r.places[j].onboards.length === 0) {
|
|
|
+ return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ isSaving.value = true;
|
|
|
+ try {
|
|
|
+ const payload = {
|
|
|
+ name: formData.value.name,
|
|
|
+ fee: formData.value.fee,
|
|
|
+ start_date: startDate.value,
|
|
|
+ end_date: endDate.value,
|
|
|
+ max_participants: Number(formData.value.max_participants),
|
|
|
+ description: formData.value.description,
|
|
|
+ status_YN: formData.value.status_YN,
|
|
|
+ rounds: rounds.value.map((r) => ({
|
|
|
+ round_no: r.round_no,
|
|
|
+ place_mode: r.place_mode,
|
|
|
+ qualified: Number(r.qualified),
|
|
|
+ items: r.place_mode === "all"
|
|
|
+ ? r.items.map((it) => ({ item_id: it.item_id }))
|
|
|
+ : [],
|
|
|
+ places: r.place_mode === "specific"
|
|
|
+ ? r.places.map((p) => ({
|
|
|
+ // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
|
|
|
+ onboards: p.onboards.map((key) => {
|
|
|
+ const i = key.indexOf("-");
|
|
|
+ return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
|
|
|
+ }),
|
|
|
+ items: p.items.map((it) => ({ item_id: it.item_id })),
|
|
|
+ }))
|
|
|
+ : [],
|
|
|
+ })),
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data, error } = await post("/challenge", payload);
|
|
|
+
|
|
|
+ if (error || !data?.success) {
|
|
|
+ errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const newId = data.data?.id;
|
|
|
+
|
|
|
+ // 이미지가 있으면 업로드 (퀘스트 id 받은 뒤)
|
|
|
+ if (newId && image.value) {
|
|
|
+ const fd = new FormData();
|
|
|
+ fd.append("image", image.value.file);
|
|
|
+ const { data: imgRes, error: imgErr } = await upload(`/challenge/${newId}/image`, fd);
|
|
|
+
|
|
|
+ if (imgErr || !imgRes?.success) {
|
|
|
+ errorMessage.value = "퀘스트는 등록됐지만 이미지 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
|
|
|
+ setTimeout(() => router.push("/site-manager/quest/list"), 1500);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ successMessage.value = data.message || "퀘스트가 등록되었습니다.";
|
|
|
+ setTimeout(() => {
|
|
|
+ router.push("/site-manager/quest/list");
|
|
|
+ }, 1000);
|
|
|
+ } catch (e) {
|
|
|
+ errorMessage.value = "서버 오류가 발생했습니다.";
|
|
|
+ console.error("Quest save error:", e);
|
|
|
+ } finally {
|
|
|
+ isSaving.value = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const goToList = () => router.push("/site-manager/quest/list");
|
|
|
+
|
|
|
+ onMounted(() => {
|
|
|
+ loadOptions();
|
|
|
+ document.addEventListener("click", handleDocumentClick);
|
|
|
+ });
|
|
|
+
|
|
|
+ onBeforeUnmount(() => {
|
|
|
+ document.removeEventListener("click", handleDocumentClick);
|
|
|
+ });
|
|
|
+</script>
|