create.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. <template>
  2. <div class="admin--page-content">
  3. <div class="admin--form">
  4. <form @submit.prevent="handleSubmit">
  5. <!-- ============================
  6. 퀘스트 기본 정보
  7. ============================ -->
  8. <div class="admin--quest--tab--wrap">
  9. <div class="tab--wrap" :class="{ 'is-right': questType === 'challenge' }">
  10. <div class="quest--tab__indicator"></div>
  11. <button
  12. type="button"
  13. class="quest--tab"
  14. :class="{ 'is-active': questType === 'solo' }"
  15. @click="questType = 'solo'"
  16. >단독진행</button>
  17. <button
  18. type="button"
  19. class="quest--tab"
  20. :class="{ 'is-active': questType === 'challenge' }"
  21. @click="questType = 'challenge'"
  22. >챌린지 연동</button>
  23. </div>
  24. <p class="" v-if="questType === 'challenge'">
  25. <span class="color--yellow">🔗</span> 진행중 챌린지의 마지막 단계 진출자만 모아 진행 - 일반 신청자 모집 안 함
  26. </p>
  27. <p v-else>
  28. <span>🎯</span> 퀘스트만 단독 진행 - 모든 사용자 신청ㆍ참여 가능
  29. </p>
  30. </div>
  31. <table class="admin--form--table">
  32. <colgroup>
  33. <col style="width: 140px;">
  34. <col>
  35. </colgroup>
  36. <tbody>
  37. <tr v-if="questType == 'challenge'">
  38. <th>연동 챌린지 <span class="admin--required">*</span></th>
  39. <td>
  40. <div class="input--wrap">
  41. <select class="admin--form-select" required>
  42. <option value="">선택하세요</option>
  43. <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
  44. </select>
  45. </div>
  46. <p class="yellow--desc mt--4">진행중 챌린지만 노출되며, 선택한 챌린지의 마지막 단계(최종 라운드) 진출자 명단이 자동 참여 대상이 됩니다.</p>
  47. </td>
  48. </tr>
  49. <tr v-if="questType == 'challenge'">
  50. <th>참여 대상</th>
  51. <td>
  52. <p class="green--desc">
  53. 마지막 단계 진출자 00명
  54. </p>
  55. </td>
  56. </tr>
  57. <tr>
  58. <th><div>퀘스트명 <span class="admin--required">*</span></div></th>
  59. <td>
  60. <div class="input--wrap">
  61. <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
  62. </div>
  63. </td>
  64. </tr>
  65. <tr>
  66. <th><div>기간 <span class="admin--required">*</span></div></th>
  67. <td>
  68. <div class="input--wrap">
  69. <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
  70. <span class="admin--date-separator">-</span>
  71. <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
  72. </div>
  73. </td>
  74. </tr>
  75. <tr>
  76. <th><div>지역 설정 <span class="admin--required">*</span></div></th>
  77. <td>
  78. <p>낚시분야·지역(장소)·제휴 구분을 행 단위로 지정하고, 각 행의 적용 단계(1~5)와 아이템을 설정합니다. [+추가]로 여러 조합을 등록할 수 있습니다.</p>
  79. <div class="admin--inner--table--wrap">
  80. <table class="admin--quest--table">
  81. <colgroup>
  82. <col>
  83. </colgroup>
  84. <thead>
  85. <tr>
  86. <th>낚시분야</th>
  87. <th>지역(장소)</th>
  88. <th>제휴 구분</th>
  89. <th>장소 구분</th>
  90. <th>선상ㆍ낚시터</th>
  91. <th>적용 단계</th>
  92. <th>아이템</th>
  93. <th>관리</th>
  94. </tr>
  95. </thead>
  96. <tbody>
  97. <tr>
  98. <td>
  99. <div class="input--wrap">
  100. <select name="" id="" class="admin--form-select">
  101. <option value="">전체 분야</option>
  102. </select>
  103. </div>
  104. </td>
  105. <td>
  106. <div class="input--wrap">
  107. <select name="" id="" class="admin--form-select">
  108. <option value="">전체 지역</option>
  109. </select>
  110. </div>
  111. </td>
  112. <td>
  113. <div class="input--wrap">
  114. <select name="" id="" class="admin--form-select">
  115. <option value="">전체</option>
  116. <option value="Y">제휴</option>
  117. <option value="N">비제휴</option>
  118. </select>
  119. </div>
  120. </td>
  121. <td>
  122. <div class="input--wrap">
  123. <select name="" id="" class="admin--form-select">
  124. <option value="">전체 장소</option>
  125. <option value="onboard">선상</option>
  126. <option value="fishing">낚시터</option>
  127. </select>
  128. </div>
  129. </td>
  130. <td>
  131. <div></div>
  132. </td>
  133. </tr>
  134. </tbody>
  135. </table>
  136. </div>
  137. <p>예) 바다낚시터·전체·비제휴 → 1·2단계 아이템 지정 / 낚시어선·제주도·제휴 → 3단계 지정</p>
  138. </td>
  139. </tr>
  140. <tr>
  141. <th><div>참가비 <span class="admin--required">*</span></div></th>
  142. <td>
  143. <div class="input--wrap">
  144. <input
  145. v-model="formData.fee"
  146. type="number"
  147. min="0"
  148. class="admin--form-input w--200"
  149. :placeholder="isFree ? '0 (무료)' : '예: 10000'"
  150. :disabled="isFree"
  151. required
  152. />
  153. <span>원</span>
  154. <label class="admin--checkbox-label">
  155. <input type="checkbox" v-model="isFree" @change="onFreeChange" /> 무료
  156. </label>
  157. </div>
  158. </td>
  159. </tr>
  160. <tr v-if="questType !== 'challenge'">
  161. <th><div>최대 참가자 <span class="admin--required">*</span></div></th>
  162. <td>
  163. <div class="input--wrap">
  164. <input v-model="formData.max_participants" type="number" min="100" max="999999" class="admin--form-input w--120" placeholder="100 ~ 999999" required />
  165. <span>명</span>
  166. </div>
  167. </td>
  168. </tr>
  169. <tr>
  170. <th><div>상세내용</div></th>
  171. <td>
  172. <ClientOnly>
  173. <SunEditor
  174. v-model="formData.description"
  175. height="400px"
  176. placeholder="퀘스트 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)"
  177. />
  178. </ClientOnly>
  179. </td>
  180. </tr>
  181. <tr>
  182. <th><div>타이틀 이미지</div></th>
  183. <td>
  184. <div class="input--wrap">
  185. <input
  186. ref="imageInput"
  187. type="file"
  188. accept="image/*"
  189. class="admin--form-file-hidden"
  190. @change="onImageChange"
  191. />
  192. <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
  193. 이미지 선택
  194. </button>
  195. <span v-if="image" class="ml--16">{{ image.file.name }}</span>
  196. </div>
  197. <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
  198. <div v-if="image" class="onboard--photo-grid mt--10">
  199. <div class="onboard--photo-item">
  200. <img :src="image.preview" alt="미리보기" />
  201. <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
  202. </div>
  203. </div>
  204. </td>
  205. </tr>
  206. <tr>
  207. <th><div>상태 <span class="admin--required">*</span></div></th>
  208. <td>
  209. <div class="input--wrap">
  210. <label class="admin--radio-label">
  211. <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
  212. </label>
  213. <label class="admin--radio-label ml--16">
  214. <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
  215. </label>
  216. </div>
  217. </td>
  218. </tr>
  219. </tbody>
  220. </table>
  221. <!-- 버튼 영역 -->
  222. <div class="admin--form-actions">
  223. <button type="button" class="admin--btn" @click="goToList">
  224. ← 목록으로
  225. </button>
  226. <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
  227. {{ isSaving ? "저장 중..." : "저장" }}
  228. </button>
  229. </div>
  230. <!-- 성공/에러 메시지 -->
  231. <div v-if="successMessage" class="admin--alert admin--alert-success">
  232. {{ successMessage }}
  233. </div>
  234. <div v-if="errorMessage" class="admin--alert admin--alert-error">
  235. {{ errorMessage }}
  236. </div>
  237. </form>
  238. </div>
  239. <!-- ============================
  240. 아이템 선택 모달
  241. ============================ -->
  242. <ClientOnly>
  243. <Teleport to="body">
  244. <div
  245. v-if="itemModal.isOpen"
  246. class="admin--modal-overlay admin--alert-overlay"
  247. @click.self="closeItemModal"
  248. >
  249. <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
  250. <div class="admin--modal-header">
  251. <h4>아이템 선택</h4>
  252. <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
  253. </div>
  254. <div class="admin--modal-body">
  255. <div class="admin--item-modal__search mb--16">
  256. <input
  257. v-model="itemModal.searchKeyword"
  258. type="text"
  259. class="admin--form-input w--full"
  260. placeholder="🔍 아이템명 검색"
  261. />
  262. </div>
  263. <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
  264. <li
  265. v-for="it in filteredItems()"
  266. :key="it.id"
  267. class="admin--item-modal__card"
  268. :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
  269. >
  270. <label>
  271. <input
  272. type="checkbox"
  273. :checked="itemModal.tempSelected.includes(it.id)"
  274. @change="toggleItemInModal(it.id)"
  275. />
  276. <div class="admin--item-modal__thumb">
  277. <img
  278. v-if="it.file_path"
  279. :src="getImageUrl(it.file_path)"
  280. :alt="it.name"
  281. />
  282. <div v-else class="admin--item-modal__no-img">🎁</div>
  283. </div>
  284. <div class="admin--item-modal__name">{{ it.name }}</div>
  285. <div class="admin--item-modal__meta">
  286. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  287. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  288. </div>
  289. </label>
  290. </li>
  291. </ul>
  292. <div v-else class="admin--item-modal__empty">
  293. {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
  294. </div>
  295. </div>
  296. <div class="admin--modal-footer">
  297. <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
  298. <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
  299. <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
  300. </div>
  301. </div>
  302. </div>
  303. </Teleport>
  304. </ClientOnly>
  305. </div>
  306. </template>
  307. <script setup>
  308. import { ref, onMounted, onBeforeUnmount } from "vue";
  309. import { useRouter } from "vue-router";
  310. import DatePicker from "~/components/admin/DatePicker.vue";
  311. import SunEditor from "~/components/admin/SunEditor.vue";
  312. definePageMeta({
  313. layout: "admin",
  314. middleware: ["auth"],
  315. });
  316. const router = useRouter();
  317. const { get, post, upload } = useApi();
  318. const { getImageUrl } = useImage();
  319. const isSaving = ref(false);
  320. const successMessage = ref("");
  321. const errorMessage = ref("");
  322. const questType = ref("solo"); // 'solo' | 'challenge'
  323. // ============================
  324. // 옵션 데이터
  325. // ============================
  326. const fieldOptions = ref([]);
  327. const areaOptions = ref([]);
  328. const placesAll = ref([]); // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
  329. const itemsAll = ref([]); // 아이템 모달용 전체 아이템
  330. // ============================
  331. // 퀘스트 기본 정보
  332. // ============================
  333. const formData = ref({
  334. name: "",
  335. fee: "",
  336. max_participants: "",
  337. status_YN: "Y",
  338. description: "",
  339. });
  340. const startDate = ref("");
  341. const endDate = ref("");
  342. const isFree = ref(false);
  343. // 무료 체크박스 토글
  344. const onFreeChange = () => {
  345. if (isFree.value) {
  346. formData.value.fee = "0";
  347. } else {
  348. formData.value.fee = "";
  349. }
  350. };
  351. // ============================
  352. // 이미지 업로드
  353. // ============================
  354. const imageInput = ref(null);
  355. const image = ref(null);
  356. const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
  357. const triggerImageInput = () => imageInput.value?.click();
  358. const onImageChange = (e) => {
  359. const file = (e.target.files || [])[0];
  360. e.target.value = "";
  361. if (!file) return;
  362. if (!file.type.startsWith("image/")) {
  363. errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
  364. return;
  365. }
  366. if (file.size > MAX_IMAGE_SIZE) {
  367. errorMessage.value = "이미지가 5MB를 초과합니다.";
  368. return;
  369. }
  370. if (image.value) URL.revokeObjectURL(image.value.preview);
  371. image.value = { file, preview: URL.createObjectURL(file) };
  372. };
  373. const removeImage = () => {
  374. if (image.value) {
  375. URL.revokeObjectURL(image.value.preview);
  376. image.value = null;
  377. }
  378. };
  379. // ============================
  380. // 라운드/장소 동적 배열
  381. // ============================
  382. let _keySeq = 0;
  383. const nextKey = () => ++_keySeq;
  384. function createPlace() {
  385. return {
  386. _key: nextKey(),
  387. field_id: "",
  388. area_id: "",
  389. partnership_YN: "",
  390. onboards: [], // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
  391. items: [], // [{ item_id, name, qty }] — Phase 2
  392. // UI 상태
  393. dropdownOpen: false,
  394. searchKeyword: "",
  395. tempSelected: [], // 드롭다운 내 임시 체크 (장소 키 배열)
  396. };
  397. }
  398. function createRound(no) {
  399. return {
  400. _key: nextKey(),
  401. round_no: no,
  402. place_mode: "all",
  403. qualified: "",
  404. items: [], // [{ item_id, name, qty }] — Phase 2
  405. places: [],
  406. };
  407. }
  408. const rounds = ref([createRound(1), createRound(2)]);
  409. function renumberRounds() {
  410. rounds.value.forEach((r, i) => { r.round_no = i + 1; });
  411. }
  412. function addRound() {
  413. if (rounds.value.length >= 5) return;
  414. rounds.value.push(createRound(rounds.value.length + 1));
  415. }
  416. function removeRound(idx) {
  417. if (rounds.value.length <= 2) return;
  418. rounds.value.splice(idx, 1);
  419. renumberRounds();
  420. }
  421. function changePlaceMode(round, mode) {
  422. round.place_mode = mode;
  423. // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
  424. if (mode === "specific" && round.places.length === 0) {
  425. round.places.push(createPlace());
  426. }
  427. }
  428. function addPlace(round) {
  429. round.places.push(createPlace());
  430. }
  431. function removePlace(round, idx) {
  432. round.places.splice(idx, 1);
  433. // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
  434. if (round.places.length === 0) {
  435. round.places.push(createPlace());
  436. }
  437. }
  438. // ============================
  439. // 장소(선상+낚시터) 검색 드롭다운
  440. // ============================
  441. // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
  442. const placeKey = (p) => `${p._placeType}-${p.id}`;
  443. const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
  444. const placeNameByKey = (k) => placeByKey(k)?.name || "?";
  445. const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
  446. function closeAllDropdowns() {
  447. rounds.value.forEach((r) =>
  448. r.places.forEach((p) => { p.dropdownOpen = false; })
  449. );
  450. }
  451. function openDropdown(place) {
  452. closeAllDropdowns();
  453. place.tempSelected = [...place.onboards];
  454. place.dropdownOpen = true;
  455. }
  456. function filteredPlaces(place) {
  457. return placesAll.value.filter((p) => {
  458. if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
  459. if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
  460. if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
  461. if (place.searchKeyword) {
  462. const kw = place.searchKeyword.toLowerCase();
  463. if (!String(p.name || "").toLowerCase().includes(kw)) return false;
  464. }
  465. return true;
  466. });
  467. }
  468. function togglePlaceInTemp(place, key) {
  469. const idx = place.tempSelected.indexOf(key);
  470. if (idx === -1) place.tempSelected.push(key);
  471. else place.tempSelected.splice(idx, 1);
  472. }
  473. function isAllFilteredSelected(place) {
  474. const filtered = filteredPlaces(place);
  475. if (filtered.length === 0) return false;
  476. return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
  477. }
  478. function toggleAll(place) {
  479. const filtered = filteredPlaces(place);
  480. const filteredKeys = filtered.map(placeKey);
  481. if (isAllFilteredSelected(place)) {
  482. const set = new Set(filteredKeys);
  483. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  484. } else {
  485. const merged = new Set([...place.tempSelected, ...filteredKeys]);
  486. place.tempSelected = [...merged];
  487. }
  488. }
  489. // 지역별 그룹화: [{area, items: [...]}, ...]
  490. function groupedFilteredPlaces(place) {
  491. const filtered = filteredPlaces(place);
  492. const map = new Map();
  493. filtered.forEach((p) => {
  494. const area = p.area_name || "미분류";
  495. if (!map.has(area)) map.set(area, []);
  496. map.get(area).push(p);
  497. });
  498. return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
  499. }
  500. function isAllInGroupSelected(place, items) {
  501. if (!items || items.length === 0) return false;
  502. return items.every((p) => place.tempSelected.includes(placeKey(p)));
  503. }
  504. function toggleAllInGroup(place, items) {
  505. const keys = items.map(placeKey);
  506. if (isAllInGroupSelected(place, items)) {
  507. const set = new Set(keys);
  508. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  509. } else {
  510. const merged = new Set([...place.tempSelected, ...keys]);
  511. place.tempSelected = [...merged];
  512. }
  513. }
  514. function applyDropdown(place) {
  515. place.onboards = [...place.tempSelected];
  516. place.dropdownOpen = false;
  517. }
  518. function removePlaceChip(place, key) {
  519. place.onboards = place.onboards.filter((k) => k !== key);
  520. }
  521. // 외부 클릭 시 모든 드롭다운 닫기
  522. function handleDocumentClick() {
  523. closeAllDropdowns();
  524. }
  525. // ============================
  526. // 아이템 선택 모달
  527. // ============================
  528. const itemModal = ref({
  529. isOpen: false,
  530. target: null, // round 또는 place 객체 (둘 다 .items 배열 가짐)
  531. tempSelected: [], // 임시 선택된 item id 배열
  532. searchKeyword: "",
  533. });
  534. function openItemModal(target) {
  535. itemModal.value.target = target;
  536. itemModal.value.tempSelected = target.items.map((i) => i.item_id);
  537. itemModal.value.searchKeyword = "";
  538. itemModal.value.isOpen = true;
  539. }
  540. function closeItemModal() {
  541. itemModal.value.isOpen = false;
  542. itemModal.value.target = null;
  543. itemModal.value.tempSelected = [];
  544. itemModal.value.searchKeyword = "";
  545. }
  546. function toggleItemInModal(itemId) {
  547. const idx = itemModal.value.tempSelected.indexOf(itemId);
  548. if (idx === -1) itemModal.value.tempSelected.push(itemId);
  549. else itemModal.value.tempSelected.splice(idx, 1);
  550. }
  551. function filteredItems() {
  552. if (!itemModal.value.searchKeyword) return itemsAll.value;
  553. const kw = itemModal.value.searchKeyword.toLowerCase();
  554. return itemsAll.value.filter((i) =>
  555. String(i.name || "").toLowerCase().includes(kw)
  556. );
  557. }
  558. function applyItemModal() {
  559. const target = itemModal.value.target;
  560. if (!target) return;
  561. target.items = itemModal.value.tempSelected.map((id) => {
  562. const it = itemsAll.value.find((x) => x.id === id);
  563. return {
  564. item_id: id,
  565. name: it?.name || "?",
  566. type: it?.type || "",
  567. point: it?.point ?? null,
  568. };
  569. });
  570. closeItemModal();
  571. }
  572. // ============================
  573. // 데이터 로드
  574. // ============================
  575. async function loadOptions() {
  576. try {
  577. const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
  578. get("/field/list", { params: { per_page: 1000 } }),
  579. get("/area/list", { params: { per_page: 1000 } }),
  580. get("/onboard/list", { params: { per_page: 1000 } }),
  581. get("/fishing/list", { params: { per_page: 1000 } }),
  582. get("/item/list", { params: { per_page: 1000, status: "Y" } }),
  583. ]);
  584. if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
  585. if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
  586. // 선상 + 낚시터 통합 (_placeType으로 구분)
  587. const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
  588. .map((o) => ({ ...o, _placeType: "onboard" }));
  589. const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
  590. .map((f) => ({ ...f, _placeType: "fishing" }));
  591. placesAll.value = [...onboards, ...fishings];
  592. if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
  593. } catch (e) {
  594. console.error("Load options error:", e);
  595. }
  596. }
  597. // ============================
  598. // 폼 제출
  599. // ============================
  600. async function handleSubmit() {
  601. errorMessage.value = "";
  602. successMessage.value = "";
  603. // 프론트 1차 검증
  604. if (!formData.value.name.trim()) return (errorMessage.value = "퀘스트명을 입력하세요.");
  605. if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
  606. if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
  607. if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
  608. if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
  609. for (let i = 0; i < rounds.value.length; i++) {
  610. const r = rounds.value[i];
  611. if (!r.qualified) {
  612. return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
  613. }
  614. if (r.place_mode === "specific") {
  615. if (r.places.length === 0) {
  616. return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
  617. }
  618. for (let j = 0; j < r.places.length; j++) {
  619. if (r.places[j].onboards.length === 0) {
  620. return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
  621. }
  622. }
  623. }
  624. }
  625. isSaving.value = true;
  626. try {
  627. const payload = {
  628. name: formData.value.name,
  629. fee: formData.value.fee,
  630. start_date: startDate.value,
  631. end_date: endDate.value,
  632. max_participants: Number(formData.value.max_participants),
  633. description: formData.value.description,
  634. status_YN: formData.value.status_YN,
  635. rounds: rounds.value.map((r) => ({
  636. round_no: r.round_no,
  637. place_mode: r.place_mode,
  638. qualified: Number(r.qualified),
  639. items: r.place_mode === "all"
  640. ? r.items.map((it) => ({ item_id: it.item_id }))
  641. : [],
  642. places: r.place_mode === "specific"
  643. ? r.places.map((p) => ({
  644. // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
  645. onboards: p.onboards.map((key) => {
  646. const i = key.indexOf("-");
  647. return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
  648. }),
  649. items: p.items.map((it) => ({ item_id: it.item_id })),
  650. }))
  651. : [],
  652. })),
  653. };
  654. const { data, error } = await post("/challenge", payload);
  655. if (error || !data?.success) {
  656. errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
  657. return;
  658. }
  659. const newId = data.data?.id;
  660. // 이미지가 있으면 업로드 (퀘스트 id 받은 뒤)
  661. if (newId && image.value) {
  662. const fd = new FormData();
  663. fd.append("image", image.value.file);
  664. const { data: imgRes, error: imgErr } = await upload(`/challenge/${newId}/image`, fd);
  665. if (imgErr || !imgRes?.success) {
  666. errorMessage.value = "퀘스트는 등록됐지만 이미지 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
  667. setTimeout(() => router.push("/site-manager/quest/list"), 1500);
  668. return;
  669. }
  670. }
  671. successMessage.value = data.message || "퀘스트가 등록되었습니다.";
  672. setTimeout(() => {
  673. router.push("/site-manager/quest/list");
  674. }, 1000);
  675. } catch (e) {
  676. errorMessage.value = "서버 오류가 발생했습니다.";
  677. console.error("Quest save error:", e);
  678. } finally {
  679. isSaving.value = false;
  680. }
  681. }
  682. const goToList = () => router.push("/site-manager/quest/list");
  683. onMounted(() => {
  684. loadOptions();
  685. document.addEventListener("click", handleDocumentClick);
  686. });
  687. onBeforeUnmount(() => {
  688. document.removeEventListener("click", handleDocumentClick);
  689. });
  690. </script>