create.vue 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147
  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--120"
  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="chip in displayChips(place).slice(0, 2)"
  302. :key="chip.key"
  303. class="place--selected"
  304. :class="{ 'is-group': chip.type === 'group' }"
  305. >
  306. {{ chip.icon }} {{ chip.label }}
  307. <button
  308. type="button"
  309. @click.stop="removeChipFromPlace(place, chip)"
  310. >✕</button>
  311. </div>
  312. <div v-if="displayChips(place).length > 2" class="place--selected">
  313. + {{ displayChips(place).length - 2 }}
  314. </div>
  315. </div>
  316. <div
  317. v-if="place.dropdownOpen"
  318. class="all--place--wrap"
  319. @click.stop
  320. >
  321. <div class="place--top">
  322. <div class="search--wrap">
  323. <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
  324. </div>
  325. <div class="check--wrap">
  326. <label>
  327. <input
  328. type="checkbox"
  329. :checked="isAllFilteredSelected(place)"
  330. @change="toggleAll(place)"
  331. >
  332. 전체
  333. <span>조건의 모든 장소에 적용</span>
  334. </label>
  335. </div>
  336. <div class="all--place">
  337. <p>등록된 선상ㆍ낚시터</p>
  338. <ul class="all--place--list mt--6">
  339. <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
  340. <p class="group--header">
  341. {{ group.area }}
  342. <button
  343. type="button"
  344. @click="toggleAllInGroup(place, group.items)"
  345. >
  346. {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
  347. </button>
  348. </p>
  349. <li v-for="p in group.items" :key="placeKey(p)">
  350. <label>
  351. <input
  352. type="checkbox"
  353. :checked="place.tempSelected.includes(placeKey(p))"
  354. @change="togglePlaceInTemp(place, placeKey(p))"
  355. >
  356. <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
  357. <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
  358. {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
  359. </span>
  360. <p>{{ p.field_name || '-' }}</p>
  361. </label>
  362. </li>
  363. </template>
  364. <li v-if="filteredPlaces(place).length === 0" class="empty">
  365. 조건에 맞는 장소가 없습니다.
  366. </li>
  367. </ul>
  368. </div>
  369. </div>
  370. <div class="place--bot">
  371. <p>{{ place.tempSelected.length }}개 선택</p>
  372. <button type="button" @click="applyDropdown(place)">적용</button>
  373. </div>
  374. </div>
  375. </div>
  376. </template>
  377. </div>
  378. <!-- 장소별 아이템 -->
  379. <div class="item--select--wrap">
  380. <div class="item--select--btn--wrap mb--4 mt--16">
  381. <p>배정 아이템ㆍ수량 {{ place.items.length }}</p>
  382. <button type="button" @click="openItemModal(place)">+ 아이템 선택</button>
  383. </div>
  384. <div class="item--selected--wrap">
  385. <div v-for="(it, iIdx) in place.items" :key="it.item_id" class="item--selected">
  386. {{ it.name }}<button type="button" @click="place.items.splice(iIdx, 1)">✕</button>
  387. </div>
  388. </div>
  389. </div>
  390. </div>
  391. <button type="button" class="place--add--btn" @click="addPlace(round)">
  392. + 장소 추가
  393. </button>
  394. </template>
  395. </div>
  396. </div>
  397. <button
  398. v-if="rounds.length < 5"
  399. type="button"
  400. class="round--add--btn"
  401. @click="addRound"
  402. >
  403. + 라운드 추가 (최대 5라운드)
  404. </button>
  405. <!-- 버튼 영역 -->
  406. <div class="admin--form-actions">
  407. <button type="button" class="admin--btn" @click="goToList">
  408. ← 목록으로
  409. </button>
  410. <button
  411. type="button"
  412. class="admin--btn admin--btn-primary ml--auto"
  413. :disabled="isSavingDraft"
  414. @click="handleSaveDraft"
  415. >
  416. {{ isSavingDraft ? "저장 중..." : "임시저장" }}
  417. </button>
  418. <button type="submit" class="admin--btn admin--btn-red" :disabled="isSaving">
  419. {{ isSaving ? "저장 중..." : "저장" }}
  420. </button>
  421. </div>
  422. <!-- 성공/에러 메시지 -->
  423. <div v-if="successMessage" class="admin--alert admin--alert-success">
  424. {{ successMessage }}
  425. </div>
  426. <div v-if="errorMessage" class="admin--alert admin--alert-error">
  427. {{ errorMessage }}
  428. </div>
  429. </form>
  430. </div>
  431. <!-- ============================
  432. 아이템 선택 모달
  433. ============================ -->
  434. <ClientOnly>
  435. <Teleport to="body">
  436. <div
  437. v-if="itemModal.isOpen"
  438. class="admin--modal-overlay admin--alert-overlay"
  439. @click.self="closeItemModal"
  440. >
  441. <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
  442. <div class="admin--modal-header">
  443. <h4>아이템 선택</h4>
  444. <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
  445. </div>
  446. <div class="admin--modal-body">
  447. <div class="admin--item-modal__search mb--16">
  448. <input
  449. v-model="itemModal.searchKeyword"
  450. type="text"
  451. class="admin--form-input w--full"
  452. placeholder="🔍 아이템명 검색"
  453. />
  454. </div>
  455. <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
  456. <li
  457. v-for="it in filteredItems()"
  458. :key="it.id"
  459. class="admin--item-modal__card"
  460. :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
  461. >
  462. <label>
  463. <input
  464. type="checkbox"
  465. :checked="itemModal.tempSelected.includes(it.id)"
  466. @change="toggleItemInModal(it.id)"
  467. />
  468. <div class="admin--item-modal__thumb">
  469. <img
  470. v-if="it.file_path"
  471. :src="getImageUrl(it.file_path)"
  472. :alt="it.name"
  473. />
  474. <div v-else class="admin--item-modal__no-img">🎁</div>
  475. </div>
  476. <div class="admin--item-modal__name">{{ it.name }}</div>
  477. <div class="admin--item-modal__meta">
  478. <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
  479. <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
  480. </div>
  481. </label>
  482. </li>
  483. </ul>
  484. <div v-else class="admin--item-modal__empty">
  485. {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
  486. </div>
  487. </div>
  488. <div class="admin--modal-footer">
  489. <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
  490. <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
  491. <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
  492. </div>
  493. </div>
  494. </div>
  495. </Teleport>
  496. </ClientOnly>
  497. <!-- 임시저장 불러오기 모달 -->
  498. <AdminAlertModal
  499. v-if="showDraftModal"
  500. title="임시저장 불러오기"
  501. :message="`임시저장된 챌린지가 있습니다.\n(저장: ${draftSavedAt})\n불러올까요?\n\n[확인] 불러오기 [취소] 새로 작성 (임시저장 삭제)`"
  502. type="confirm"
  503. @confirm="loadDraft"
  504. @cancel="discardDraft"
  505. @close="showDraftModal = false"
  506. />
  507. </div>
  508. </template>
  509. <script setup>
  510. import { ref, onMounted, onBeforeUnmount } from "vue";
  511. import { useRouter } from "vue-router";
  512. import DatePicker from "~/components/admin/DatePicker.vue";
  513. import SunEditor from "~/components/admin/SunEditor.vue";
  514. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  515. definePageMeta({
  516. layout: "admin",
  517. middleware: ["auth"],
  518. });
  519. const router = useRouter();
  520. const { get, post, del, upload } = useApi();
  521. const { getImageUrl } = useImage();
  522. const isSaving = ref(false);
  523. const isSavingDraft = ref(false);
  524. const successMessage = ref("");
  525. const errorMessage = ref("");
  526. // 임시저장 관련 상태
  527. const showDraftModal = ref(false);
  528. const draftSavedAt = ref("");
  529. const draftData = ref(null);
  530. // ============================
  531. // 옵션 데이터
  532. // ============================
  533. const fieldOptions = ref([]);
  534. const areaOptions = ref([]);
  535. const placesAll = ref([]); // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
  536. const itemsAll = ref([]); // 아이템 모달용 전체 아이템
  537. // ============================
  538. // 챌린지 기본 정보
  539. // ============================
  540. const formData = ref({
  541. name: "",
  542. fee: "",
  543. max_participants: "",
  544. status_YN: "Y",
  545. description: "",
  546. });
  547. const startDate = ref("");
  548. const endDate = ref("");
  549. const isFree = ref(false);
  550. // 무료 체크박스 토글
  551. const onFreeChange = () => {
  552. if (isFree.value) {
  553. formData.value.fee = "0";
  554. } else {
  555. formData.value.fee = "";
  556. }
  557. };
  558. // ============================
  559. // 이미지 업로드
  560. // ============================
  561. const imageInput = ref(null);
  562. const image = ref(null);
  563. const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
  564. const triggerImageInput = () => imageInput.value?.click();
  565. const onImageChange = (e) => {
  566. const file = (e.target.files || [])[0];
  567. e.target.value = "";
  568. if (!file) return;
  569. if (!file.type.startsWith("image/")) {
  570. errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
  571. return;
  572. }
  573. if (file.size > MAX_IMAGE_SIZE) {
  574. errorMessage.value = "이미지가 5MB를 초과합니다.";
  575. return;
  576. }
  577. if (image.value) URL.revokeObjectURL(image.value.preview);
  578. image.value = { file, preview: URL.createObjectURL(file) };
  579. };
  580. const removeImage = () => {
  581. if (image.value) {
  582. URL.revokeObjectURL(image.value.preview);
  583. image.value = null;
  584. }
  585. };
  586. // ============================
  587. // 라운드/장소 동적 배열
  588. // ============================
  589. let _keySeq = 0;
  590. const nextKey = () => ++_keySeq;
  591. function createPlace() {
  592. return {
  593. _key: nextKey(),
  594. field_id: "",
  595. area_id: "",
  596. partnership_YN: "",
  597. onboards: [], // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
  598. items: [], // [{ item_id, name, qty }] — Phase 2
  599. // UI 상태
  600. dropdownOpen: false,
  601. searchKeyword: "",
  602. tempSelected: [], // 드롭다운 내 임시 체크 (장소 키 배열)
  603. };
  604. }
  605. function createRound(no) {
  606. return {
  607. _key: nextKey(),
  608. round_no: no,
  609. place_mode: "all",
  610. qualified: "",
  611. items: [], // [{ item_id, name, qty }] — Phase 2
  612. places: [],
  613. };
  614. }
  615. const rounds = ref([createRound(1), createRound(2)]);
  616. function renumberRounds() {
  617. rounds.value.forEach((r, i) => { r.round_no = i + 1; });
  618. }
  619. function addRound() {
  620. if (rounds.value.length >= 5) return;
  621. rounds.value.push(createRound(rounds.value.length + 1));
  622. }
  623. function removeRound(idx) {
  624. if (rounds.value.length <= 2) return;
  625. rounds.value.splice(idx, 1);
  626. renumberRounds();
  627. }
  628. function changePlaceMode(round, mode) {
  629. round.place_mode = mode;
  630. // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
  631. if (mode === "specific" && round.places.length === 0) {
  632. round.places.push(createPlace());
  633. }
  634. }
  635. function addPlace(round) {
  636. round.places.push(createPlace());
  637. }
  638. function removePlace(round, idx) {
  639. round.places.splice(idx, 1);
  640. // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
  641. if (round.places.length === 0) {
  642. round.places.push(createPlace());
  643. }
  644. }
  645. // ============================
  646. // 장소(선상+낚시터) 검색 드롭다운
  647. // ============================
  648. // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
  649. const placeKey = (p) => `${p._placeType}-${p.id}`;
  650. const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
  651. const placeNameByKey = (k) => placeByKey(k)?.name || "?";
  652. const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
  653. function closeAllDropdowns() {
  654. rounds.value.forEach((r) =>
  655. r.places.forEach((p) => { p.dropdownOpen = false; })
  656. );
  657. }
  658. function openDropdown(place) {
  659. closeAllDropdowns();
  660. place.tempSelected = [...place.onboards];
  661. place.dropdownOpen = true;
  662. }
  663. function filteredPlaces(place) {
  664. return placesAll.value.filter((p) => {
  665. if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
  666. if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
  667. if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
  668. if (place.searchKeyword) {
  669. const kw = place.searchKeyword.toLowerCase();
  670. if (!String(p.name || "").toLowerCase().includes(kw)) return false;
  671. }
  672. return true;
  673. });
  674. }
  675. function togglePlaceInTemp(place, key) {
  676. const idx = place.tempSelected.indexOf(key);
  677. if (idx === -1) place.tempSelected.push(key);
  678. else place.tempSelected.splice(idx, 1);
  679. }
  680. function isAllFilteredSelected(place) {
  681. const filtered = filteredPlaces(place);
  682. if (filtered.length === 0) return false;
  683. return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
  684. }
  685. function toggleAll(place) {
  686. const filtered = filteredPlaces(place);
  687. const filteredKeys = filtered.map(placeKey);
  688. if (isAllFilteredSelected(place)) {
  689. const set = new Set(filteredKeys);
  690. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  691. } else {
  692. const merged = new Set([...place.tempSelected, ...filteredKeys]);
  693. place.tempSelected = [...merged];
  694. }
  695. }
  696. // 지역별 그룹화: [{area, items: [...]}, ...]
  697. function groupedFilteredPlaces(place) {
  698. const filtered = filteredPlaces(place);
  699. const map = new Map();
  700. filtered.forEach((p) => {
  701. const area = p.area_name || "미분류";
  702. if (!map.has(area)) map.set(area, []);
  703. map.get(area).push(p);
  704. });
  705. return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
  706. }
  707. function isAllInGroupSelected(place, items) {
  708. if (!items || items.length === 0) return false;
  709. return items.every((p) => place.tempSelected.includes(placeKey(p)));
  710. }
  711. function toggleAllInGroup(place, items) {
  712. const keys = items.map(placeKey);
  713. if (isAllInGroupSelected(place, items)) {
  714. const set = new Set(keys);
  715. place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
  716. } else {
  717. const merged = new Set([...place.tempSelected, ...keys]);
  718. place.tempSelected = [...merged];
  719. }
  720. }
  721. function applyDropdown(place) {
  722. place.onboards = [...place.tempSelected];
  723. place.dropdownOpen = false;
  724. }
  725. function removePlaceChip(place, key) {
  726. place.onboards = place.onboards.filter((k) => k !== key);
  727. }
  728. // 칩 표시용 — 같은 지역의 모든 장소가 선택됐으면 "○○ 전체" 그룹 칩으로 묶음
  729. function displayChips(place) {
  730. const selectedKeys = new Set(place.onboards);
  731. const groupedAll = new Map();
  732. placesAll.value.forEach((p) => {
  733. const area = p.area_name || "미분류";
  734. if (!groupedAll.has(area)) groupedAll.set(area, []);
  735. groupedAll.get(area).push(p);
  736. });
  737. const result = [];
  738. const processedKeys = new Set();
  739. for (const [area, places] of groupedAll.entries()) {
  740. if (places.length < 2) continue;
  741. const groupKeys = places.map(placeKey);
  742. const allSelected = groupKeys.every((k) => selectedKeys.has(k));
  743. if (allSelected) {
  744. result.push({
  745. key: `group:${area}`,
  746. type: "group",
  747. label: `${area} 전체`,
  748. icon: "📍",
  749. keys: groupKeys,
  750. });
  751. groupKeys.forEach((k) => processedKeys.add(k));
  752. }
  753. }
  754. for (const key of place.onboards) {
  755. if (processedKeys.has(key)) continue;
  756. result.push({
  757. key,
  758. type: "single",
  759. label: placeNameByKey(key),
  760. icon: placeTypeByKey(key) === "onboard" ? "🚤" : "🎣",
  761. keys: [key],
  762. });
  763. }
  764. return result;
  765. }
  766. function removeChipFromPlace(place, chip) {
  767. const keysToRemove = new Set(chip.keys);
  768. place.onboards = place.onboards.filter((k) => !keysToRemove.has(k));
  769. }
  770. // 외부 클릭 시 모든 드롭다운 닫기
  771. function handleDocumentClick() {
  772. closeAllDropdowns();
  773. }
  774. // ============================
  775. // 아이템 선택 모달
  776. // ============================
  777. const itemModal = ref({
  778. isOpen: false,
  779. target: null, // round 또는 place 객체 (둘 다 .items 배열 가짐)
  780. tempSelected: [], // 임시 선택된 item id 배열
  781. searchKeyword: "",
  782. });
  783. function openItemModal(target) {
  784. itemModal.value.target = target;
  785. itemModal.value.tempSelected = target.items.map((i) => i.item_id);
  786. itemModal.value.searchKeyword = "";
  787. itemModal.value.isOpen = true;
  788. }
  789. function closeItemModal() {
  790. itemModal.value.isOpen = false;
  791. itemModal.value.target = null;
  792. itemModal.value.tempSelected = [];
  793. itemModal.value.searchKeyword = "";
  794. }
  795. function toggleItemInModal(itemId) {
  796. const idx = itemModal.value.tempSelected.indexOf(itemId);
  797. if (idx === -1) itemModal.value.tempSelected.push(itemId);
  798. else itemModal.value.tempSelected.splice(idx, 1);
  799. }
  800. function filteredItems() {
  801. if (!itemModal.value.searchKeyword) return itemsAll.value;
  802. const kw = itemModal.value.searchKeyword.toLowerCase();
  803. return itemsAll.value.filter((i) =>
  804. String(i.name || "").toLowerCase().includes(kw)
  805. );
  806. }
  807. function applyItemModal() {
  808. const target = itemModal.value.target;
  809. if (!target) return;
  810. target.items = itemModal.value.tempSelected.map((id) => {
  811. const it = itemsAll.value.find((x) => x.id === id);
  812. return {
  813. item_id: id,
  814. name: it?.name || "?",
  815. type: it?.type || "",
  816. point: it?.point ?? null,
  817. };
  818. });
  819. closeItemModal();
  820. }
  821. // ============================
  822. // 데이터 로드
  823. // ============================
  824. async function loadOptions() {
  825. try {
  826. const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
  827. get("/field/list", { params: { per_page: 1000 } }),
  828. get("/area/list", { params: { per_page: 1000 } }),
  829. get("/onboard/list", { params: { per_page: 1000 } }),
  830. get("/fishing/list", { params: { per_page: 1000 } }),
  831. get("/item/list", { params: { per_page: 1000, status: "Y" } }),
  832. ]);
  833. if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
  834. if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
  835. // 선상 + 낚시터 통합 (_placeType으로 구분)
  836. const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
  837. .map((o) => ({ ...o, _placeType: "onboard" }));
  838. const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
  839. .map((f) => ({ ...f, _placeType: "fishing" }));
  840. placesAll.value = [...onboards, ...fishings];
  841. if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
  842. } catch (e) {
  843. console.error("Load options error:", e);
  844. }
  845. }
  846. // ============================
  847. // 임시저장 (draft)
  848. // ============================
  849. // 페이지 진입 시 임시저장 있는지 확인
  850. async function checkDraft() {
  851. try {
  852. const { data } = await get("/challenge/draft");
  853. if (data?.success && data.data) {
  854. draftData.value = data.data.data; // 응답의 data 컬럼 (이미 객체로 파싱됨)
  855. const ts = data.data.updated_at || data.data.created_at;
  856. draftSavedAt.value = ts
  857. ? new Date(String(ts).replace(" ", "T")).toLocaleString("ko-KR")
  858. : "";
  859. showDraftModal.value = true;
  860. }
  861. } catch (e) {
  862. console.error("[Draft] 조회 실패:", e);
  863. }
  864. }
  865. // 임시저장 불러오기 (모달 확인 후)
  866. function loadDraft() {
  867. showDraftModal.value = false;
  868. const d = draftData.value;
  869. if (!d) return;
  870. formData.value = {
  871. name: d.name || "",
  872. fee: d.fee || "",
  873. max_participants: d.max_participants || "",
  874. status_YN: d.status_YN || "Y",
  875. description: d.description || "",
  876. };
  877. startDate.value = d.start_date || "";
  878. endDate.value = d.end_date || "";
  879. isFree.value = !!d.is_free;
  880. if (Array.isArray(d.rounds) && d.rounds.length >= 2) {
  881. rounds.value = d.rounds.map((r) => {
  882. const round = createRound(r.round_no);
  883. round.place_mode = r.place_mode || "all";
  884. round.qualified = String(r.qualified || "");
  885. round.items = (r.items || []).map((it) => ({ ...it }));
  886. round.places = (r.places || []).map((p) => {
  887. const place = createPlace();
  888. place.field_id = p.field_id || "";
  889. place.area_id = p.area_id || "";
  890. place.partnership_YN = p.partnership_YN || "";
  891. place.onboards = [...(p.onboards || [])]; // 키 배열 그대로
  892. place.items = (p.items || []).map((it) => ({ ...it }));
  893. return place;
  894. });
  895. return round;
  896. });
  897. }
  898. successMessage.value = "임시저장을 불러왔습니다.";
  899. }
  900. // 임시저장 삭제하고 새로 작성
  901. async function discardDraft() {
  902. showDraftModal.value = false;
  903. try {
  904. await del("/challenge/draft");
  905. } catch (e) {
  906. console.error("[Draft] 삭제 실패:", e);
  907. }
  908. draftData.value = null;
  909. }
  910. // 임시저장 버튼 핸들러
  911. async function handleSaveDraft() {
  912. errorMessage.value = "";
  913. successMessage.value = "";
  914. if (!formData.value.name.trim()) {
  915. return (errorMessage.value = "임시저장하려면 최소한 챌린지명은 입력하세요.");
  916. }
  917. isSavingDraft.value = true;
  918. try {
  919. const payload = {
  920. name: formData.value.name,
  921. fee: formData.value.fee,
  922. max_participants: formData.value.max_participants,
  923. status_YN: formData.value.status_YN,
  924. description: formData.value.description,
  925. start_date: startDate.value,
  926. end_date: endDate.value,
  927. is_free: isFree.value,
  928. rounds: rounds.value.map((r) => ({
  929. round_no: r.round_no,
  930. place_mode: r.place_mode,
  931. qualified: r.qualified,
  932. items: r.items.map((it) => ({
  933. item_id: it.item_id, name: it.name, type: it.type, point: it.point,
  934. })),
  935. places: r.places.map((p) => ({
  936. field_id: p.field_id,
  937. area_id: p.area_id,
  938. partnership_YN: p.partnership_YN,
  939. onboards: [...p.onboards],
  940. items: p.items.map((it) => ({
  941. item_id: it.item_id, name: it.name, type: it.type, point: it.point,
  942. })),
  943. })),
  944. })),
  945. };
  946. const { data, error } = await post("/challenge/draft", payload);
  947. if (error || !data?.success) {
  948. errorMessage.value = error?.message || data?.message || "임시저장 실패";
  949. return;
  950. }
  951. successMessage.value = "임시저장되었습니다.";
  952. } catch (e) {
  953. console.error("[Draft] 저장 실패:", e);
  954. errorMessage.value = "서버 오류가 발생했습니다.";
  955. } finally {
  956. isSavingDraft.value = false;
  957. }
  958. }
  959. // ============================
  960. // 폼 제출
  961. // ============================
  962. async function handleSubmit() {
  963. errorMessage.value = "";
  964. successMessage.value = "";
  965. // 프론트 1차 검증
  966. if (!formData.value.name.trim()) return (errorMessage.value = "챌린지명을 입력하세요.");
  967. if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
  968. if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
  969. if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
  970. if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
  971. for (let i = 0; i < rounds.value.length; i++) {
  972. const r = rounds.value[i];
  973. if (!r.qualified) {
  974. return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
  975. }
  976. if (r.place_mode === "specific") {
  977. if (r.places.length === 0) {
  978. return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
  979. }
  980. for (let j = 0; j < r.places.length; j++) {
  981. if (r.places[j].onboards.length === 0) {
  982. return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
  983. }
  984. }
  985. }
  986. }
  987. isSaving.value = true;
  988. try {
  989. const payload = {
  990. name: formData.value.name,
  991. fee: formData.value.fee,
  992. start_date: startDate.value,
  993. end_date: endDate.value,
  994. max_participants: Number(formData.value.max_participants),
  995. description: formData.value.description,
  996. status_YN: formData.value.status_YN,
  997. rounds: rounds.value.map((r) => ({
  998. round_no: r.round_no,
  999. place_mode: r.place_mode,
  1000. qualified: Number(r.qualified),
  1001. items: r.place_mode === "all"
  1002. ? r.items.map((it) => ({ item_id: it.item_id }))
  1003. : [],
  1004. places: r.place_mode === "specific"
  1005. ? r.places.map((p) => ({
  1006. // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
  1007. onboards: p.onboards.map((key) => {
  1008. const i = key.indexOf("-");
  1009. return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
  1010. }),
  1011. items: p.items.map((it) => ({ item_id: it.item_id })),
  1012. }))
  1013. : [],
  1014. })),
  1015. };
  1016. const { data, error } = await post("/challenge", payload);
  1017. if (error || !data?.success) {
  1018. errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
  1019. return;
  1020. }
  1021. const newId = data.data?.id;
  1022. // 이미지가 있으면 업로드 (챌린지 id 받은 뒤)
  1023. if (newId && image.value) {
  1024. const fd = new FormData();
  1025. fd.append("image", image.value.file);
  1026. const { data: imgRes, error: imgErr } = await upload(`/challenge/${newId}/image`, fd);
  1027. if (imgErr || !imgRes?.success) {
  1028. errorMessage.value = "챌린지는 등록됐지만 이미지 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
  1029. setTimeout(() => router.push("/site-manager/challenge/list"), 1500);
  1030. return;
  1031. }
  1032. }
  1033. // 정식 등록 성공 → 임시저장 삭제
  1034. try { await del("/challenge/draft"); } catch (_) { /* noop */ }
  1035. successMessage.value = data.message || "챌린지가 등록되었습니다.";
  1036. setTimeout(() => {
  1037. router.push("/site-manager/challenge/list");
  1038. }, 1000);
  1039. } catch (e) {
  1040. errorMessage.value = "서버 오류가 발생했습니다.";
  1041. console.error("Challenge save error:", e);
  1042. } finally {
  1043. isSaving.value = false;
  1044. }
  1045. }
  1046. const goToList = () => router.push("/site-manager/challenge/list");
  1047. onMounted(async () => {
  1048. document.addEventListener("click", handleDocumentClick);
  1049. await loadOptions();
  1050. // 옵션 로드 후 임시저장 확인 (불러올 때 onboards 매핑 안전)
  1051. await checkDraft();
  1052. });
  1053. onBeforeUnmount(() => {
  1054. document.removeEventListener("click", handleDocumentClick);
  1055. });
  1056. </script>