| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147 |
- <template>
- <div class="admin--page-content">
- <div class="admin--form">
- <form @submit.prevent="handleSubmit">
- <!-- ============================
- 챌린지 기본 정보
- ============================ -->
- <table class="admin--form--table">
- <colgroup>
- <col style="width: 140px;">
- <col>
- </colgroup>
- <tbody>
- <tr>
- <th><div>챌린지명 <span class="admin--required">*</span></div></th>
- <td>
- <div class="input--wrap">
- <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
- </div>
- </td>
- </tr>
- <tr>
- <th><div>참가비 <span class="admin--required">*</span></div></th>
- <td>
- <div class="input--wrap">
- <input
- v-model="formData.fee"
- type="number"
- min="0"
- class="admin--form-input w--120"
- :placeholder="isFree ? '0 (무료)' : '예: 10000'"
- :disabled="isFree"
- required
- />
- <span>원</span>
- <label class="admin--checkbox-label">
- <input type="checkbox" v-model="isFree" @change="onFreeChange" /> 무료
- </label>
- </div>
- </td>
- </tr>
- <tr>
- <th><div>기간 <span class="admin--required">*</span></div></th>
- <td>
- <div class="input--wrap">
- <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
- <span class="admin--date-separator">-</span>
- <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
- </div>
- </td>
- </tr>
- <tr>
- <th><div>최대 참가자 <span class="admin--required">*</span></div></th>
- <td>
- <div class="input--wrap">
- <input v-model="formData.max_participants" type="number" min="100" max="999999" class="admin--form-input w--120" placeholder="100 ~ 999999" required />
- <span>명</span>
- </div>
- </td>
- </tr>
- <tr>
- <th><div>타이틀 이미지</div></th>
- <td>
- <div class="input--wrap">
- <input
- ref="imageInput"
- type="file"
- accept="image/*"
- class="admin--form-file-hidden"
- @change="onImageChange"
- />
- <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
- 이미지 선택
- </button>
- <span v-if="image" class="ml--16">{{ image.file.name }}</span>
- </div>
- <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
- <div v-if="image" class="onboard--photo-grid mt--10">
- <div class="onboard--photo-item">
- <img :src="image.preview" alt="미리보기" />
- <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
- </div>
- </div>
- </td>
- </tr>
- <tr>
- <th><div>상태 <span class="admin--required">*</span></div></th>
- <td>
- <div class="input--wrap">
- <label class="admin--radio-label">
- <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
- </label>
- <label class="admin--radio-label ml--16">
- <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
- </label>
- </div>
- </td>
- </tr>
- <tr>
- <th><div>상세내용</div></th>
- <td>
- <ClientOnly>
- <SunEditor
- v-model="formData.description"
- height="400px"
- placeholder="챌린지 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)"
- />
- </ClientOnly>
- </td>
- </tr>
- </tbody>
- </table>
- <!-- ============================
- 라운드 설정
- ============================ -->
- <h3 class="admin--table--middle--title">라운드 설정</h3>
- <p class="admin--table--middle--desc">
- 라운드별로 장소 범위(전체/개별)를 선택하고, 장소마다 아이템을 배정합니다. 최대 5라운드까지 등록할 수 있습니다.
- </p>
- <div
- v-for="(round, rIdx) in rounds"
- :key="round._key"
- class="admin--round--box--wrap"
- :class="{ 'mt--16': rIdx > 0 }"
- >
- <div class="admin--round--title">
- 라운드 {{ round.round_no }}
- <span v-if="round.place_mode === 'all'">전체 장소ㆍ아이템 {{ round.items.length }}</span>
- <span v-else>개별 장소 {{ round.places.length }}</span>
- <!-- 최소 2라운드 이상. 3라운드부터 라운드 삭제 버튼 노출 -->
- <button
- v-if="rounds.length > 2"
- type="button"
- class="round--remove--btn"
- @click="removeRound(rIdx)"
- >
- 라운드 삭제
- </button>
- </div>
- <div class="admin--round--box">
- <div class="input--wrap mt--16">
- <label class="admin--round--radio">
- <input
- type="radio"
- :name="'place_mode_' + round._key"
- value="all"
- :checked="round.place_mode === 'all'"
- @change="changePlaceMode(round, 'all')"
- />
- 전체 장소에 동일 적용
- </label>
- <label class="admin--round--radio">
- <input
- type="radio"
- :name="'place_mode_' + round._key"
- value="specific"
- :checked="round.place_mode === 'specific'"
- @change="changePlaceMode(round, 'specific')"
- />
- 장소별 개별 설정
- </label>
- </div>
- <div class="qual--wrap">
- <p class="mt--16 mb--4">진출자 {{ rIdx === 0 ? '인원' : '확률' }}</p>
- <div class="input--wrap">
- <input
- v-model="round.qualified"
- type="number"
- min="1"
- class="admin--form-input w--120"
- :placeholder="rIdx === 0 ? '예: 30' : '예: 50'"
- required
- />
- <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
- <span>{{ rIdx === 0 ? '명' : '%' }}</span>
- </div>
- </div>
- <!-- 전체 적용 모드 — 라운드 단위 아이템 -->
- <div v-if="round.place_mode === 'all'" class="item--select--wrap">
- <div class="item--select--btn--wrap mt--16 mb--4">
- <p>배정 아이템ㆍ수량 {{ round.items.length }}</p>
- <button type="button" @click="openItemModal(round)">+ 아이템 선택</button>
- </div>
- <div class="item--selected--wrap">
- <div v-for="(it, iIdx) in round.items" :key="it.item_id" class="item--selected">
- {{ it.name }}<button type="button" @click="round.items.splice(iIdx, 1)">✕</button>
- </div>
- </div>
- </div>
- <!-- 장소별 개별 설정 모드 -->
- <template v-else>
- <div
- v-for="(place, pIdx) in round.places"
- :key="place._key"
- class="round--place--wrap"
- >
- <div class="admin--round--title">
- 장소 {{ pIdx + 1 }}
- <button
- type="button"
- class="place--remove--btn"
- @click="removePlace(round, pIdx)"
- >✕</button>
- </div>
- <div class="place--select--wrap">
- <p class="mb--4">장소 정의</p>
- <!-- 분야/지역/제휴 셀렉트 — 항상 노출 -->
- <div class="input--wrap">
- <select v-model="place.field_id" class="admin--form-select">
- <option value="">전체 분야</option>
- <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
- </select>
- <select v-model="place.area_id" class="admin--form-select">
- <option value="">전체 지역</option>
- <option v-for="a in areaOptions" :key="a.id" :value="a.id">{{ a.name }}</option>
- </select>
- <select v-model="place.partnership_YN" class="admin--form-select">
- <option value="">제휴 여부</option>
- <option value="Y">제휴</option>
- <option value="N">비제휴</option>
- </select>
- <!-- 장소 미선택 시: "장소 선택" 버튼 + 드롭다운 -->
- <div v-if="place.onboards.length === 0" class="place--select--btn--wrap">
- <button
- type="button"
- class="admin--form-select"
- @click.stop="openDropdown(place)"
- >
- 장소 선택
- </button>
- <div
- v-if="place.dropdownOpen"
- class="all--place--wrap"
- @click.stop
- >
- <div class="place--top">
- <div class="search--wrap">
- <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
- </div>
- <div class="check--wrap">
- <label>
- <input
- type="checkbox"
- :checked="isAllFilteredSelected(place)"
- @change="toggleAll(place)"
- >
- 전체
- <span>조건의 모든 장소에 적용</span>
- </label>
- </div>
- <div class="all--place">
- <p>등록된 선상ㆍ낚시터</p>
- <ul class="all--place--list mt--6">
- <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
- <p class="group--header">
- {{ group.area }}
- <button
- type="button"
- @click="toggleAllInGroup(place, group.items)"
- >
- {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
- </button>
- </p>
- <li v-for="p in group.items" :key="placeKey(p)">
- <label>
- <input
- type="checkbox"
- :checked="place.tempSelected.includes(placeKey(p))"
- @change="togglePlaceInTemp(place, placeKey(p))"
- >
- <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
- <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
- {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
- </span>
- <p>{{ p.field_name || '-' }}</p>
- </label>
- </li>
- </template>
- <li v-if="filteredPlaces(place).length === 0" class="empty">
- 조건에 맞는 장소가 없습니다.
- </li>
- </ul>
- </div>
- </div>
- <div class="place--bot">
- <p>{{ place.tempSelected.length }}개 선택</p>
- <button type="button" @click="applyDropdown(place)">적용</button>
- </div>
- </div>
- </div>
- </div>
- <!-- 장소 선택 후 — 별도 영역에 "선상ㆍ낚시터 복수 선택" + 칩 -->
- <template v-if="place.onboards.length > 0">
- <p class="mt--16 mb--4">선상ㆍ낚시터 복수 선택</p>
- <div class="place--select--btn--wrap">
- <div
- class="admin--form-select"
- @click.stop="openDropdown(place)"
- >
- <div
- v-for="chip in displayChips(place).slice(0, 2)"
- :key="chip.key"
- class="place--selected"
- :class="{ 'is-group': chip.type === 'group' }"
- >
- {{ chip.icon }} {{ chip.label }}
- <button
- type="button"
- @click.stop="removeChipFromPlace(place, chip)"
- >✕</button>
- </div>
- <div v-if="displayChips(place).length > 2" class="place--selected">
- + {{ displayChips(place).length - 2 }}
- </div>
- </div>
- <div
- v-if="place.dropdownOpen"
- class="all--place--wrap"
- @click.stop
- >
- <div class="place--top">
- <div class="search--wrap">
- <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
- </div>
- <div class="check--wrap">
- <label>
- <input
- type="checkbox"
- :checked="isAllFilteredSelected(place)"
- @change="toggleAll(place)"
- >
- 전체
- <span>조건의 모든 장소에 적용</span>
- </label>
- </div>
- <div class="all--place">
- <p>등록된 선상ㆍ낚시터</p>
- <ul class="all--place--list mt--6">
- <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
- <p class="group--header">
- {{ group.area }}
- <button
- type="button"
- @click="toggleAllInGroup(place, group.items)"
- >
- {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
- </button>
- </p>
- <li v-for="p in group.items" :key="placeKey(p)">
- <label>
- <input
- type="checkbox"
- :checked="place.tempSelected.includes(placeKey(p))"
- @change="togglePlaceInTemp(place, placeKey(p))"
- >
- <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
- <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
- {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
- </span>
- <p>{{ p.field_name || '-' }}</p>
- </label>
- </li>
- </template>
- <li v-if="filteredPlaces(place).length === 0" class="empty">
- 조건에 맞는 장소가 없습니다.
- </li>
- </ul>
- </div>
- </div>
- <div class="place--bot">
- <p>{{ place.tempSelected.length }}개 선택</p>
- <button type="button" @click="applyDropdown(place)">적용</button>
- </div>
- </div>
- </div>
- </template>
- </div>
- <!-- 장소별 아이템 -->
- <div class="item--select--wrap">
- <div class="item--select--btn--wrap mb--4 mt--16">
- <p>배정 아이템ㆍ수량 {{ place.items.length }}</p>
- <button type="button" @click="openItemModal(place)">+ 아이템 선택</button>
- </div>
- <div class="item--selected--wrap">
- <div v-for="(it, iIdx) in place.items" :key="it.item_id" class="item--selected">
- {{ it.name }}<button type="button" @click="place.items.splice(iIdx, 1)">✕</button>
- </div>
- </div>
- </div>
- </div>
- <button type="button" class="place--add--btn" @click="addPlace(round)">
- + 장소 추가
- </button>
- </template>
- </div>
- </div>
- <button
- v-if="rounds.length < 5"
- type="button"
- class="round--add--btn"
- @click="addRound"
- >
- + 라운드 추가 (최대 5라운드)
- </button>
- <!-- 버튼 영역 -->
- <div class="admin--form-actions">
- <button type="button" class="admin--btn" @click="goToList">
- ← 목록으로
- </button>
- <button
- type="button"
- class="admin--btn admin--btn-primary ml--auto"
- :disabled="isSavingDraft"
- @click="handleSaveDraft"
- >
- {{ isSavingDraft ? "저장 중..." : "임시저장" }}
- </button>
- <button type="submit" class="admin--btn admin--btn-red" :disabled="isSaving">
- {{ isSaving ? "저장 중..." : "저장" }}
- </button>
- </div>
- <!-- 성공/에러 메시지 -->
- <div v-if="successMessage" class="admin--alert admin--alert-success">
- {{ successMessage }}
- </div>
- <div v-if="errorMessage" class="admin--alert admin--alert-error">
- {{ errorMessage }}
- </div>
- </form>
- </div>
- <!-- ============================
- 아이템 선택 모달
- ============================ -->
- <ClientOnly>
- <Teleport to="body">
- <div
- v-if="itemModal.isOpen"
- class="admin--modal-overlay admin--alert-overlay"
- @click.self="closeItemModal"
- >
- <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
- <div class="admin--modal-header">
- <h4>아이템 선택</h4>
- <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
- </div>
- <div class="admin--modal-body">
- <div class="admin--item-modal__search mb--16">
- <input
- v-model="itemModal.searchKeyword"
- type="text"
- class="admin--form-input w--full"
- placeholder="🔍 아이템명 검색"
- />
- </div>
- <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
- <li
- v-for="it in filteredItems()"
- :key="it.id"
- class="admin--item-modal__card"
- :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
- >
- <label>
- <input
- type="checkbox"
- :checked="itemModal.tempSelected.includes(it.id)"
- @change="toggleItemInModal(it.id)"
- />
- <div class="admin--item-modal__thumb">
- <img
- v-if="it.file_path"
- :src="getImageUrl(it.file_path)"
- :alt="it.name"
- />
- <div v-else class="admin--item-modal__no-img">🎁</div>
- </div>
- <div class="admin--item-modal__name">{{ it.name }}</div>
- <div class="admin--item-modal__meta">
- <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
- <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
- </div>
- </label>
- </li>
- </ul>
- <div v-else class="admin--item-modal__empty">
- {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
- </div>
- </div>
- <div class="admin--modal-footer">
- <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
- <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
- <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
- </div>
- </div>
- </div>
- </Teleport>
- </ClientOnly>
- <!-- 임시저장 불러오기 모달 -->
- <AdminAlertModal
- v-if="showDraftModal"
- title="임시저장 불러오기"
- :message="`임시저장된 챌린지가 있습니다.\n(저장: ${draftSavedAt})\n불러올까요?\n\n[확인] 불러오기 [취소] 새로 작성 (임시저장 삭제)`"
- type="confirm"
- @confirm="loadDraft"
- @cancel="discardDraft"
- @close="showDraftModal = false"
- />
- </div>
- </template>
- <script setup>
- import { ref, onMounted, onBeforeUnmount } from "vue";
- import { useRouter } from "vue-router";
- import DatePicker from "~/components/admin/DatePicker.vue";
- import SunEditor from "~/components/admin/SunEditor.vue";
- import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
- definePageMeta({
- layout: "admin",
- middleware: ["auth"],
- });
- const router = useRouter();
- const { get, post, del, upload } = useApi();
- const { getImageUrl } = useImage();
- const isSaving = ref(false);
- const isSavingDraft = ref(false);
- const successMessage = ref("");
- const errorMessage = ref("");
- // 임시저장 관련 상태
- const showDraftModal = ref(false);
- const draftSavedAt = ref("");
- const draftData = ref(null);
- // ============================
- // 옵션 데이터
- // ============================
- const fieldOptions = ref([]);
- const areaOptions = ref([]);
- const placesAll = ref([]); // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
- const itemsAll = ref([]); // 아이템 모달용 전체 아이템
- // ============================
- // 챌린지 기본 정보
- // ============================
- const formData = ref({
- name: "",
- fee: "",
- max_participants: "",
- status_YN: "Y",
- description: "",
- });
- const startDate = ref("");
- const endDate = ref("");
- const isFree = ref(false);
- // 무료 체크박스 토글
- const onFreeChange = () => {
- if (isFree.value) {
- formData.value.fee = "0";
- } else {
- formData.value.fee = "";
- }
- };
- // ============================
- // 이미지 업로드
- // ============================
- const imageInput = ref(null);
- const image = ref(null);
- const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
- const triggerImageInput = () => imageInput.value?.click();
- const onImageChange = (e) => {
- const file = (e.target.files || [])[0];
- e.target.value = "";
- if (!file) return;
- if (!file.type.startsWith("image/")) {
- errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
- return;
- }
- if (file.size > MAX_IMAGE_SIZE) {
- errorMessage.value = "이미지가 5MB를 초과합니다.";
- return;
- }
- if (image.value) URL.revokeObjectURL(image.value.preview);
- image.value = { file, preview: URL.createObjectURL(file) };
- };
- const removeImage = () => {
- if (image.value) {
- URL.revokeObjectURL(image.value.preview);
- image.value = null;
- }
- };
- // ============================
- // 라운드/장소 동적 배열
- // ============================
- let _keySeq = 0;
- const nextKey = () => ++_keySeq;
- function createPlace() {
- return {
- _key: nextKey(),
- field_id: "",
- area_id: "",
- partnership_YN: "",
- onboards: [], // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
- items: [], // [{ item_id, name, qty }] — Phase 2
- // UI 상태
- dropdownOpen: false,
- searchKeyword: "",
- tempSelected: [], // 드롭다운 내 임시 체크 (장소 키 배열)
- };
- }
- function createRound(no) {
- return {
- _key: nextKey(),
- round_no: no,
- place_mode: "all",
- qualified: "",
- items: [], // [{ item_id, name, qty }] — Phase 2
- places: [],
- };
- }
- const rounds = ref([createRound(1), createRound(2)]);
- function renumberRounds() {
- rounds.value.forEach((r, i) => { r.round_no = i + 1; });
- }
- function addRound() {
- if (rounds.value.length >= 5) return;
- rounds.value.push(createRound(rounds.value.length + 1));
- }
- function removeRound(idx) {
- if (rounds.value.length <= 2) return;
- rounds.value.splice(idx, 1);
- renumberRounds();
- }
- function changePlaceMode(round, mode) {
- round.place_mode = mode;
- // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
- if (mode === "specific" && round.places.length === 0) {
- round.places.push(createPlace());
- }
- }
- function addPlace(round) {
- round.places.push(createPlace());
- }
- function removePlace(round, idx) {
- round.places.splice(idx, 1);
- // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
- if (round.places.length === 0) {
- round.places.push(createPlace());
- }
- }
- // ============================
- // 장소(선상+낚시터) 검색 드롭다운
- // ============================
- // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
- const placeKey = (p) => `${p._placeType}-${p.id}`;
- const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
- const placeNameByKey = (k) => placeByKey(k)?.name || "?";
- const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
- function closeAllDropdowns() {
- rounds.value.forEach((r) =>
- r.places.forEach((p) => { p.dropdownOpen = false; })
- );
- }
- function openDropdown(place) {
- closeAllDropdowns();
- place.tempSelected = [...place.onboards];
- place.dropdownOpen = true;
- }
- function filteredPlaces(place) {
- return placesAll.value.filter((p) => {
- if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
- if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
- if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
- if (place.searchKeyword) {
- const kw = place.searchKeyword.toLowerCase();
- if (!String(p.name || "").toLowerCase().includes(kw)) return false;
- }
- return true;
- });
- }
- function togglePlaceInTemp(place, key) {
- const idx = place.tempSelected.indexOf(key);
- if (idx === -1) place.tempSelected.push(key);
- else place.tempSelected.splice(idx, 1);
- }
- function isAllFilteredSelected(place) {
- const filtered = filteredPlaces(place);
- if (filtered.length === 0) return false;
- return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
- }
- function toggleAll(place) {
- const filtered = filteredPlaces(place);
- const filteredKeys = filtered.map(placeKey);
- if (isAllFilteredSelected(place)) {
- const set = new Set(filteredKeys);
- place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
- } else {
- const merged = new Set([...place.tempSelected, ...filteredKeys]);
- place.tempSelected = [...merged];
- }
- }
- // 지역별 그룹화: [{area, items: [...]}, ...]
- function groupedFilteredPlaces(place) {
- const filtered = filteredPlaces(place);
- const map = new Map();
- filtered.forEach((p) => {
- const area = p.area_name || "미분류";
- if (!map.has(area)) map.set(area, []);
- map.get(area).push(p);
- });
- return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
- }
- function isAllInGroupSelected(place, items) {
- if (!items || items.length === 0) return false;
- return items.every((p) => place.tempSelected.includes(placeKey(p)));
- }
- function toggleAllInGroup(place, items) {
- const keys = items.map(placeKey);
- if (isAllInGroupSelected(place, items)) {
- const set = new Set(keys);
- place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
- } else {
- const merged = new Set([...place.tempSelected, ...keys]);
- place.tempSelected = [...merged];
- }
- }
- function applyDropdown(place) {
- place.onboards = [...place.tempSelected];
- place.dropdownOpen = false;
- }
- function removePlaceChip(place, key) {
- place.onboards = place.onboards.filter((k) => k !== key);
- }
- // 칩 표시용 — 같은 지역의 모든 장소가 선택됐으면 "○○ 전체" 그룹 칩으로 묶음
- function displayChips(place) {
- const selectedKeys = new Set(place.onboards);
- const groupedAll = new Map();
- placesAll.value.forEach((p) => {
- const area = p.area_name || "미분류";
- if (!groupedAll.has(area)) groupedAll.set(area, []);
- groupedAll.get(area).push(p);
- });
- const result = [];
- const processedKeys = new Set();
- for (const [area, places] of groupedAll.entries()) {
- if (places.length < 2) continue;
- const groupKeys = places.map(placeKey);
- const allSelected = groupKeys.every((k) => selectedKeys.has(k));
- if (allSelected) {
- result.push({
- key: `group:${area}`,
- type: "group",
- label: `${area} 전체`,
- icon: "📍",
- keys: groupKeys,
- });
- groupKeys.forEach((k) => processedKeys.add(k));
- }
- }
- for (const key of place.onboards) {
- if (processedKeys.has(key)) continue;
- result.push({
- key,
- type: "single",
- label: placeNameByKey(key),
- icon: placeTypeByKey(key) === "onboard" ? "🚤" : "🎣",
- keys: [key],
- });
- }
- return result;
- }
- function removeChipFromPlace(place, chip) {
- const keysToRemove = new Set(chip.keys);
- place.onboards = place.onboards.filter((k) => !keysToRemove.has(k));
- }
- // 외부 클릭 시 모든 드롭다운 닫기
- function handleDocumentClick() {
- closeAllDropdowns();
- }
- // ============================
- // 아이템 선택 모달
- // ============================
- const itemModal = ref({
- isOpen: false,
- target: null, // round 또는 place 객체 (둘 다 .items 배열 가짐)
- tempSelected: [], // 임시 선택된 item id 배열
- searchKeyword: "",
- });
- function openItemModal(target) {
- itemModal.value.target = target;
- itemModal.value.tempSelected = target.items.map((i) => i.item_id);
- itemModal.value.searchKeyword = "";
- itemModal.value.isOpen = true;
- }
- function closeItemModal() {
- itemModal.value.isOpen = false;
- itemModal.value.target = null;
- itemModal.value.tempSelected = [];
- itemModal.value.searchKeyword = "";
- }
- function toggleItemInModal(itemId) {
- const idx = itemModal.value.tempSelected.indexOf(itemId);
- if (idx === -1) itemModal.value.tempSelected.push(itemId);
- else itemModal.value.tempSelected.splice(idx, 1);
- }
- function filteredItems() {
- if (!itemModal.value.searchKeyword) return itemsAll.value;
- const kw = itemModal.value.searchKeyword.toLowerCase();
- return itemsAll.value.filter((i) =>
- String(i.name || "").toLowerCase().includes(kw)
- );
- }
- function applyItemModal() {
- const target = itemModal.value.target;
- if (!target) return;
- target.items = itemModal.value.tempSelected.map((id) => {
- const it = itemsAll.value.find((x) => x.id === id);
- return {
- item_id: id,
- name: it?.name || "?",
- type: it?.type || "",
- point: it?.point ?? null,
- };
- });
- closeItemModal();
- }
- // ============================
- // 데이터 로드
- // ============================
- async function loadOptions() {
- try {
- const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
- get("/field/list", { params: { per_page: 1000 } }),
- get("/area/list", { params: { per_page: 1000 } }),
- get("/onboard/list", { params: { per_page: 1000 } }),
- get("/fishing/list", { params: { per_page: 1000 } }),
- get("/item/list", { params: { per_page: 1000, status: "Y" } }),
- ]);
- if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
- if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
- // 선상 + 낚시터 통합 (_placeType으로 구분)
- const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
- .map((o) => ({ ...o, _placeType: "onboard" }));
- const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
- .map((f) => ({ ...f, _placeType: "fishing" }));
- placesAll.value = [...onboards, ...fishings];
- if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
- } catch (e) {
- console.error("Load options error:", e);
- }
- }
- // ============================
- // 임시저장 (draft)
- // ============================
- // 페이지 진입 시 임시저장 있는지 확인
- async function checkDraft() {
- try {
- const { data } = await get("/challenge/draft");
- if (data?.success && data.data) {
- draftData.value = data.data.data; // 응답의 data 컬럼 (이미 객체로 파싱됨)
- const ts = data.data.updated_at || data.data.created_at;
- draftSavedAt.value = ts
- ? new Date(String(ts).replace(" ", "T")).toLocaleString("ko-KR")
- : "";
- showDraftModal.value = true;
- }
- } catch (e) {
- console.error("[Draft] 조회 실패:", e);
- }
- }
- // 임시저장 불러오기 (모달 확인 후)
- function loadDraft() {
- showDraftModal.value = false;
- const d = draftData.value;
- if (!d) return;
- formData.value = {
- name: d.name || "",
- fee: d.fee || "",
- max_participants: d.max_participants || "",
- status_YN: d.status_YN || "Y",
- description: d.description || "",
- };
- startDate.value = d.start_date || "";
- endDate.value = d.end_date || "";
- isFree.value = !!d.is_free;
- if (Array.isArray(d.rounds) && d.rounds.length >= 2) {
- rounds.value = d.rounds.map((r) => {
- const round = createRound(r.round_no);
- round.place_mode = r.place_mode || "all";
- round.qualified = String(r.qualified || "");
- round.items = (r.items || []).map((it) => ({ ...it }));
- round.places = (r.places || []).map((p) => {
- const place = createPlace();
- place.field_id = p.field_id || "";
- place.area_id = p.area_id || "";
- place.partnership_YN = p.partnership_YN || "";
- place.onboards = [...(p.onboards || [])]; // 키 배열 그대로
- place.items = (p.items || []).map((it) => ({ ...it }));
- return place;
- });
- return round;
- });
- }
- successMessage.value = "임시저장을 불러왔습니다.";
- }
- // 임시저장 삭제하고 새로 작성
- async function discardDraft() {
- showDraftModal.value = false;
- try {
- await del("/challenge/draft");
- } catch (e) {
- console.error("[Draft] 삭제 실패:", e);
- }
- draftData.value = null;
- }
- // 임시저장 버튼 핸들러
- async function handleSaveDraft() {
- errorMessage.value = "";
- successMessage.value = "";
- if (!formData.value.name.trim()) {
- return (errorMessage.value = "임시저장하려면 최소한 챌린지명은 입력하세요.");
- }
- isSavingDraft.value = true;
- try {
- const payload = {
- name: formData.value.name,
- fee: formData.value.fee,
- max_participants: formData.value.max_participants,
- status_YN: formData.value.status_YN,
- description: formData.value.description,
- start_date: startDate.value,
- end_date: endDate.value,
- is_free: isFree.value,
- rounds: rounds.value.map((r) => ({
- round_no: r.round_no,
- place_mode: r.place_mode,
- qualified: r.qualified,
- items: r.items.map((it) => ({
- item_id: it.item_id, name: it.name, type: it.type, point: it.point,
- })),
- places: r.places.map((p) => ({
- field_id: p.field_id,
- area_id: p.area_id,
- partnership_YN: p.partnership_YN,
- onboards: [...p.onboards],
- items: p.items.map((it) => ({
- item_id: it.item_id, name: it.name, type: it.type, point: it.point,
- })),
- })),
- })),
- };
- const { data, error } = await post("/challenge/draft", payload);
- if (error || !data?.success) {
- errorMessage.value = error?.message || data?.message || "임시저장 실패";
- return;
- }
- successMessage.value = "임시저장되었습니다.";
- } catch (e) {
- console.error("[Draft] 저장 실패:", e);
- errorMessage.value = "서버 오류가 발생했습니다.";
- } finally {
- isSavingDraft.value = false;
- }
- }
- // ============================
- // 폼 제출
- // ============================
- async function handleSubmit() {
- errorMessage.value = "";
- successMessage.value = "";
- // 프론트 1차 검증
- if (!formData.value.name.trim()) return (errorMessage.value = "챌린지명을 입력하세요.");
- if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
- if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
- if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
- if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
- for (let i = 0; i < rounds.value.length; i++) {
- const r = rounds.value[i];
- if (!r.qualified) {
- return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
- }
- if (r.place_mode === "specific") {
- if (r.places.length === 0) {
- return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
- }
- for (let j = 0; j < r.places.length; j++) {
- if (r.places[j].onboards.length === 0) {
- return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
- }
- }
- }
- }
- isSaving.value = true;
- try {
- const payload = {
- name: formData.value.name,
- fee: formData.value.fee,
- start_date: startDate.value,
- end_date: endDate.value,
- max_participants: Number(formData.value.max_participants),
- description: formData.value.description,
- status_YN: formData.value.status_YN,
- rounds: rounds.value.map((r) => ({
- round_no: r.round_no,
- place_mode: r.place_mode,
- qualified: Number(r.qualified),
- items: r.place_mode === "all"
- ? r.items.map((it) => ({ item_id: it.item_id }))
- : [],
- places: r.place_mode === "specific"
- ? r.places.map((p) => ({
- // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
- onboards: p.onboards.map((key) => {
- const i = key.indexOf("-");
- return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
- }),
- items: p.items.map((it) => ({ item_id: it.item_id })),
- }))
- : [],
- })),
- };
- const { data, error } = await post("/challenge", payload);
- if (error || !data?.success) {
- errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
- return;
- }
- const newId = data.data?.id;
- // 이미지가 있으면 업로드 (챌린지 id 받은 뒤)
- if (newId && image.value) {
- const fd = new FormData();
- fd.append("image", image.value.file);
- const { data: imgRes, error: imgErr } = await upload(`/challenge/${newId}/image`, fd);
- if (imgErr || !imgRes?.success) {
- errorMessage.value = "챌린지는 등록됐지만 이미지 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
- setTimeout(() => router.push("/site-manager/challenge/list"), 1500);
- return;
- }
- }
- // 정식 등록 성공 → 임시저장 삭제
- try { await del("/challenge/draft"); } catch (_) { /* noop */ }
- successMessage.value = data.message || "챌린지가 등록되었습니다.";
- setTimeout(() => {
- router.push("/site-manager/challenge/list");
- }, 1000);
- } catch (e) {
- errorMessage.value = "서버 오류가 발생했습니다.";
- console.error("Challenge save error:", e);
- } finally {
- isSaving.value = false;
- }
- }
- const goToList = () => router.push("/site-manager/challenge/list");
- onMounted(async () => {
- document.addEventListener("click", handleDocumentClick);
- await loadOptions();
- // 옵션 로드 후 임시저장 확인 (불러올 때 onboards 매핑 안전)
- await checkDraft();
- });
- onBeforeUnmount(() => {
- document.removeEventListener("click", handleDocumentClick);
- });
- </script>
|