|
@@ -1,25 +1,31 @@
|
|
|
<template>
|
|
<template>
|
|
|
<div class="admin--field-list">
|
|
<div class="admin--field-list">
|
|
|
- <!-- 상단 검색/액션 영역 -->
|
|
|
|
|
|
|
+ <!-- 상단 액션 영역 -->
|
|
|
<div class="admin--search-box">
|
|
<div class="admin--search-box">
|
|
|
- <div class="admin--search-form">
|
|
|
|
|
- <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
|
|
|
|
|
- <option value="">전체</option>
|
|
|
|
|
- <option value="Y">사용중</option>
|
|
|
|
|
- <option value="N">미사용</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">
|
|
<div class="admin--search-actions">
|
|
|
- <button class="admin--btn-add" @click="goToCreate">+ 새 분야 추가</button>
|
|
|
|
|
|
|
+ <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
|
|
|
|
|
+ class="admin--btn-small admin--btn-small-danger"
|
|
|
|
|
+ :disabled="!hasChanges || isSaving"
|
|
|
|
|
+ @click="bulkSave"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ isSaving ? "저장 중..." : `일괄 저장${hasChanges ? ` (${changeCount})` : ""}` }}
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -28,49 +34,158 @@
|
|
|
<table class="admin--table">
|
|
<table class="admin--table">
|
|
|
<thead>
|
|
<thead>
|
|
|
<tr>
|
|
<tr>
|
|
|
- <th style="width: 80px;">번호</th>
|
|
|
|
|
- <th style="width: 40%;">분야</th>
|
|
|
|
|
- <th>가중치</th>
|
|
|
|
|
- <th>등록일</th>
|
|
|
|
|
- <th>상태</th>
|
|
|
|
|
- <th style="width: 120px;">관리</th>
|
|
|
|
|
|
|
+ <th style="width: 80px;">
|
|
|
|
|
+ <div class="input--wrap">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ :checked="isAllSelected"
|
|
|
|
|
+ :indeterminate.prop="isPartialSelected"
|
|
|
|
|
+ @change="toggleAll($event.target.checked)"
|
|
|
|
|
+ aria-label="전체 선택"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th style="">구분명</th>
|
|
|
|
|
+ <th style="">정렬순서</th>
|
|
|
|
|
+ <th style="width: 10%;">상태</th>
|
|
|
|
|
+ <th style="width: 10%">등록일</th>
|
|
|
|
|
+ <th style="width: 10%">관리</th>
|
|
|
</tr>
|
|
</tr>
|
|
|
</thead>
|
|
</thead>
|
|
|
<tbody>
|
|
<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>
|
|
|
|
|
+ <input
|
|
|
|
|
+ v-model.number="n.sort_order"
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min="0"
|
|
|
|
|
+ class="admin--form-input admin--inline-input"
|
|
|
|
|
+ @keyup.enter="bulkSave"
|
|
|
|
|
+ />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td @click.stop>
|
|
|
|
|
+ <select v-model="n.status_YN" class="admin--form-select admin--inline-input">
|
|
|
|
|
+ <option value="Y">사용중</option>
|
|
|
|
|
+ <option value="N">미사용</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="date">{{ todayLabel }}</td>
|
|
|
|
|
+ <td @click.stop>
|
|
|
|
|
+ <button class="admin--btn-small admin--btn-small-secondary" @click="removeNewRow(n._tempId)">제거</button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+
|
|
|
<tr v-if="isLoading">
|
|
<tr v-if="isLoading">
|
|
|
<td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
|
|
<td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
|
|
|
</tr>
|
|
</tr>
|
|
|
- <tr v-else-if="!fields || fields.length === 0">
|
|
|
|
|
- <td colspan="6" class="admin--table-empty">등록된 낚시분야가 없습니다.</td>
|
|
|
|
|
|
|
+ <tr v-else-if="!displayedItems || displayedItems.length === 0">
|
|
|
|
|
+ <td colspan="6" class="admin--table-empty" v-if="newRows.length === 0">등록된 어종구분이 없습니다.</td>
|
|
|
</tr>
|
|
</tr>
|
|
|
<tr
|
|
<tr
|
|
|
v-else
|
|
v-else
|
|
|
- v-for="(field, index) in fields"
|
|
|
|
|
- :key="field.id"
|
|
|
|
|
- class="admin--table-row-clickable"
|
|
|
|
|
- @click="goToDetail(field.id)"
|
|
|
|
|
|
|
+ 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 class="date">{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
|
|
|
|
|
- <td class="admin--table-title">{{ field.name }}</td>
|
|
|
|
|
- <td class="color--yellow">{{ field.weight }}</td>
|
|
|
|
|
- <td class="date">{{ formatDate(field.created_at) }}</td>
|
|
|
|
|
- <td>
|
|
|
|
|
- <span :class="['admin--badge', getStatusBadgeClass(field.status_YN)]">
|
|
|
|
|
- {{ getStatusLabel(field.status_YN) }}
|
|
|
|
|
- </span>
|
|
|
|
|
- </td>
|
|
|
|
|
- <td>
|
|
|
|
|
- <div class="admin--table-actions">
|
|
|
|
|
- <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(field.id)">
|
|
|
|
|
- 수정
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <td @click.stop>
|
|
|
|
|
+ <div class="input--wrap">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ :value="item.id"
|
|
|
|
|
+ v-model="selectedIds"
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
</td>
|
|
</td>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 수정 모드 -->
|
|
|
|
|
+ <template v-if="editing[item.id]">
|
|
|
|
|
+ <td @click.stop>
|
|
|
|
|
+ <input
|
|
|
|
|
+ v-model="editing[item.id].name"
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ class="admin--form-input admin--inline-input"
|
|
|
|
|
+ @keyup.enter="bulkSave"
|
|
|
|
|
+ />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td @click.stop>
|
|
|
|
|
+ <input
|
|
|
|
|
+ v-model.number="editing[item.id].sort_order"
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min="0"
|
|
|
|
|
+ class="admin--form-input admin--inline-input"
|
|
|
|
|
+ @keyup.enter="bulkSave"
|
|
|
|
|
+ />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td @click.stop>
|
|
|
|
|
+ <select v-model="editing[item.id].status_YN" class="admin--form-select admin--inline-input">
|
|
|
|
|
+ <option value="Y">사용중</option>
|
|
|
|
|
+ <option value="N">미사용</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="date">{{ formatDate(item.created_at) }}</td>
|
|
|
|
|
+ <td @click.stop>
|
|
|
|
|
+ <button class="admin--btn-small admin--btn-small-secondary" @click="cancelEdit(item.id)">취소</button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 일반 모드 -->
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <td class="admin--table-title">{{ item.name }}</td>
|
|
|
|
|
+ <td>{{ item.sort_order }}</td>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <span :class="['admin--badge', getStatusBadgeClass(item.status_YN)]">
|
|
|
|
|
+ {{ getStatusLabel(item.status_YN) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="date">{{ formatDate(item.created_at) }}</td>
|
|
|
|
|
+ <td></td>
|
|
|
|
|
+ </template>
|
|
|
</tr>
|
|
</tr>
|
|
|
</tbody>
|
|
</tbody>
|
|
|
</table>
|
|
</table>
|
|
|
</div>
|
|
</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">
|
|
<div v-if="totalPages > 1" class="admin--pagination">
|
|
|
<button
|
|
<button
|
|
@@ -79,157 +194,301 @@
|
|
|
:disabled="currentPage === 1"
|
|
:disabled="currentPage === 1"
|
|
|
@click="changePage(1)"
|
|
@click="changePage(1)"
|
|
|
title="처음"
|
|
title="처음"
|
|
|
- >
|
|
|
|
|
- ◀◀
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ >◀◀</button>
|
|
|
<button
|
|
<button
|
|
|
class="admin--pagination-btn"
|
|
class="admin--pagination-btn"
|
|
|
:disabled="currentPage === 1"
|
|
:disabled="currentPage === 1"
|
|
|
@click="changePage(currentPage - 1)"
|
|
@click="changePage(currentPage - 1)"
|
|
|
title="이전"
|
|
title="이전"
|
|
|
- >
|
|
|
|
|
- ◀
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ >◀</button>
|
|
|
<button
|
|
<button
|
|
|
v-for="page in visiblePages"
|
|
v-for="page in visiblePages"
|
|
|
:key="page"
|
|
:key="page"
|
|
|
class="admin--pagination-btn"
|
|
class="admin--pagination-btn"
|
|
|
:class="{ 'is-active': page === currentPage }"
|
|
:class="{ 'is-active': page === currentPage }"
|
|
|
@click="changePage(page)"
|
|
@click="changePage(page)"
|
|
|
- >
|
|
|
|
|
- {{ page }}
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ >{{ page }}</button>
|
|
|
<button
|
|
<button
|
|
|
class="admin--pagination-btn"
|
|
class="admin--pagination-btn"
|
|
|
:disabled="currentPage === totalPages"
|
|
:disabled="currentPage === totalPages"
|
|
|
@click="changePage(currentPage + 1)"
|
|
@click="changePage(currentPage + 1)"
|
|
|
title="다음"
|
|
title="다음"
|
|
|
- >
|
|
|
|
|
- ▶
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ >▶</button>
|
|
|
<button
|
|
<button
|
|
|
v-if="totalPages > 2"
|
|
v-if="totalPages > 2"
|
|
|
class="admin--pagination-btn"
|
|
class="admin--pagination-btn"
|
|
|
:disabled="currentPage === totalPages"
|
|
:disabled="currentPage === totalPages"
|
|
|
@click="changePage(totalPages)"
|
|
@click="changePage(totalPages)"
|
|
|
title="끝"
|
|
title="끝"
|
|
|
- >
|
|
|
|
|
- ▶▶
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ >▶▶</button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { ref, computed, onMounted } from "vue";
|
|
|
- import { useRouter } from "vue-router";
|
|
|
|
|
|
|
+ import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
|
|
|
|
|
|
|
|
definePageMeta({
|
|
definePageMeta({
|
|
|
layout: "admin",
|
|
layout: "admin",
|
|
|
middleware: ["auth"],
|
|
middleware: ["auth"],
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- const router = useRouter();
|
|
|
|
|
- const { get } = useApi();
|
|
|
|
|
|
|
+ const { get, post } = useApi();
|
|
|
|
|
|
|
|
const isLoading = ref(false);
|
|
const isLoading = ref(false);
|
|
|
- const fields = ref([]);
|
|
|
|
|
|
|
+ const isSaving = ref(false);
|
|
|
|
|
+ const items = ref([]);
|
|
|
const currentPage = ref(1);
|
|
const currentPage = ref(1);
|
|
|
const perPage = ref(10);
|
|
const perPage = ref(10);
|
|
|
const totalCount = ref(0);
|
|
const totalCount = ref(0);
|
|
|
const totalPages = ref(0);
|
|
const totalPages = ref(0);
|
|
|
|
|
|
|
|
- const searchQuery = ref("");
|
|
|
|
|
- const filterStatus = 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([]); // [{ _tempId, name, sort_order, status_YN }]
|
|
|
|
|
+ let tempIdCounter = 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 수정 중인 행들: { [id]: { name, sort_order, status_YN } }
|
|
|
|
|
+ const editing = ref({});
|
|
|
|
|
+
|
|
|
|
|
+ // 삭제 예정 ID들 (저장 시 일괄 처리)
|
|
|
|
|
+ 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 visiblePages = computed(() => {
|
|
|
const pages = [];
|
|
const pages = [];
|
|
|
const maxVisible = 5;
|
|
const maxVisible = 5;
|
|
|
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
|
|
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
|
|
|
let end = Math.min(totalPages.value, start + maxVisible - 1);
|
|
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);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
|
|
|
|
|
+ for (let i = start; i <= end; i++) pages.push(i);
|
|
|
return pages;
|
|
return pages;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 데이터 로드
|
|
// 데이터 로드
|
|
|
- const loadFields = async () => {
|
|
|
|
|
|
|
+ const loadItems = async () => {
|
|
|
isLoading.value = true;
|
|
isLoading.value = true;
|
|
|
-
|
|
|
|
|
- const params = {
|
|
|
|
|
- page: currentPage.value,
|
|
|
|
|
- per_page: perPage.value,
|
|
|
|
|
- };
|
|
|
|
|
- if (searchQuery.value) params.search = searchQuery.value;
|
|
|
|
|
- if (filterStatus.value) params.status = filterStatus.value;
|
|
|
|
|
-
|
|
|
|
|
- const { data, error } = await get("/field/list", { params });
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const { data, error } = await get("/species/list", {
|
|
|
|
|
+ params: { page: currentPage.value, per_page: perPage.value },
|
|
|
|
|
+ });
|
|
|
if (error) {
|
|
if (error) {
|
|
|
- console.error("[FieldList] 목록 로드 실패:", error);
|
|
|
|
|
- fields.value = [];
|
|
|
|
|
|
|
+ console.error("[SpeciesList] 목록 로드 실패:", error);
|
|
|
|
|
+ items.value = [];
|
|
|
totalCount.value = 0;
|
|
totalCount.value = 0;
|
|
|
totalPages.value = 0;
|
|
totalPages.value = 0;
|
|
|
} else if (data?.success && data?.data) {
|
|
} else if (data?.success && data?.data) {
|
|
|
- fields.value = data.data.items || [];
|
|
|
|
|
|
|
+ items.value = data.data.items || [];
|
|
|
totalCount.value = data.data.total || 0;
|
|
totalCount.value = data.data.total || 0;
|
|
|
totalPages.value = data.data.total_pages || 0;
|
|
totalPages.value = data.data.total_pages || 0;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
isLoading.value = false;
|
|
isLoading.value = false;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 검색
|
|
|
|
|
- const onSearch = () => {
|
|
|
|
|
- currentPage.value = 1;
|
|
|
|
|
- loadFields();
|
|
|
|
|
|
|
+ // 신규 행 추가
|
|
|
|
|
+ const addNewRow = () => {
|
|
|
|
|
+ dismissToast();
|
|
|
|
|
+ newRows.value.push({
|
|
|
|
|
+ _tempId: ++tempIdCounter,
|
|
|
|
|
+ name: "",
|
|
|
|
|
+ sort_order: 1,
|
|
|
|
|
+ status_YN: "Y",
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const removeNewRow = (tempId) => {
|
|
|
|
|
+ newRows.value = newRows.value.filter((r) => r._tempId !== tempId);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 수정 시작
|
|
|
|
|
+ const startEdit = (item) => {
|
|
|
|
|
+ dismissToast();
|
|
|
|
|
+ editing.value = {
|
|
|
|
|
+ ...editing.value,
|
|
|
|
|
+ [item.id]: { name: item.name, sort_order: item.sort_order, status_YN: item.status_YN },
|
|
|
|
|
+ };
|
|
|
|
|
+ };
|
|
|
|
|
+ const cancelEdit = (id) => {
|
|
|
|
|
+ const next = { ...editing.value };
|
|
|
|
|
+ delete next[id];
|
|
|
|
|
+ editing.value = next;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 검색 초기화
|
|
|
|
|
- const resetSearch = () => {
|
|
|
|
|
- searchQuery.value = "";
|
|
|
|
|
- filterStatus.value = "";
|
|
|
|
|
- currentPage.value = 1;
|
|
|
|
|
- loadFields();
|
|
|
|
|
|
|
+ // 모두 취소
|
|
|
|
|
+ const cancelAll = () => {
|
|
|
|
|
+ if (!hasChanges.value) return;
|
|
|
|
|
+ showConfirm(
|
|
|
|
|
+ "작성·수정·삭제 표시한 모든 변경사항을 취소하시겠습니까?",
|
|
|
|
|
+ () => {
|
|
|
|
|
+ newRows.value = [];
|
|
|
|
|
+ editing.value = {};
|
|
|
|
|
+ markedForDeleteIds.value = [];
|
|
|
|
|
+ dismissToast();
|
|
|
|
|
+ },
|
|
|
|
|
+ "변경 취소"
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 일괄 저장
|
|
|
|
|
+ const bulkSave = async () => {
|
|
|
|
|
+ dismissToast();
|
|
|
|
|
+ if (!hasChanges.value) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 프론트 측 기본 검증
|
|
|
|
|
+ const creates = newRows.value.map((n) => ({
|
|
|
|
|
+ name: (n.name || "").trim(),
|
|
|
|
|
+ sort_order: Number(n.sort_order) || 0,
|
|
|
|
|
+ status_YN: n.status_YN,
|
|
|
|
|
+ }));
|
|
|
|
|
+ const updates = Object.entries(editing.value).map(([id, v]) => ({
|
|
|
|
|
+ id: Number(id),
|
|
|
|
|
+ name: (v.name || "").trim(),
|
|
|
|
|
+ sort_order: Number(v.sort_order) || 0,
|
|
|
|
|
+ status_YN: v.status_YN,
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < creates.length; i++) {
|
|
|
|
|
+ if (!creates[i].name) return showToast(`신규 ${i + 1}행: 구분명을 입력하세요.`, "error");
|
|
|
|
|
+ if (creates[i].name.length > 30) return showToast(`신규 ${i + 1}행: 구분명 30자 이내`, "error");
|
|
|
|
|
+ if (creates[i].sort_order < 0) return showToast(`신규 ${i + 1}행: 정렬순서 0 이상`, "error");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (let i = 0; i < updates.length; i++) {
|
|
|
|
|
+ if (!updates[i].name) return showToast(`수정 ${i + 1}행: 구분명을 입력하세요.`, "error");
|
|
|
|
|
+ if (updates[i].name.length > 30) return showToast(`수정 ${i + 1}행: 구분명 30자 이내`, "error");
|
|
|
|
|
+ if (updates[i].sort_order < 0) return showToast(`수정 ${i + 1}행: 정렬순서 0 이상`, "error");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const deletes = [...markedForDeleteIds.value];
|
|
|
|
|
+
|
|
|
|
|
+ isSaving.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { data, error } = await post("/species/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;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 선택 삭제 — 즉시 서버 호출 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) => {
|
|
const changePage = (page) => {
|
|
|
if (page < 1 || page > totalPages.value) return;
|
|
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;
|
|
currentPage.value = page;
|
|
|
- loadFields();
|
|
|
|
|
|
|
+ loadItems();
|
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 이동
|
|
|
|
|
- const goToCreate = () => router.push("/site-manager/field/create");
|
|
|
|
|
- const goToDetail = (id) => router.push(`/site-manager/field/detail/${id}`);
|
|
|
|
|
- const goToEdit = (id) => router.push(`/site-manager/field/edit/${id}`);
|
|
|
|
|
-
|
|
|
|
|
- // 상태 라벨 / 뱃지 클래스
|
|
|
|
|
|
|
+ // 라벨/뱃지
|
|
|
const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
|
|
const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
|
|
|
- const getStatusBadgeClass = (status) =>
|
|
|
|
|
- status === "Y" ? "admin--badge-active" : "admin--badge-ended";
|
|
|
|
|
|
|
+ const getStatusBadgeClass = (status) => (status === "Y" ? "admin--badge-active" : "admin--badge-ended");
|
|
|
|
|
|
|
|
- // 날짜 포맷
|
|
|
|
|
const formatDate = (dateString) => {
|
|
const formatDate = (dateString) => {
|
|
|
if (!dateString) return "-";
|
|
if (!dateString) return "-";
|
|
|
const date = new Date(dateString.replace(" ", "T"));
|
|
const date = new Date(dateString.replace(" ", "T"));
|
|
|
if (isNaN(date.getTime())) return dateString;
|
|
if (isNaN(date.getTime())) return dateString;
|
|
|
- return date.toLocaleDateString("ko-KR", {
|
|
|
|
|
- year: "numeric",
|
|
|
|
|
- month: "2-digit",
|
|
|
|
|
- day: "2-digit",
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ return date.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
- loadFields();
|
|
|
|
|
|
|
+ loadItems();
|
|
|
});
|
|
});
|
|
|
</script>
|
|
</script>
|