create.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. <template>
  2. <div class="admin--page-content">
  3. <div class="admin--form">
  4. <form @submit.prevent="handleSubmit">
  5. <table class="admin--form--table">
  6. <colgroup>
  7. <col style="width: 140px;">
  8. <col>
  9. </colgroup>
  10. <tbody>
  11. <tr>
  12. <th><div>챌린지명 <span class="admin--required">*</span></div></th>
  13. <td>
  14. <div class="input--wrap">
  15. <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
  16. </div>
  17. <div class="input--wrap">
  18. </div>
  19. </td>
  20. </tr>
  21. <tr>
  22. <th><div>참가비 <span class="admin--required">*</span></div></th>
  23. <td>
  24. <div class="input--wrap">
  25. <input v-model="formData.name" type="text" class="admin--form-input" placeholder="예: 10,000" required />
  26. </div>
  27. </td>
  28. </tr>
  29. <tr>
  30. <th><div>기간 <span class="admin--required">*</span></div></th>
  31. <td>
  32. <div class="input--wrap">
  33. <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
  34. <span class="admin--date-separator">-</span>
  35. <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
  36. </div>
  37. </td>
  38. </tr>
  39. <tr>
  40. <th><div>최대 참가자 <span class="admin--required">*</span></div></th>
  41. <td>
  42. <div class="input--wrap">
  43. <select v-model="formData.field_id" class="admin--form-select w--60" required>
  44. <option value="">0</option>
  45. <option value="1">1</option>
  46. </select>
  47. <select v-model="formData.field_id" class="admin--form-select w--60" required>
  48. <option value="">0</option>
  49. <option value="1">1</option>
  50. </select>
  51. <select v-model="formData.field_id" class="admin--form-select w--60" required>
  52. <option value="">0</option>
  53. <option value="1">1</option>
  54. </select>
  55. <select v-model="formData.field_id" class="admin--form-select w--60" required>
  56. <option value="">0</option>
  57. <option value="1">1</option>
  58. </select>
  59. <select v-model="formData.field_id" class="admin--form-select w--60" required>
  60. <option value="">0</option>
  61. <option value="1">1</option>
  62. </select>
  63. <select v-model="formData.field_id" class="admin--form-select w--60" required>
  64. <option value="">0</option>
  65. <option value="1">1</option>
  66. </select>
  67. </div>
  68. </td>
  69. </tr>
  70. <tr>
  71. <th><div>타이틀 이미지</div></th>
  72. <td>
  73. <div class="input--wrap">
  74. <input
  75. ref="imageInput"
  76. type="file"
  77. accept="image/*"
  78. class="admin--form-file-hidden"
  79. @change="onImageChange"
  80. />
  81. <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
  82. 이미지 선택
  83. </button>
  84. <span v-if="image" class="ml--16">{{ image.file.name }}</span>
  85. </div>
  86. <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
  87. <div v-if="image" class="onboard--photo-grid mt--10">
  88. <div class="onboard--photo-item">
  89. <img :src="image.preview" alt="미리보기" />
  90. <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
  91. </div>
  92. </div>
  93. </td>
  94. </tr>
  95. <tr>
  96. <th><div>상태 <span class="admin--required">*</span></div></th>
  97. <td>
  98. <div class="input--wrap">
  99. <label class="admin--radio-label">
  100. <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
  101. </label>
  102. <label class="admin--radio-label ml--16">
  103. <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
  104. </label>
  105. </div>
  106. </td>
  107. </tr>
  108. <tr>
  109. <th><div>상세내용</div></th>
  110. <td>
  111. <div class="input--wrap">
  112. <textarea v-model="formData.address_refer" type="text" class="admin--form-input w--full" placeholder="챌린지 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)" />
  113. </div>
  114. </td>
  115. </tr>
  116. </tbody>
  117. </table>
  118. <h3 class="admin--table--middle--title">라운드 설정</h3>
  119. <p class="admin--table--middle--desc">
  120. 라운드별로 장소 범위(전체/개별)를 선택하고, 장소마다 아이템을 배정합니다. 최대 5라운드까지 등록할 수 있습니다.
  121. </p>
  122. <div class="admin--round--box--wrap">
  123. <div class="admin--round--title">라운드 1 <span>전체 장소ㆍ아이템 2</span>
  124. <!-- 최소 2라운드 이상. 3라운드부터 라운드 삭제 버튼 노출 -->
  125. <!-- <button>
  126. 라운드 삭제
  127. </button> -->
  128. </div>
  129. <div class="admin--round--box">
  130. <div class="input--wrap">
  131. <label class="admin--round--radio">
  132. <!-- 디폴트 선택값 -->
  133. <input type="radio" value="all">
  134. 전체 장소에 동일 적용
  135. </label>
  136. <label class="admin--round--radio">
  137. <input type="radio" value="specific">
  138. 장소별 개별 설정
  139. </label>
  140. </div>
  141. <div class="qual--wrap">
  142. <p class="mt--16 mb--4">진출자 확률</p>
  143. <div class="input--wrap">
  144. <input v-model="formData.name" type="text" class="admin--form-input w--120" placeholder="예: 30" required />
  145. <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
  146. <span>명</span>
  147. </div>
  148. </div>
  149. <div class="item--select--wrap">
  150. <div class="item--select--btn--wrap mt--16 mb--4">
  151. <p class="">배정 아이템ㆍ수량 2</p>
  152. <button>+ 아이템 선택</button>
  153. </div>
  154. <div class="item--selected--wrap">
  155. <div class="item--selected">아이템 이름<button>✕</button></div>
  156. <div class="item--selected">아이템 이름<button>✕</button></div>
  157. </div>
  158. </div>
  159. </div>
  160. </div>
  161. <div class="admin--round--box--wrap mt--16">
  162. <div class="admin--round--title">라운드 2 <span>전체 장소ㆍ아이템 2</span>
  163. </div>
  164. <div class="admin--round--box">
  165. <div class="input--wrap">
  166. <label class="admin--round--radio">
  167. <!-- 디폴트 선택값 -->
  168. <input type="radio" value="all">
  169. 전체 장소에 동일 적용
  170. </label>
  171. <label class="admin--round--radio">
  172. <input type="radio" value="specific">
  173. 장소별 개별 설정
  174. </label>
  175. </div>
  176. <div class="qual--wrap">
  177. <p class="mt--16 mb--4">진출자 확률</p>
  178. <div class="input--wrap">
  179. <input v-model="formData.name" type="text" class="admin--form-input w--120" placeholder="예: 30" required />
  180. <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
  181. <span>명</span>
  182. </div>
  183. </div>
  184. <div class="item--select--wrap">
  185. <div class="item--select--btn--wrap mt--16 mb--4">
  186. <p class="">배정 아이템ㆍ수량 2</p>
  187. <button>+ 아이템 선택</button>
  188. </div>
  189. <div class="item--selected--wrap">
  190. <div class="item--selected">아이템 이름<button>✕</button></div>
  191. <div class="item--selected">아이템 이름<button>✕</button></div>
  192. </div>
  193. </div>
  194. <div class="round--place--wrap">
  195. <div class="admin--round--title">
  196. 장소 1
  197. <button class="place--remove--btn">✕</button>
  198. </div>
  199. <div class="place--select--wrap">
  200. <p class="mb--4">장소 정의</p>
  201. <div class="input--wrap">
  202. <select class="admin--form-select" required>
  203. <option value="">전체 분야</option>
  204. </select>
  205. <select class="admin--form-select" required>
  206. <option value="">전체 지역</option>
  207. </select>
  208. <select class="admin--form-select" required>
  209. <option value="">제휴</option>
  210. </select>
  211. <!-- 선상 선택 전 노출 -->
  212. <div class="place--select--btn--wrap">
  213. <button class="admin--form-select" required>
  214. <option value="">선상 선택</option>
  215. </button>
  216. <div class="all--place--wrap">
  217. <div class="place--top">
  218. <div class="search--wrap">
  219. <input type="text" placeholder="선상명 검색">
  220. </div>
  221. <div class="check--wrap">
  222. <label>
  223. <input type="checkbox">
  224. 전체
  225. <span>모든 등록 선상에 적용</span>
  226. </label>
  227. </div>
  228. <div class="all--place">
  229. <p>등록된 선상ㆍ지역명</p>
  230. <ul class="all--place--list mt--6">
  231. <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
  232. <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
  233. <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
  234. <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
  235. </ul>
  236. </div>
  237. </div>
  238. <div class="place--bot">
  239. <p>3개 선택</p>
  240. <button>적용</button>
  241. </div>
  242. </div>
  243. </div>
  244. </div>
  245. <!-- 선상 선택 후 노출 -->
  246. <p class="mt--16 mb--4">선상ㆍ복수 선택</p>
  247. <div class="place--select--btn--wrap">
  248. <div class="admin--form-select">
  249. <div class="place--selected">
  250. 동산피싱
  251. <button>✕</button>
  252. </div>
  253. <div class="place--selected">
  254. 행복마린호
  255. <button>✕</button>
  256. </div>
  257. <div class="place--selected">
  258. + 8
  259. </div>
  260. </div>
  261. <div class="all--place--wrap">
  262. <div class="place--top">
  263. <div class="search--wrap">
  264. <input type="text" placeholder="선상명 검색">
  265. </div>
  266. <div class="check--wrap">
  267. <label>
  268. <input type="checkbox">
  269. 전체
  270. <span>모든 등록 선상에 적용</span>
  271. </label>
  272. </div>
  273. <div class="all--place">
  274. <p>등록된 선상ㆍ지역명</p>
  275. <ul class="all--place--list mt--6">
  276. <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
  277. <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
  278. <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
  279. <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
  280. </ul>
  281. </div>
  282. </div>
  283. <div class="place--bot">
  284. <p>3개 선택</p>
  285. <button>적용</button>
  286. </div>
  287. </div>
  288. </div>
  289. </div>
  290. <div class="item--select--wrap">
  291. <div class="item--select--btn--wrap mb--4 mt--16">
  292. <p>배정 아이템ㆍ수량 2</p>
  293. <button>+ 아이템 선택</button>
  294. </div>
  295. <div class="item--selected--wrap">
  296. <div class="item--selected">아이템 이름<button>✕</button></div>
  297. <div class="item--selected">아이템 이름<button>✕</button></div>
  298. </div>
  299. </div>
  300. </div>
  301. <div class="place--add--btn">
  302. + 장소 추가
  303. </div>
  304. </div>
  305. </div>
  306. <div class="round--add--btn">
  307. + 라운드 추가 (최대 5라운드)
  308. </div>
  309. <!-- 버튼 영역 -->
  310. <div class="admin--form-actions">
  311. <button type="button" class="admin--btn" @click="goToList">
  312. ← 목록으로
  313. </button>
  314. <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
  315. {{ isSaving ? "저장 중..." : "저장" }}
  316. </button>
  317. </div>
  318. <!-- 성공/에러 메시지 -->
  319. <div v-if="successMessage" class="admin--alert admin--alert-success">
  320. {{ successMessage }}
  321. </div>
  322. <div v-if="errorMessage" class="admin--alert admin--alert-error">
  323. {{ errorMessage }}
  324. </div>
  325. </form>
  326. </div>
  327. </div>
  328. </template>
  329. <script setup>
  330. import { ref, computed, watch, onMounted } from "vue";
  331. import { useRouter } from "vue-router";
  332. import DatePicker from "~/components/admin/DatePicker.vue";
  333. definePageMeta({
  334. layout: "admin",
  335. middleware: ["auth"],
  336. });
  337. const router = useRouter();
  338. const config = useRuntimeConfig();
  339. const { get, post, upload } = useApi();
  340. const isSaving = ref(false);
  341. const successMessage = ref("");
  342. const errorMessage = ref("");
  343. const coordError = ref("");
  344. // 분야 / 지역 select 옵션
  345. const fieldOptions = ref([]);
  346. const areaOptions = ref([]);
  347. // 사진 업로드 (등록 시점엔 낚시터 id가 없어 파일을 보관만 함)
  348. const imageInput = ref(null);
  349. const image = ref(null);
  350. const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
  351. const triggerImageInput = () => imageInput.value?.click();
  352. const onImageChange = (e) => {
  353. const file = (e.target.files || [])[0];
  354. e.target.value = "";
  355. if (!file) return;
  356. if (!file.type.startsWith("image/")) {
  357. errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
  358. return;
  359. }
  360. if (file.size > MAX_IMAGE_SIZE) {
  361. errorMessage.value = "이미지가 10MB를 초과합니다.";
  362. return;
  363. }
  364. if (image.value) URL.revokeObjectURL(image.value.preview);
  365. image.value = { file, preview: URL.createObjectURL(file) };
  366. };
  367. const removeImage = () => {
  368. if (image.value) {
  369. URL.revokeObjectURL(image.value.preview);
  370. image.value = null;
  371. }
  372. };
  373. // 주요 은행 목록 (금융결제원 표준 코드)
  374. const bankOptions = [
  375. { code: "002", name: "산업은행" },
  376. { code: "003", name: "기업은행" },
  377. { code: "004", name: "국민은행" },
  378. { code: "007", name: "수협은행" },
  379. { code: "011", name: "농협은행" },
  380. { code: "020", name: "우리은행" },
  381. { code: "023", name: "SC제일은행" },
  382. { code: "031", name: "대구은행" },
  383. { code: "032", name: "부산은행" },
  384. { code: "034", name: "광주은행" },
  385. { code: "035", name: "제주은행" },
  386. { code: "037", name: "전북은행" },
  387. { code: "039", name: "경남은행" },
  388. { code: "045", name: "새마을금고" },
  389. { code: "071", name: "우체국" },
  390. { code: "081", name: "하나은행" },
  391. { code: "088", name: "신한은행" },
  392. { code: "089", name: "케이뱅크" },
  393. { code: "090", name: "카카오뱅크" },
  394. { code: "092", name: "토스뱅크" },
  395. ];
  396. const formData = ref({
  397. field_id: "",
  398. area_id: "",
  399. name: "",
  400. operating_hours: "",
  401. fish_species: "",
  402. zip_code: "",
  403. address: "",
  404. address_detail: "",
  405. address_refer: "",
  406. lat: "",
  407. lng: "",
  408. bank_code: "",
  409. account_number: "",
  410. account_holder: "",
  411. partnership_YN: "N",
  412. status_YN: "Y",
  413. });
  414. // 제휴 여부 — 비제휴면 계좌 입력 비활성화
  415. const isPartner = computed(() => formData.value.partnership_YN === "Y");
  416. // 비제휴로 전환 시 입력했던 계좌 정보 초기화
  417. watch(
  418. () => formData.value.partnership_YN,
  419. (val) => {
  420. if (val === "N") {
  421. formData.value.bank_code = "";
  422. formData.value.account_number = "";
  423. formData.value.account_holder = "";
  424. }
  425. }
  426. );
  427. // 분야 / 지역 옵션 로드
  428. const loadOptions = async () => {
  429. const [fieldRes, areaRes] = await Promise.all([
  430. get("/field/list", { params: { per_page: 1000 } }),
  431. get("/area/list", { params: { per_page: 1000 } }),
  432. ]);
  433. // API는 id DESC(최신순)로 주므로 뒤집어서 먼저 등록한 순(수도권 등)이 위로 오게
  434. if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
  435. if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
  436. };
  437. // 외부 스크립트 동적 로드
  438. const loadScript = (src) =>
  439. new Promise((resolve, reject) => {
  440. if (document.querySelector(`script[src="${src}"]`)) {
  441. resolve();
  442. return;
  443. }
  444. const s = document.createElement("script");
  445. s.src = src;
  446. s.onload = () => resolve();
  447. s.onerror = () => reject(new Error(`스크립트 로드 실패: ${src}`));
  448. document.head.appendChild(s);
  449. });
  450. // 주소 → 위도/경도 변환 (Google Geocoding API)
  451. const searchCoords = async (address) => {
  452. coordError.value = "";
  453. const key = config.public.googleMapKey;
  454. if (!key) {
  455. coordError.value = "좌표를 자동으로 가져올 수 없습니다. 위도/경도를 직접 입력하세요.";
  456. return;
  457. }
  458. try {
  459. const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
  460. url.searchParams.set("address", address);
  461. url.searchParams.set("key", key);
  462. url.searchParams.set("language", "ko");
  463. url.searchParams.set("region", "kr");
  464. const res = await fetch(url);
  465. const data = await res.json();
  466. if (data.status === "OK" && data.results?.[0]) {
  467. const loc = data.results[0].geometry.location;
  468. formData.value.lat = String(loc.lat);
  469. formData.value.lng = String(loc.lng);
  470. } else {
  471. formData.value.lat = "";
  472. formData.value.lng = "";
  473. coordError.value = "좌표를 찾지 못했습니다. 직접 입력해 주세요.";
  474. }
  475. } catch (e) {
  476. console.error("Geocoding error:", e);
  477. formData.value.lat = "";
  478. formData.value.lng = "";
  479. coordError.value = "좌표 조회 중 오류가 발생했습니다. 위도/경도를 직접 입력해주세요.";
  480. }
  481. };
  482. // 우편번호 검색 (Daum Postcode)
  483. const openPostcode = async () => {
  484. coordError.value = "";
  485. try {
  486. await loadScript("https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js");
  487. } catch (e) {
  488. errorMessage.value = "우편번호 서비스를 불러오지 못했습니다.";
  489. return;
  490. }
  491. new window.daum.Postcode({
  492. oncomplete: (data) => {
  493. formData.value.zip_code = data.zonecode;
  494. formData.value.address = data.roadAddress || data.jibunAddress;
  495. // 선택한 주소로 좌표 자동 조회
  496. searchCoords(formData.value.address);
  497. },
  498. }).open();
  499. };
  500. // 폼 제출
  501. const handleSubmit = async () => {
  502. successMessage.value = "";
  503. errorMessage.value = "";
  504. // 필수값 검증
  505. if (!formData.value.field_id) return (errorMessage.value = "분야를 선택하세요.");
  506. if (!formData.value.area_id) return (errorMessage.value = "지역을 선택하세요.");
  507. if (!formData.value.name.trim()) return (errorMessage.value = "낚시터명을 입력하세요.");
  508. isSaving.value = true;
  509. try {
  510. // 1) 낚시터 등록
  511. const { data, error } = await post("/fishing", { ...formData.value });
  512. if (error || !data?.success) {
  513. errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
  514. return;
  515. }
  516. const newId = data.data?.id;
  517. // 2) 사진이 있으면 업로드 (낚시터 id 받은 뒤)
  518. if (newId && photos.value.length) {
  519. const fd = new FormData();
  520. photos.value.forEach((p) => fd.append("photos[]", p.file));
  521. const { data: photoRes, error: photoErr } = await upload(`/fishing/${newId}/photos`, fd);
  522. if (photoErr || !photoRes?.success) {
  523. // 낚시터는 등록됐으나 사진 일부 실패 — 안내 후 목록 이동
  524. errorMessage.value = "낚시터는 등록됐지만 사진 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
  525. setTimeout(() => router.push("/site-manager/fishing/list"), 1500);
  526. return;
  527. }
  528. }
  529. successMessage.value = data.message || "낚시터이 등록되었습니다.";
  530. setTimeout(() => {
  531. router.push("/site-manager/fishing/list");
  532. }, 1000);
  533. } catch (e) {
  534. errorMessage.value = "서버 오류가 발생했습니다.";
  535. console.error("Save error:", e);
  536. } finally {
  537. isSaving.value = false;
  538. }
  539. };
  540. // 목록으로 이동
  541. const goToList = () => router.push("/site-manager/fishing/list");
  542. onMounted(() => {
  543. loadOptions();
  544. });
  545. </script>