| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586 |
- <template>
- <div class="admin--field-list">
- <!-- 상단 액션 영역 -->
- <div class="admin--search-box type2">
- <div class="admin--search--inner--box">
- <div class="admin--search-form">
- <select v-model="filterTypeId" @change="onSearch" class="admin--form-select admin--search-select">
- <option value="">전체</option>
- <option value="null">구분 없음</option>
- <option v-for="t in typeOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
- </select>
- <input
- v-model="searchQuery"
- type="text"
- placeholder="어종명 검색"
- @keyup.enter="onSearch"
- class="admin--form-input admin--search-input"
- />
- <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
- <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
- </div>
- <div class="admin--search-actions">
- <button
- v-if="hasChanges"
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="isSaving"
- @click="cancelAll"
- >모두 취소</button>
- <button
- class="admin--btn-small admin--btn-small-secondary"
- :disabled="selectedIds.length === 0"
- @click="bulkDelete"
- >
- 선택 삭제<span v-if="selectedIds.length"> ({{ selectedIds.length }})</span>
- </button>
- <button class="admin--btn-small admin--btn-small-primary" @click="addNewRow">+ 어종 추가</button>
- <button
- v-if="hasChanges || isSaving"
- class="admin--btn-small admin--btn-small-danger"
- :disabled="isSaving"
- @click="bulkSave"
- >
- {{ isSaving ? "저장 중..." : `일괄 저장 (${changeCount})` }}
- </button>
- </div>
- </div>
- <div class="admin--search--inner--box">
- <div class="admin--search-form">
- <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
- <span class="admin--date-separator">-</span>
- <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
- <div class="admin--quick-range">
- <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('today')">오늘</button>
- <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('7d')">7일</button>
- <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('15d')">15일</button>
- <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1m')">1개월</button>
- <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('3m')">3개월</button>
- <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1y')">1년</button>
- </div>
- </div>
- </div>
- </div>
- <p class="admin--table--top--text">길이 기준 : CM</p>
- <div class="admin--table-wrapper">
- <table class="admin--table fish--table">
- <thead>
- <tr>
- <th style="width: 36px;">
- <div class="input--wrap">
- <input
- type="checkbox"
- :checked="isAllSelected"
- :indeterminate.prop="isPartialSelected"
- @change="toggleAll($event.target.checked)"
- aria-label="전체 선택"
- />
- </div>
- </th>
- <th>어종</th>
- <th style="width: 100px;">구분</th>
- <th style="width: 68px;">최소금지</th>
- <th style="width: 68px;">최대길이</th>
- <th style="width: 140px;">1라운드</th>
- <th style="width: 140px;">2라운드</th>
- <th style="width: 140px;">3라운드</th>
- <th style="width: 140px;">4라운드</th>
- <th style="width: 140px;">5라운드</th>
- <th style="width: 90px;">등록일</th>
- <th style="width: 60px;">관리</th>
- </tr>
- </thead>
- <tbody>
- <!-- 신규 행 -->
- <tr v-for="n in newRows" :key="'new-' + n._tempId" class="admin--table-row-new">
- <td></td>
- <td @click.stop>
- <input v-model="n.name" type="text" class="admin--form-input admin--inline-input" placeholder="어종명" @keyup.enter="bulkSave" />
- </td>
- <td @click.stop>
- <select v-model="n.type_id" class="admin--form-select admin--inline-input">
- <option value="">선택</option>
- <option v-for="t in typeOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
- </select>
- </td>
- <td @click.stop>
- <input v-model.number="n.min" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
- </td>
- <td @click.stop>
- <input v-model.number="n.max" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
- </td>
- <td v-for="r in 5" :key="'r' + r + 'new' + n._tempId" @click.stop>
- <div class="admin--range-cell">
- <input v-model.number="n[`round${r}_min`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="min" @input="limitDigits" @keyup.enter="bulkSave" />
- <span>~</span>
- <input v-model.number="n[`round${r}_max`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="max" @input="limitDigits" @keyup.enter="bulkSave" />
- </div>
- </td>
- <td class="date">{{ todayLabel }}</td>
- <td @click.stop>
- <button class="admin--btn-small mw--0 admin--btn-small-secondary" @click="removeNewRow(n._tempId)">제거</button>
- </td>
- </tr>
- <tr v-if="isLoading">
- <td colspan="12" class="admin--table-loading">데이터를 불러오는 중...</td>
- </tr>
- <tr v-else-if="!displayedItems || displayedItems.length === 0">
- <td colspan="12" class="admin--table-empty" v-if="newRows.length === 0">등록된 어종이 없습니다.</td>
- </tr>
- <tr
- v-else
- v-for="item in displayedItems"
- :key="item.id"
- :class="{
- 'admin--table-row-new': editing[item.id],
- 'admin--table-row-clickable': !editing[item.id],
- }"
- @click="!editing[item.id] && startEdit(item)"
- >
- <td @click.stop style="padding: 8px;">
- <div class="input--wrap">
- <input type="checkbox" :value="item.id" v-model="selectedIds" />
- </div>
- </td>
- <!-- 수정 모드 -->
- <template v-if="editing[item.id]">
- <td @click.stop style="padding: 8px;">
- <input v-model="editing[item.id].name" type="text" class="admin--form-input admin--inline-input" @keyup.enter="bulkSave" />
- </td>
- <td @click.stop>
- <select v-model="editing[item.id].type_id" class="admin--form-select admin--inline-input">
- <option value="">선택</option>
- <option v-for="t in typeOptions" :key="t.id" :value="t.id">{{ t.name }}</option>
- </select>
- </td>
- <td @click.stop>
- <input v-model.number="editing[item.id].min" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
- </td>
- <td @click.stop>
- <input v-model.number="editing[item.id].max" type="number" min="0" class="admin--form-input admin--inline-input center" @input="limitDigits" @keyup.enter="bulkSave" />
- </td>
- <td v-for="r in 5" :key="'r' + r + 'edit' + item.id" @click.stop>
- <div class="admin--range-cell">
- <input v-model.number="editing[item.id][`round${r}_min`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="min" @input="limitDigits" @keyup.enter="bulkSave" />
- <span>~</span>
- <input v-model.number="editing[item.id][`round${r}_max`]" type="number" min="0" class="admin--form-input admin--inline-input" placeholder="max" @input="limitDigits" @keyup.enter="bulkSave" />
- </div>
- </td>
- <td class="date">{{ formatDate(item.created_at) }}</td>
- <td @click.stop>
- <button class="admin--btn-small mw--0 admin--btn-small-secondary" @click="cancelEdit(item.id)">취소</button>
- </td>
- </template>
- <!-- 일반 모드 -->
- <template v-else>
- <td class="admin--table-title">{{ item.name }}</td>
- <td>{{ item.type_name || "-" }}</td>
- <td>{{ item.min }}</td>
- <td>{{ item.max }}</td>
- <td v-for="r in 5" :key="'r' + r + 'view' + item.id">
- {{ item[`round${r}_min`] }} ~ {{ item[`round${r}_max`] }}
- </td>
- <td class="date">{{ formatDate(item.created_at) }}</td>
- <td></td>
- </template>
- </tr>
- </tbody>
- </table>
- </div>
- <!-- 토스트 -->
- <Teleport to="body">
- <Transition name="admin--toast">
- <div v-if="toast.show" class="admin--toast" :class="{ 'is-error': toast.type === 'error' }">
- <span class="admin--toast-icon"></span>
- <span class="admin--toast-msg">{{ toast.message }}</span>
- <button class="admin--toast-close" @click="dismissToast">×</button>
- </div>
- </Transition>
- </Teleport>
- <!-- 알림 모달 -->
- <AdminAlertModal
- v-if="alertModal.show"
- :title="alertModal.title"
- :message="alertModal.message"
- :type="alertModal.type"
- @confirm="handleAlertConfirm"
- @cancel="handleAlertCancel"
- @close="closeAlertModal"
- />
- <!-- 페이지네이션 -->
- <div v-if="totalPages > 1" class="admin--pagination">
- <button v-if="totalPages > 2" class="admin--pagination-btn" :disabled="currentPage === 1" @click="changePage(1)" title="처음">◀◀</button>
- <button class="admin--pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)" title="이전">◀</button>
- <button v-for="page in visiblePages" :key="page" class="admin--pagination-btn" :class="{ 'is-active': page === currentPage }" @click="changePage(page)">{{ page }}</button>
- <button class="admin--pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)" title="다음">▶</button>
- <button v-if="totalPages > 2" class="admin--pagination-btn" :disabled="currentPage === totalPages" @click="changePage(totalPages)" title="끝">▶▶</button>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted } from "vue";
- import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
- import DatePicker from "~/components/admin/DatePicker.vue";
- definePageMeta({
- layout: "admin",
- middleware: ["auth"],
- });
- const { get, post } = useApi();
- const isLoading = ref(false);
- const isSaving = ref(false);
- const items = ref([]);
- const typeOptions = ref([]);
- const currentPage = ref(1);
- const perPage = ref(10);
- const totalCount = ref(0);
- const totalPages = ref(0);
- // 검색
- const searchQuery = ref("");
- const filterTypeId = ref("");
- const startDate = ref("");
- const endDate = ref("");
- // 토스트
- const toast = ref({ show: false, type: "success", message: "" });
- let toastTimer = null;
- const showToast = (message, type = "success", duration = type === "error" ? 4500 : 2500) => {
- if (toastTimer) clearTimeout(toastTimer);
- toast.value = { show: true, type, message };
- toastTimer = setTimeout(() => { toast.value.show = false; }, duration);
- };
- const dismissToast = () => {
- if (toastTimer) clearTimeout(toastTimer);
- toast.value.show = false;
- };
- // 신규/수정/삭제 상태
- const newRows = ref([]);
- let tempIdCounter = 0;
- const editing = ref({});
- const markedForDeleteIds = ref([]);
- const displayedItems = computed(() =>
- items.value.filter((it) => !markedForDeleteIds.value.includes(it.id))
- );
- const changeCount = computed(
- () => newRows.value.length + Object.keys(editing.value).length + markedForDeleteIds.value.length
- );
- const hasChanges = computed(() => changeCount.value > 0);
- // 오늘 라벨
- const todayLabel = computed(() => {
- const d = new Date();
- return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
- });
- // 체크박스
- const selectedIds = ref([]);
- const isAllSelected = computed(
- () => displayedItems.value.length > 0 && displayedItems.value.every((it) => selectedIds.value.includes(it.id))
- );
- const isPartialSelected = computed(() => selectedIds.value.length > 0 && !isAllSelected.value);
- const toggleAll = (checked) => {
- if (checked) {
- const pageIds = displayedItems.value.map((it) => it.id);
- selectedIds.value = Array.from(new Set([...selectedIds.value, ...pageIds]));
- } else {
- const pageIds = new Set(displayedItems.value.map((it) => it.id));
- selectedIds.value = selectedIds.value.filter((id) => !pageIds.has(id));
- }
- };
- // 알림 모달
- const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
- const showConfirm = (message, onConfirm, title = "확인") => {
- alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
- };
- const closeAlertModal = () => { alertModal.value.show = false; };
- const handleAlertConfirm = () => {
- if (alertModal.value.onConfirm) alertModal.value.onConfirm();
- closeAlertModal();
- };
- const handleAlertCancel = () => closeAlertModal();
- // 페이지 번호
- const visiblePages = computed(() => {
- const pages = [];
- const maxVisible = 5;
- let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
- let end = Math.min(totalPages.value, start + maxVisible - 1);
- if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
- for (let i = start; i <= end; i++) pages.push(i);
- return pages;
- });
- // YYYY-MM-DD 포맷
- const toYMD = (d) => {
- const y = d.getFullYear();
- const m = String(d.getMonth() + 1).padStart(2, "0");
- const day = String(d.getDate()).padStart(2, "0");
- return `${y}-${m}-${day}`;
- };
- // 빠른 기간 선택
- const setRange = (kind) => {
- const end = toYMD(new Date());
- const startDt = new Date();
- switch (kind) {
- case "today": break;
- case "7d": startDt.setDate(startDt.getDate() - 7); break;
- case "15d": startDt.setDate(startDt.getDate() - 15); break;
- case "1m": startDt.setMonth(startDt.getMonth() - 1); break;
- case "3m": startDt.setMonth(startDt.getMonth() - 3); break;
- case "1y": startDt.setFullYear(startDt.getFullYear() - 1); break;
- }
- startDate.value = toYMD(startDt);
- endDate.value = end;
- onSearch();
- };
- // 구분 옵션 로드 (species_type)
- const loadTypeOptions = async () => {
- const { data } = await get("/species/list", { params: { per_page: 1000 } });
- if (data?.success) typeOptions.value = data.data.items || [];
- };
- // 데이터 로드
- const loadItems = async () => {
- isLoading.value = true;
- const params = { page: currentPage.value, per_page: perPage.value };
- if (searchQuery.value) params.search = searchQuery.value;
- if (filterTypeId.value) params.type_id = filterTypeId.value;
- if (startDate.value) params.start_date = startDate.value;
- if (endDate.value) params.end_date = endDate.value;
- const { data, error } = await get("/species-challenge/list", { params });
- if (error) {
- items.value = []; totalCount.value = 0; totalPages.value = 0;
- } else if (data?.success && data?.data) {
- items.value = data.data.items || [];
- totalCount.value = data.data.total || 0;
- totalPages.value = data.data.total_pages || 0;
- }
- isLoading.value = false;
- };
- // 검색
- const onSearch = () => {
- currentPage.value = 1;
- loadItems();
- };
- const resetSearch = () => {
- searchQuery.value = "";
- filterTypeId.value = "";
- startDate.value = "";
- endDate.value = "";
- currentPage.value = 1;
- loadItems();
- };
- // 신규 행 추가
- const blankRow = () => ({
- _tempId: ++tempIdCounter,
- type_id: "",
- name: "",
- min: 0,
- max: 0,
- round1_min: 0, round1_max: 0,
- round2_min: 0, round2_max: 0,
- round3_min: 0, round3_max: 0,
- round4_min: 0, round4_max: 0,
- round5_min: 0, round5_max: 0,
- });
- const addNewRow = () => {
- dismissToast();
- newRows.value.push(blankRow());
- };
- const removeNewRow = (tempId) => {
- newRows.value = newRows.value.filter((r) => r._tempId !== tempId);
- };
- // 수정 시작
- const startEdit = (item) => {
- dismissToast();
- editing.value = {
- ...editing.value,
- [item.id]: {
- type_id: item.type_id ?? "",
- name: item.name ?? "",
- min: item.min ?? 0,
- max: item.max ?? 0,
- round1_min: item.round1_min ?? 0, round1_max: item.round1_max ?? 0,
- round2_min: item.round2_min ?? 0, round2_max: item.round2_max ?? 0,
- round3_min: item.round3_min ?? 0, round3_max: item.round3_max ?? 0,
- round4_min: item.round4_min ?? 0, round4_max: item.round4_max ?? 0,
- round5_min: item.round5_min ?? 0, round5_max: item.round5_max ?? 0,
- },
- };
- };
- const cancelEdit = (id) => {
- const next = { ...editing.value };
- delete next[id];
- editing.value = next;
- };
- // 모두 취소
- const cancelAll = () => {
- if (!hasChanges.value) return;
- showConfirm(
- "작성·수정·삭제 표시한 모든 변경사항을 취소하시겠습니까?",
- () => {
- newRows.value = [];
- editing.value = {};
- markedForDeleteIds.value = [];
- dismissToast();
- },
- "변경 취소"
- );
- };
- // number 인풋 자릿수 제한 — 입력 즉시 자르고 v-model 동기화
- const limitDigits = (e, max = 4) => {
- const v = e.target.value;
- if (v.length > max) {
- e.target.value = v.slice(0, max);
- e.target.dispatchEvent(new Event("input", { bubbles: true }));
- }
- };
- // 한 행 검증 (프론트) — 구분은 선택사항 (저장 시 별도 확인 모달로 한 번 더 물음)
- const validateRow = (row, label) => {
- const name = (row.name || "").trim();
- if (!name) return `${label}: 어종명을 입력하세요.`;
- if (name.length > 50) return `${label}: 어종명은 50자 이내`;
- const num = (v) => (v === null || v === "" || isNaN(Number(v))) ? null : Number(v);
- const min = num(row.min); const max = num(row.max);
- if (min === null) return `${label}: 최소금지를 입력하세요.`;
- if (max === null) return `${label}: 최대길이를 입력하세요.`;
- if (max < min) return `${label}: 최대길이는 최소금지 이상`;
- for (let r = 1; r <= 5; r++) {
- const rmin = num(row[`round${r}_min`]);
- const rmax = num(row[`round${r}_max`]);
- if (rmin === null) return `${label}: ${r}라운드 최소를 입력하세요.`;
- if (rmax === null) return `${label}: ${r}라운드 최대를 입력하세요.`;
- if (rmin > rmax) return `${label}: ${r}라운드 최소는 최대보다 작거나 같아야 합니다.`;
- if (rmax > max) return `${label}: ${r}라운드 최대는 최대길이(${max})보다 클 수 없습니다.`;
- }
- if (Number(row.round1_min) < min) return `${label}: 1라운드 최소는 최소금지(${min})보다 작을 수 없습니다.`;
- return null;
- };
- // 일괄 저장
- const bulkSave = async () => {
- dismissToast();
- if (!hasChanges.value) return;
- const creates = newRows.value.map((n) => ({ ...n }));
- const updates = Object.entries(editing.value).map(([id, v]) => ({ id: Number(id), ...v }));
- // 기본 검증 (구분 제외)
- for (let i = 0; i < creates.length; i++) {
- const err = validateRow(creates[i], `신규 ${i + 1}행`);
- if (err) return showToast(err, "error");
- }
- for (let i = 0; i < updates.length; i++) {
- const err = validateRow(updates[i], `수정 ${i + 1}행`);
- if (err) return showToast(err, "error");
- }
- const deletes = [...markedForDeleteIds.value];
- // 실제 서버 호출
- const doSave = async () => {
- isSaving.value = true;
- try {
- const { data, error } = await post("/species-challenge/bulk-save", { creates, updates, deletes });
- if (error || !data?.success) {
- showToast(error?.message || data?.message || "저장 실패", "error");
- return;
- }
- showToast(data.message || "저장되었습니다.", "success");
- newRows.value = [];
- editing.value = {};
- markedForDeleteIds.value = [];
- selectedIds.value = [];
- await loadItems();
- } catch (e) {
- showToast("서버 오류가 발생했습니다.", "error");
- console.error("Bulk save error:", e);
- } finally {
- isSaving.value = false;
- }
- };
- // 구분 미선택 행 카운트 → 확인 모달
- const missingCount = [...creates, ...updates].filter((r) => !r.type_id).length;
- if (missingCount > 0) {
- showConfirm(
- `${missingCount}개 행에 구분이 선택되지 않았습니다.\n그대로 저장하시겠습니까?`,
- doSave,
- "구분 미선택"
- );
- return;
- }
- await doSave();
- };
- // 선택 삭제 — 즉시 X, 일괄 저장 시 처리
- const bulkDelete = () => {
- if (selectedIds.value.length === 0) return;
- const ids = [...selectedIds.value];
- const nextEditing = { ...editing.value };
- ids.forEach((id) => { delete nextEditing[id]; });
- editing.value = nextEditing;
- markedForDeleteIds.value = Array.from(new Set([...markedForDeleteIds.value, ...ids]));
- selectedIds.value = [];
- dismissToast();
- };
- // 페이지 변경
- const changePage = (page) => {
- if (page < 1 || page > totalPages.value) return;
- if (hasChanges.value) {
- return showConfirm(
- "저장하지 않은 변경사항이 있습니다. 페이지를 이동하면 모두 사라집니다.",
- () => {
- newRows.value = [];
- editing.value = {};
- markedForDeleteIds.value = [];
- selectedIds.value = [];
- currentPage.value = page;
- loadItems();
- window.scrollTo({ top: 0, behavior: "smooth" });
- },
- "페이지 이동"
- );
- }
- currentPage.value = page;
- loadItems();
- window.scrollTo({ top: 0, behavior: "smooth" });
- };
- const formatDate = (dateString) => {
- if (!dateString) return "-";
- const date = new Date(dateString.replace(" ", "T"));
- if (isNaN(date.getTime())) return dateString;
- return date.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
- };
- onMounted(async () => {
- await loadTypeOptions();
- await loadItems();
- });
- </script>
|