create.vue 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097
  1. <template>
  2. <div class="admin--page-content">
  3. <div class="admin--form">
  4. <form @submit.prevent="handleSubmit">
  5. <!-- ============================
  6. 챌린지 기본 정보
  7. ============================ -->
  8. <table class="admin--form--table">
  9. <colgroup>
  10. <col style="width: 140px;">
  11. <col>
  12. </colgroup>
  13. <tbody>
  14. <tr>
  15. <th><div>챌린지명 <span class="admin--required">*</span></div></th>
  16. <td>
  17. <div class="input--wrap">
  18. <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
  19. </div>
  20. </td>
  21. </tr>
  22. <tr>
  23. <th><div>참가비 <span class="admin--required">*</span></div></th>
  24. <td>
  25. <div class="input--wrap">
  26. <input
  27. v-model="formData.fee"
  28. type="number"
  29. min="0"
  30. class="admin--form-input w--200"
  31. :placeholder="isFree ? '0 (무료)' : '예: 10000'"
  32. :disabled="isFree"
  33. required
  34. />
  35. <span>원</span>
  36. <label class="admin--checkbox-label">
  37. <input type="checkbox" v-model="isFree" @change="onFreeChange" /> 무료
  38. </label>
  39. </div>
  40. </td>
  41. </tr>
  42. <tr>
  43. <th><div>기간 <span class="admin--required">*</span></div></th>
  44. <td>
  45. <div class="input--wrap">
  46. <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
  47. <span class="admin--date-separator">-</span>
  48. <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
  49. </div>
  50. </td>
  51. </tr>
  52. <tr>
  53. <th><div>최대 참가자 <span class="admin--required">*</span></div></th>
  54. <td>
  55. <div class="input--wrap">
  56. <input v-model="formData.max_participants" type="number" min="100" max="999999" class="admin--form-input w--120" placeholder="100 ~ 999999" required />
  57. <span>명</span>
  58. </div>
  59. </td>
  60. </tr>
  61. <tr>
  62. <th><div>타이틀 이미지</div></th>
  63. <td>
  64. <div class="input--wrap">
  65. <input
  66. ref="imageInput"
  67. type="file"
  68. accept="image/*"
  69. class="admin--form-file-hidden"
  70. @change="onImageChange"
  71. />
  72. <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
  73. 이미지 선택
  74. </button>
  75. <span v-if="image" class="ml--16">{{ image.file.name }}</span>
  76. </div>
  77. <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
  78. <div v-if="image" class="onboard--photo-grid mt--10">
  79. <div class="onboard--photo-item">
  80. <img :src="image.preview" alt="미리보기" />
  81. <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
  82. </div>
  83. </div>
  84. </td>
  85. </tr>
  86. <tr>
  87. <th><div>상태 <span class="admin--required">*</span></div></th>
  88. <td>
  89. <div class="input--wrap">
  90. <label class="admin--radio-label">
  91. <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
  92. </label>
  93. <label class="admin--radio-label ml--16">
  94. <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
  95. </label>
  96. </div>
  97. </td>
  98. </tr>
  99. <tr>
  100. <th><div>상세내용</div></th>
  101. <td>
  102. <ClientOnly>
  103. <SunEditor
  104. v-model="formData.description"
  105. height="400px"
  106. placeholder="챌린지 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)"
  107. />
  108. </ClientOnly>
  109. </td>
  110. </tr>
  111. </tbody>
  112. </table>
  113. <!-- ============================
  114. 라운드 설정
  115. ============================ -->
  116. <h3 class="admin--table--middle--title">라운드 설정</h3>
  117. <p class="admin--table--middle--desc">
  118. 라운드별로 장소 범위(전체/개별)를 선택하고, 장소마다 아이템을 배정합니다. 최대 5라운드까지 등록할 수 있습니다.
  119. </p>
  120. <div
  121. v-for="(round, rIdx) in rounds"
  122. :key="round._key"
  123. class="admin--round--box--wrap"
  124. :class="{ 'mt--16': rIdx > 0 }"
  125. >
  126. <div class="admin--round--title">
  127. 라운드 {{ round.round_no }}
  128. <span v-if="round.place_mode === 'all'">전체 장소ㆍ아이템 {{ round.items.length }}</span>
  129. <span v-else>개별 장소 {{ round.places.length }}</span>
  130. <!-- 최소 2라운드 이상. 3라운드부터 라운드 삭제 버튼 노출 -->
  131. <button
  132. v-if="rounds.length > 2"
  133. type="button"
  134. class="round--remove--btn"
  135. @click="removeRound(rIdx)"
  136. >
  137. 라운드 삭제
  138. </button>
  139. </div>
  140. <div class="admin--round--box">
  141. <div class="input--wrap mt--16">
  142. <label class="admin--round--radio">
  143. <input
  144. type="radio"
  145. :name="'place_mode_' + round._key"
  146. value="all"
  147. :checked="round.place_mode === 'all'"
  148. @change="changePlaceMode(round, 'all')"
  149. />
  150. 전체 장소에 동일 적용
  151. </label>
  152. <label class="admin--round--radio">
  153. <input
  154. type="radio"
  155. :name="'place_mode_' + round._key"
  156. value="specific"
  157. :checked="round.place_mode === 'specific'"
  158. @change="changePlaceMode(round, 'specific')"
  159. />
  160. 장소별 개별 설정
  161. </label>
  162. </div>
  163. <div class="qual--wrap">
  164. <p class="mt--16 mb--4">진출자 {{ rIdx === 0 ? '인원' : '확률' }}</p>
  165. <div class="input--wrap">
  166. <input
  167. v-model="round.qualified"
  168. type="number"
  169. min="1"
  170. class="admin--form-input w--120"
  171. :placeholder="rIdx === 0 ? '예: 30' : '예: 50'"
  172. required
  173. />
  174. <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
  175. <span>{{ rIdx === 0 ? '명' : '%' }}</span>
  176. </div>
  177. </div>
  178. <!-- 전체 적용 모드 — 라운드 단위 아이템 -->
  179. <div v-if="round.place_mode === 'all'" class="item--select--wrap">
  180. <div class="item--select--btn--wrap mt--16 mb--4">
  181. <p>배정 아이템ㆍ수량 {{ round.items.length }}</p>
  182. <button type="button" @click="openItemModal(round)">+ 아이템 선택</button>
  183. </div>
  184. <div class="item--selected--wrap">
  185. <div v-for="(it, iIdx) in round.items" :key="it.item_id" class="item--selected">
  186. {{ it.name }}<button type="button" @click="round.items.splice(iIdx, 1)">✕</button>
  187. </div>
  188. </div>
  189. </div>
  190. <!-- 장소별 개별 설정 모드 -->
  191. <template v-else>
  192. <div
  193. v-for="(place, pIdx) in round.places"
  194. :key="place._key"
  195. class="round--place--wrap"
  196. >
  197. <div class="admin--round--title">
  198. 장소 {{ pIdx + 1 }}
  199. <button
  200. type="button"
  201. class="place--remove--btn"
  202. @click="removePlace(round, pIdx)"
  203. >✕</button>
  204. </div>
  205. <div class="place--select--wrap">
  206. <p class="mb--4">장소 정의</p>
  207. <!-- 분야/지역/제휴 셀렉트 — 항상 노출 -->
  208. <div class="input--wrap">
  209. <select v-model="place.field_id" class="admin--form-select">
  210. <option value="">전체 분야</option>
  211. <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
  212. </select>
  213. <select v-model="place.area_id" class="admin--form-select">
  214. <option value="">전체 지역</option>
  215. <option v-for="a in areaOptions" :key="a.id" :value="a.id">{{ a.name }}</option>
  216. </select>
  217. <select v-model="place.partnership_YN" class="admin--form-select">
  218. <option value="">제휴 여부</option>
  219. <option value="Y">제휴</option>
  220. <option value="N">비제휴</option>
  221. </select>
  222. <!-- 장소 미선택 시: "장소 선택" 버튼 + 드롭다운 -->
  223. <div v-if="place.onboards.length === 0" class="place--select--btn--wrap">
  224. <button
  225. type="button"
  226. class="admin--form-select"
  227. @click.stop="openDropdown(place)"
  228. >
  229. 장소 선택
  230. </button>
  231. <div
  232. v-if="place.dropdownOpen"
  233. class="all--place--wrap"
  234. @click.stop
  235. >
  236. <div class="place--top">
  237. <div class="search--wrap">
  238. <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
  239. </div>
  240. <div class="check--wrap">
  241. <label>
  242. <input
  243. type="checkbox"
  244. :checked="isAllFilteredSelected(place)"
  245. @change="toggleAll(place)"
  246. >
  247. 전체
  248. <span>조건의 모든 장소에 적용</span>
  249. </label>
  250. </div>
  251. <div class="all--place">
  252. <p>등록된 선상ㆍ낚시터</p>
  253. <ul class="all--place--list mt--6">
  254. <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
  255. <p class="group--header">
  256. {{ group.area }}
  257. <button
  258. type="button"
  259. @click="toggleAllInGroup(place, group.items)"
  260. >
  261. {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
  262. </button>
  263. </p>
  264. <li v-for="p in group.items" :key="placeKey(p)">
  265. <label>
  266. <input
  267. type="checkbox"
  268. :checked="place.tempSelected.includes(placeKey(p))"
  269. @change="togglePlaceInTemp(place, placeKey(p))"
  270. >
  271. <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
  272. <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
  273. {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
  274. </span>
  275. <p>{{ p.field_name || '-' }}</p>
  276. </label>
  277. </li>
  278. </template>
  279. <li v-if="filteredPlaces(place).length === 0" class="empty">
  280. 조건에 맞는 장소가 없습니다.
  281. </li>
  282. </ul>
  283. </div>
  284. </div>
  285. <div class="place--bot">
  286. <p>{{ place.tempSelected.length }}개 선택</p>
  287. <button type="button" @click="applyDropdown(place)">적용</button>
  288. </div>
  289. </div>
  290. </div>
  291. </div>
  292. <!-- 장소 선택 후 — 별도 영역에 "선상ㆍ낚시터 복수 선택" + 칩 -->
  293. <template v-if="place.onboards.length > 0">
  294. <p class="mt--16 mb--4">선상ㆍ낚시터 복수 선택</p>
  295. <div class="place--select--btn--wrap">
  296. <div
  297. class="admin--form-select"
  298. @click.stop="openDropdown(place)"
  299. >
  300. <div
  301. v-for="key in place.onboards.slice(0, 2)"
  302. :key="key"
  303. class="place--selected"
  304. >
  305. {{ placeTypeByKey(key) === 'onboard' ? '🚤' : '🎣' }} {{ placeNameByKey(key) }}
  306. <button
  307. type="button"
  308. @click.stop="removePlaceChip(place, key)"
  309. >✕</button>
  310. </div>
  311. <div v-if="place.onboards.length > 2" class="place--selected">
  312. + {{ place.onboards.length - 2 }}
  313. </div>
  314. </div>
  315. <div
  316. v-if="place.dropdownOpen"
  317. class="all--place--wrap"
  318. @click.stop
  319. >
  320. <div class="place--top">
  321. <div class="search--wrap">
  322. <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
  323. </div>
  324. <div class="check--wrap">
  325. <label>
  326. <input
  327. type="checkbox"
  328. :checked="isAllFilteredSelected(place)"
  329. @change="toggleAll(place)"
  330. >
  331. 전체
  332. <span>조건의 모든 장소에 적용</span>
  333. </label>
  334. </div>
  335. <div class="all--place">
  336. <p>등록된 선상ㆍ낚시터</p>
  337. <ul class="all--place--list mt--6">
  338. <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
  339. <p class="group--header">
  340. {{ group.area }}
  341. <button
  342. type="button"
  343. @click="toggleAllInGroup(place, group.items)"
  344. >
  345. {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
  346. </button>
  347. </p>
  348. <li v-for="p in group.items" :key="placeKey(p)">
  349. <label>
  350. <input
  351. type="checkbox"
  352. :checked="place.tempSelected.includes(placeKey(p))"
  353. @change="togglePlaceInTemp(place, placeKey(p))"
  354. >
  355. <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
  356. <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
  357. {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
  358. </span>
  359. <p>{{ p.field_name || '-' }}</p>
  360. </label>
  361. </li>
  362. </template>
  363. <li v-if="filteredPlaces(place).length === 0" class="empty">
  364. 조건에 맞는 장소가 없습니다.
  365. </li>
  366. </ul>
  367. </div>
  368. </div>
  369. <div class="place--bot">
  370. <p>{{ place.tempSelected.length }}개 선택</p>
  371. <button type="button" @click="applyDropdown(place)">적용</button>
  372. </div>
  373. </div>
  374. </div>
  375. </template>
  376. </div>
  377. <!-- 장소별 아이템 -->
  378. <div class="item--select--wrap">
  379. <div class="item--select--btn--wrap mb--4 mt--16">
  380. <p>배정 아이템ㆍ수량 {{ place.items.length }}</p>
  381. <button type="button" @click="openItemModal(place)">+ 아이템 선택</button>
  382. </div>
  383. <div class="item--selected--wrap">
  384. <div v-for="(it, iIdx) in place.items" :key="it.item_id" class="item--selected">
  385. {{ it.name }}<button type="button" @click="place.items.splice(iIdx, 1)">✕</button>
  386. </div>
  387. </div>
  388. </div>
  389. </div>
  390. <button type="button" class="place--add--btn" @click="addPlace(round)">
  391. + 장소 추가
  392. </button>
  393. </template>
  394. </div>
  395. </div>
  396. <button
  397. v-if="rounds.length < 5"
  398. type="button"
  399. class="round--add--btn"
  400. @click="addRound"
  401. >
  402. + 라운드 추가 (최대 5라운드)
  403. </button>
  404. <!-- 버튼 영역 -->
  405. <div class="admin--form-actions">
  406. <button type="button" class="admin--btn" @click="goToList">
  407. ← 목록으로
  408. </button>
  409. <button
  410. type="button"
  411. class="admin--btn admin--btn-primary ml--auto"
  412. :disabled="isSavingDraft"
  413. @click="handleSaveDraft"
  414. >
  415. {{ isSavingDraft ? "저장 중..." : "임시저장" }}
  416. </button>
  417. <button type="submit" class="admin--btn admin--btn-red" :disabled="isSaving">
  418. {{ isSaving ? "저장 중..." : "저장" }}
  419. </button>
  420. </div>
  421. <!-- 성공/에러 메시지 -->
  422. <div v-if="successMessage" class="admin--alert admin--alert-success">
  423. {{ successMessage }}
  424. </div>
  425. <div v-if="errorMessage" class="admin--alert admin--alert-error">
  426. {{ errorMessage }}
  427. </div>
  428. </form>
  429. </div>
  430. <!-- ============================
  431. 아이템 선택 모달
  432. ============================ -->
  433. <ClientOnly>
  434. <Teleport to="body">
  435. <div
  436. v-if="itemModal.isOpen"
  437. class="admin--modal-overlay admin--alert-overlay"
  438. @click.self="closeItemModal"
  439. >
  440. <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
  441. <div class="admin--modal-header">
  442. <h4>아이템 선택</h4>
  443. <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
  444. </div>
  445. <div class="admin--modal-body">
  446. <div class="admin--item-modal__search mb--16">
  447. <input
  448. v-model="itemModal.searchKeyword"
  449. type="text"
  450. class="admin--form-input w--full"
  451. placeholder="🔍 아이템명 검색"
  452. />
  453. </div>
  454. <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
  455. <li
  456. v-for="it in filteredItems()"
  457. :key="it.id"
  458. class="admin--item-modal__card"
  459. :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
  460. >
  461. <label>
  462. <input
  463. type="checkbox"
  464. :checked="itemModal.tempSelected.includes(it.id)"
  465. @change="toggleItemInModal(it.id)"
  466. />
  467. <div class="admin--item-modal__thumb">
  468. <img
  469. v-if="it.file_path"
  470. :src="getImageUrl(it.file_path)"
  471. :alt="it.name"
  472. />
  473. <div v-else class="admin--item-modal__no-img">🎁</div>
  474. </div>
  475. <div class="admin--item-modal__name">{{ it.name }}</div>
  476. <div class="admin--item-modal__meta">
  477. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  478. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  479. </div>
  480. </label>
  481. </li>
  482. </ul>
  483. <div v-else class="admin--item-modal__empty">
  484. {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
  485. </div>
  486. </div>
  487. <div class="admin--modal-footer">
  488. <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
  489. <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
  490. <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
  491. </div>
  492. </div>
  493. </div>
  494. </Teleport>
  495. </ClientOnly>
  496. <!-- 임시저장 불러오기 모달 -->
  497. <AdminAlertModal
  498. v-if="showDraftModal"
  499. title="임시저장 불러오기"
  500. :message="`임시저장된 챌린지가 있습니다.\n(저장: ${draftSavedAt})\n불러올까요?\n\n[확인] 불러오기 [취소] 새로 작성 (임시저장 삭제)`"
  501. type="confirm"
  502. @confirm="loadDraft"
  503. @cancel="discardDraft"
  504. @close="showDraftModal = false"
  505. />
  506. </div>
  507. </template>
  508. <script setup>
  509. import { ref, onMounted, onBeforeUnmount } from "vue";
  510. import { useRouter } from "vue-router";
  511. import DatePicker from "~/components/admin/DatePicker.vue";
  512. import SunEditor from "~/components/admin/SunEditor.vue";
  513. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  514. definePageMeta({
  515. layout: "admin",
  516. middleware: ["auth"],
  517. });
  518. const router = useRouter();
  519. const { get, post, del, upload } = useApi();
  520. const { getImageUrl } = useImage();
  521. const isSaving = ref(false);
  522. const isSavingDraft = ref(false);
  523. const successMessage = ref("");
  524. const errorMessage = ref("");
  525. // 임시저장 관련 상태
  526. const showDraftModal = ref(false);
  527. const draftSavedAt = ref("");
  528. const draftData = ref(null);
  529. // ============================
  530. // 옵션 데이터
  531. // ============================
  532. const fieldOptions = ref([]);
  533. const areaOptions = ref([]);
  534. const placesAll = ref([]); // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
  535. const itemsAll = ref([]); // 아이템 모달용 전체 아이템
  536. // ============================
  537. // 챌린지 기본 정보
  538. // ============================
  539. const formData = ref({
  540. name: "",
  541. fee: "",
  542. max_participants: "",
  543. status_YN: "Y",
  544. description: "",
  545. });
  546. const startDate = ref("");
  547. const endDate = ref("");
  548. const isFree = ref(false);
  549. // 무료 체크박스 토글
  550. const onFreeChange = () => {
  551. if (isFree.value) {
  552. formData.value.fee = "0";
  553. } else {
  554. formData.value.fee = "";
  555. }
  556. };
  557. // ============================
  558. // 이미지 업로드
  559. // ============================
  560. const imageInput = ref(null);
  561. const image = ref(null);
  562. const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
  563. const triggerImageInput = () => imageInput.value?.click();
  564. const onImageChange = (e) => {
  565. const file = (e.target.files || [])[0];
  566. e.target.value = "";
  567. if (!file) return;
  568. if (!file.type.startsWith("image/")) {
  569. errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
  570. return;
  571. }
  572. if (file.size > MAX_IMAGE_SIZE) {
  573. errorMessage.value = "이미지가 5MB를 초과합니다.";
  574. return;
  575. }
  576. if (image.value) URL.revokeObjectURL(image.value.preview);
  577. image.value = { file, preview: URL.createObjectURL(file) };
  578. };
  579. const removeImage = () => {
  580. if (image.value) {
  581. URL.revokeObjectURL(image.value.preview);
  582. image.value = null;
  583. }
  584. };
  585. // ============================
  586. // 라운드/장소 동적 배열
  587. // ============================
  588. let _keySeq = 0;
  589. const nextKey = () => ++_keySeq;
  590. function createPlace() {
  591. return {
  592. _key: nextKey(),
  593. field_id: "",
  594. area_id: "",
  595. partnership_YN: "",
  596. onboards: [], // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
  597. items: [], // [{ item_id, name, qty }] — Phase 2
  598. // UI 상태
  599. dropdownOpen: false,
  600. searchKeyword: "",
  601. tempSelected: [], // 드롭다운 내 임시 체크 (장소 키 배열)
  602. };
  603. }
  604. function createRound(no) {
  605. return {
  606. _key: nextKey(),
  607. round_no: no,
  608. place_mode: "all",
  609. qualified: "",
  610. items: [], // [{ item_id, name, qty }] — Phase 2
  611. places: [],
  612. };
  613. }
  614. const rounds = ref([createRound(1), createRound(2)]);
  615. function renumberRounds() {
  616. rounds.value.forEach((r, i) => { r.round_no = i + 1; });
  617. }
  618. function addRound() {
  619. if (rounds.value.length >= 5) return;
  620. rounds.value.push(createRound(rounds.value.length + 1));
  621. }
  622. function removeRound(idx) {
  623. if (rounds.value.length <= 2) return;
  624. rounds.value.splice(idx, 1);
  625. renumberRounds();
  626. }
  627. function changePlaceMode(round, mode) {
  628. round.place_mode = mode;
  629. // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
  630. if (mode === "specific" && round.places.length === 0) {
  631. round.places.push(createPlace());
  632. }
  633. }
  634. function addPlace(round) {
  635. round.places.push(createPlace());
  636. }
  637. function removePlace(round, idx) {
  638. round.places.splice(idx, 1);
  639. // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
  640. if (round.places.length === 0) {
  641. round.places.push(createPlace());
  642. }
  643. }
  644. // ============================
  645. // 장소(선상+낚시터) 검색 드롭다운
  646. // ============================
  647. // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
  648. const placeKey = (p) => `${p._placeType}-${p.id}`;
  649. const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
  650. const placeNameByKey = (k) => placeByKey(k)?.name || "?";
  651. const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
  652. function closeAllDropdowns() {
  653. rounds.value.forEach((r) =>
  654. r.places.forEach((p) => { p.dropdownOpen = false; })
  655. );
  656. }
  657. function openDropdown(place) {
  658. closeAllDropdowns();
  659. place.tempSelected = [...place.onboards];
  660. place.dropdownOpen = true;
  661. }
  662. function filteredPlaces(place) {
  663. return placesAll.value.filter((p) => {
  664. if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
  665. if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
  666. if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
  667. if (place.searchKeyword) {
  668. const kw = place.searchKeyword.toLowerCase();
  669. if (!String(p.name || "").toLowerCase().includes(kw)) return false;
  670. }
  671. return true;
  672. });
  673. }
  674. function togglePlaceInTemp(place, key) {
  675. const idx = place.tempSelected.indexOf(key);
  676. if (idx === -1) place.tempSelected.push(key);
  677. else place.tempSelected.splice(idx, 1);
  678. }
  679. function isAllFilteredSelected(place) {
  680. const filtered = filteredPlaces(place);
  681. if (filtered.length === 0) return false;
  682. return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
  683. }
  684. function toggleAll(place) {
  685. const filtered = filteredPlaces(place);
  686. const filteredKeys = filtered.map(placeKey);
  687. if (isAllFilteredSelected(place)) {
  688. const set = new Set(filteredKeys);
  689. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  690. } else {
  691. const merged = new Set([...place.tempSelected, ...filteredKeys]);
  692. place.tempSelected = [...merged];
  693. }
  694. }
  695. // 지역별 그룹화: [{area, items: [...]}, ...]
  696. function groupedFilteredPlaces(place) {
  697. const filtered = filteredPlaces(place);
  698. const map = new Map();
  699. filtered.forEach((p) => {
  700. const area = p.area_name || "미분류";
  701. if (!map.has(area)) map.set(area, []);
  702. map.get(area).push(p);
  703. });
  704. return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
  705. }
  706. function isAllInGroupSelected(place, items) {
  707. if (!items || items.length === 0) return false;
  708. return items.every((p) => place.tempSelected.includes(placeKey(p)));
  709. }
  710. function toggleAllInGroup(place, items) {
  711. const keys = items.map(placeKey);
  712. if (isAllInGroupSelected(place, items)) {
  713. const set = new Set(keys);
  714. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  715. } else {
  716. const merged = new Set([...place.tempSelected, ...keys]);
  717. place.tempSelected = [...merged];
  718. }
  719. }
  720. function applyDropdown(place) {
  721. place.onboards = [...place.tempSelected];
  722. place.dropdownOpen = false;
  723. }
  724. function removePlaceChip(place, key) {
  725. place.onboards = place.onboards.filter((k) => k !== key);
  726. }
  727. // 외부 클릭 시 모든 드롭다운 닫기
  728. function handleDocumentClick() {
  729. closeAllDropdowns();
  730. }
  731. // ============================
  732. // 아이템 선택 모달
  733. // ============================
  734. const itemModal = ref({
  735. isOpen: false,
  736. target: null, // round 또는 place 객체 (둘 다 .items 배열 가짐)
  737. tempSelected: [], // 임시 선택된 item id 배열
  738. searchKeyword: "",
  739. });
  740. function openItemModal(target) {
  741. itemModal.value.target = target;
  742. itemModal.value.tempSelected = target.items.map((i) => i.item_id);
  743. itemModal.value.searchKeyword = "";
  744. itemModal.value.isOpen = true;
  745. }
  746. function closeItemModal() {
  747. itemModal.value.isOpen = false;
  748. itemModal.value.target = null;
  749. itemModal.value.tempSelected = [];
  750. itemModal.value.searchKeyword = "";
  751. }
  752. function toggleItemInModal(itemId) {
  753. const idx = itemModal.value.tempSelected.indexOf(itemId);
  754. if (idx === -1) itemModal.value.tempSelected.push(itemId);
  755. else itemModal.value.tempSelected.splice(idx, 1);
  756. }
  757. function filteredItems() {
  758. if (!itemModal.value.searchKeyword) return itemsAll.value;
  759. const kw = itemModal.value.searchKeyword.toLowerCase();
  760. return itemsAll.value.filter((i) =>
  761. String(i.name || "").toLowerCase().includes(kw)
  762. );
  763. }
  764. function applyItemModal() {
  765. const target = itemModal.value.target;
  766. if (!target) return;
  767. target.items = itemModal.value.tempSelected.map((id) => {
  768. const it = itemsAll.value.find((x) => x.id === id);
  769. return {
  770. item_id: id,
  771. name: it?.name || "?",
  772. type: it?.type || "",
  773. point: it?.point ?? null,
  774. };
  775. });
  776. closeItemModal();
  777. }
  778. // ============================
  779. // 데이터 로드
  780. // ============================
  781. async function loadOptions() {
  782. try {
  783. const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
  784. get("/field/list", { params: { per_page: 1000 } }),
  785. get("/area/list", { params: { per_page: 1000 } }),
  786. get("/onboard/list", { params: { per_page: 1000 } }),
  787. get("/fishing/list", { params: { per_page: 1000 } }),
  788. get("/item/list", { params: { per_page: 1000, status: "Y" } }),
  789. ]);
  790. if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
  791. if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
  792. // 선상 + 낚시터 통합 (_placeType으로 구분)
  793. const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
  794. .map((o) => ({ ...o, _placeType: "onboard" }));
  795. const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
  796. .map((f) => ({ ...f, _placeType: "fishing" }));
  797. placesAll.value = [...onboards, ...fishings];
  798. if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
  799. } catch (e) {
  800. console.error("Load options error:", e);
  801. }
  802. }
  803. // ============================
  804. // 임시저장 (draft)
  805. // ============================
  806. // 페이지 진입 시 임시저장 있는지 확인
  807. async function checkDraft() {
  808. try {
  809. const { data } = await get("/challenge/draft");
  810. if (data?.success && data.data) {
  811. draftData.value = data.data.data; // 응답의 data 컬럼 (이미 객체로 파싱됨)
  812. const ts = data.data.updated_at || data.data.created_at;
  813. draftSavedAt.value = ts
  814. ? new Date(String(ts).replace(" ", "T")).toLocaleString("ko-KR")
  815. : "";
  816. showDraftModal.value = true;
  817. }
  818. } catch (e) {
  819. console.error("[Draft] 조회 실패:", e);
  820. }
  821. }
  822. // 임시저장 불러오기 (모달 확인 후)
  823. function loadDraft() {
  824. showDraftModal.value = false;
  825. const d = draftData.value;
  826. if (!d) return;
  827. formData.value = {
  828. name: d.name || "",
  829. fee: d.fee || "",
  830. max_participants: d.max_participants || "",
  831. status_YN: d.status_YN || "Y",
  832. description: d.description || "",
  833. };
  834. startDate.value = d.start_date || "";
  835. endDate.value = d.end_date || "";
  836. isFree.value = !!d.is_free;
  837. if (Array.isArray(d.rounds) && d.rounds.length >= 2) {
  838. rounds.value = d.rounds.map((r) => {
  839. const round = createRound(r.round_no);
  840. round.place_mode = r.place_mode || "all";
  841. round.qualified = String(r.qualified || "");
  842. round.items = (r.items || []).map((it) => ({ ...it }));
  843. round.places = (r.places || []).map((p) => {
  844. const place = createPlace();
  845. place.field_id = p.field_id || "";
  846. place.area_id = p.area_id || "";
  847. place.partnership_YN = p.partnership_YN || "";
  848. place.onboards = [...(p.onboards || [])]; // 키 배열 그대로
  849. place.items = (p.items || []).map((it) => ({ ...it }));
  850. return place;
  851. });
  852. return round;
  853. });
  854. }
  855. successMessage.value = "임시저장을 불러왔습니다.";
  856. }
  857. // 임시저장 삭제하고 새로 작성
  858. async function discardDraft() {
  859. showDraftModal.value = false;
  860. try {
  861. await del("/challenge/draft");
  862. } catch (e) {
  863. console.error("[Draft] 삭제 실패:", e);
  864. }
  865. draftData.value = null;
  866. }
  867. // 임시저장 버튼 핸들러
  868. async function handleSaveDraft() {
  869. errorMessage.value = "";
  870. successMessage.value = "";
  871. if (!formData.value.name.trim()) {
  872. return (errorMessage.value = "임시저장하려면 최소한 챌린지명은 입력하세요.");
  873. }
  874. isSavingDraft.value = true;
  875. try {
  876. const payload = {
  877. name: formData.value.name,
  878. fee: formData.value.fee,
  879. max_participants: formData.value.max_participants,
  880. status_YN: formData.value.status_YN,
  881. description: formData.value.description,
  882. start_date: startDate.value,
  883. end_date: endDate.value,
  884. is_free: isFree.value,
  885. rounds: rounds.value.map((r) => ({
  886. round_no: r.round_no,
  887. place_mode: r.place_mode,
  888. qualified: r.qualified,
  889. items: r.items.map((it) => ({
  890. item_id: it.item_id, name: it.name, type: it.type, point: it.point,
  891. })),
  892. places: r.places.map((p) => ({
  893. field_id: p.field_id,
  894. area_id: p.area_id,
  895. partnership_YN: p.partnership_YN,
  896. onboards: [...p.onboards],
  897. items: p.items.map((it) => ({
  898. item_id: it.item_id, name: it.name, type: it.type, point: it.point,
  899. })),
  900. })),
  901. })),
  902. };
  903. const { data, error } = await post("/challenge/draft", payload);
  904. if (error || !data?.success) {
  905. errorMessage.value = error?.message || data?.message || "임시저장 실패";
  906. return;
  907. }
  908. successMessage.value = "임시저장되었습니다.";
  909. } catch (e) {
  910. console.error("[Draft] 저장 실패:", e);
  911. errorMessage.value = "서버 오류가 발생했습니다.";
  912. } finally {
  913. isSavingDraft.value = false;
  914. }
  915. }
  916. // ============================
  917. // 폼 제출
  918. // ============================
  919. async function handleSubmit() {
  920. errorMessage.value = "";
  921. successMessage.value = "";
  922. // 프론트 1차 검증
  923. if (!formData.value.name.trim()) return (errorMessage.value = "챌린지명을 입력하세요.");
  924. if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
  925. if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
  926. if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
  927. if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
  928. for (let i = 0; i < rounds.value.length; i++) {
  929. const r = rounds.value[i];
  930. if (!r.qualified) {
  931. return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
  932. }
  933. if (r.place_mode === "specific") {
  934. if (r.places.length === 0) {
  935. return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
  936. }
  937. for (let j = 0; j < r.places.length; j++) {
  938. if (r.places[j].onboards.length === 0) {
  939. return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
  940. }
  941. }
  942. }
  943. }
  944. isSaving.value = true;
  945. try {
  946. const payload = {
  947. name: formData.value.name,
  948. fee: formData.value.fee,
  949. start_date: startDate.value,
  950. end_date: endDate.value,
  951. max_participants: Number(formData.value.max_participants),
  952. description: formData.value.description,
  953. status_YN: formData.value.status_YN,
  954. rounds: rounds.value.map((r) => ({
  955. round_no: r.round_no,
  956. place_mode: r.place_mode,
  957. qualified: Number(r.qualified),
  958. items: r.place_mode === "all"
  959. ? r.items.map((it) => ({ item_id: it.item_id }))
  960. : [],
  961. places: r.place_mode === "specific"
  962. ? r.places.map((p) => ({
  963. // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
  964. onboards: p.onboards.map((key) => {
  965. const i = key.indexOf("-");
  966. return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
  967. }),
  968. items: p.items.map((it) => ({ item_id: it.item_id })),
  969. }))
  970. : [],
  971. })),
  972. };
  973. const { data, error } = await post("/challenge", payload);
  974. if (error || !data?.success) {
  975. errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
  976. return;
  977. }
  978. const newId = data.data?.id;
  979. // 이미지가 있으면 업로드 (챌린지 id 받은 뒤)
  980. if (newId && image.value) {
  981. const fd = new FormData();
  982. fd.append("image", image.value.file);
  983. const { data: imgRes, error: imgErr } = await upload(`/challenge/${newId}/image`, fd);
  984. if (imgErr || !imgRes?.success) {
  985. errorMessage.value = "챌린지는 등록됐지만 이미지 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
  986. setTimeout(() => router.push("/site-manager/challenge/list"), 1500);
  987. return;
  988. }
  989. }
  990. // 정식 등록 성공 → 임시저장 삭제
  991. try { await del("/challenge/draft"); } catch (_) { /* noop */ }
  992. successMessage.value = data.message || "챌린지가 등록되었습니다.";
  993. setTimeout(() => {
  994. router.push("/site-manager/challenge/list");
  995. }, 1000);
  996. } catch (e) {
  997. errorMessage.value = "서버 오류가 발생했습니다.";
  998. console.error("Challenge save error:", e);
  999. } finally {
  1000. isSaving.value = false;
  1001. }
  1002. }
  1003. const goToList = () => router.push("/site-manager/challenge/list");
  1004. onMounted(async () => {
  1005. document.addEventListener("click", handleDocumentClick);
  1006. await loadOptions();
  1007. // 옵션 로드 후 임시저장 확인 (불러올 때 onboards 매핑 안전)
  1008. await checkDraft();
  1009. });
  1010. onBeforeUnmount(() => {
  1011. document.removeEventListener("click", handleDocumentClick);
  1012. });
  1013. </script>