Преглед на файлове

[어종구분] 기능 완료, 테이블 내 input 디자인 수정 필요

DESKTOP-T61HUSC\user преди 3 седмици
родител
ревизия
85cc5ff6a1
променени са 4 файла, в които са добавени 945 реда и са изтрити 114 реда
  1. 180 0
      app/assets/scss/admin.scss
  2. 373 114
      app/pages/site-manager/species/list.vue
  3. 7 0
      backend/app/Config/Routes.php
  4. 385 0
      backend/app/Controllers/Api/SpeciesController.php

+ 180 - 0
app/assets/scss/admin.scss

@@ -2246,6 +2246,12 @@ footer {
     gap: 8px;
   }
 
+  .admin--search-actions{
+    display: flex;
+    margin-left: auto;
+    gap: 12px;
+  }
+
   .admin--search--inner--box{
     display: flex;
     justify-content: space-between;
@@ -2381,9 +2387,62 @@ footer {
 }
 
 .admin--table {
+  // 테이블 안 체크박스 — SVG 체크/대시
+  input[type="checkbox"] {
+    appearance: none;
+    -webkit-appearance: none;
+    -moz-appearance: none;
+    width: 18px;
+    height: 18px;
+    margin: 0;
+    border: 1.5px solid #cbd5e0;
+    border-radius: 4px;
+    background-color: #fff;
+    background-position: center;
+    background-repeat: no-repeat;
+    background-size: 12px 12px;
+    cursor: pointer;
+    display: inline-block;
+    vertical-align: middle;
+    transition: background-color 0.15s ease, border-color 0.15s ease;
+    flex-shrink: 0;
+
+    &:hover {
+      border-color: var(--admin-accent-primary);
+    }
+
+    &:focus-visible {
+      outline: 2px solid rgba(26, 35, 50, 0.25);
+      outline-offset: 1px;
+    }
+
+    &:checked {
+      background-color: var(--admin-accent-primary);
+      border-color: var(--admin-accent-primary);
+      background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M3.5 8L6.75 11.25L12.5 5.25' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+    }
+
+    &:indeterminate {
+      background-color: var(--admin-accent-primary);
+      border-color: var(--admin-accent-primary);
+      background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 8h8' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
+    }
+
+    &:disabled {
+      opacity: 0.4;
+      cursor: not-allowed;
+    }
+  }
+
   width: 100%;
   border-collapse: collapse;
 
+  .input--wrap{
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
   thead {
     background: #F8F9FB;
     border-bottom: 1px solid #E8EAEF;
@@ -2396,6 +2455,7 @@ footer {
       color: #666b75;
       white-space: nowrap;
       letter-spacing: 0.36px;
+
     }
   }
 
@@ -2419,6 +2479,28 @@ footer {
           background: var(--admin-bg-primary);
         }
       }
+
+      &.admin--table-row-new {
+        background: #fffbe6;
+        td {
+          padding: 8px 12px;
+        }
+      }
+    }
+
+    // 인라인 input (테이블 셀 안에서 편집)
+    .admin--inline-input {
+      width: 100%;
+      height: 34px;
+      padding: 6px 10px !important;
+      font-size: 13px !important;
+      border: 1px solid #e8eaef !important;
+      border-radius: 4px;
+      background: #fff;
+      &:focus {
+        outline: none;
+        border-color: var(--admin-accent-primary) !important;
+      }
     }
 
     td {
@@ -2472,6 +2554,19 @@ footer {
       font-size: 12px;
     }
   }
+
+  input[type=checkbox]{
+    width: 20px;
+    height: 20px;
+    cursor: pointer;
+    border: 1px solid #c8d5e6;
+    border-radius: 3px;
+    background-color: #fff;
+    appearance: none;
+    &:checked{
+      
+    }
+  }
 }
 
 // 배지
@@ -7619,4 +7714,89 @@ footer {
   color: #fff;
   font-size: 11px;
   font-weight: 600;
+}
+
+// ============================================
+// Toast (어드민 공통 하단 중앙 알림)
+// ============================================
+.admin--toast {
+  position: fixed;
+  bottom: 40px;
+  left: 50%;
+  transform: translateX(-50%);
+  z-index: 10000;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 280px;
+  max-width: 480px;
+  padding: 14px 18px;
+  border-radius: 10px;
+  background: #ecfdf5;
+  color: #166534;
+  border: 1px solid #86efac;
+  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 1.4;
+
+  &.is-error {
+    background: #fef2f2;
+    color: #991b1b;
+    border-color: #fca5a5;
+  }
+
+  .admin--toast-icon {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 22px;
+    height: 22px;
+    border-radius: 50%;
+    background: currentColor;
+    color: #fff;
+    font-size: 13px;
+    font-weight: 700;
+    flex-shrink: 0;
+    &::before { content: '✓'; }
+  }
+  &.is-error .admin--toast-icon::before { content: '!'; }
+
+  .admin--toast-msg {
+    flex: 1;
+    color: inherit;
+  }
+
+  .admin--toast-close {
+    background: none;
+    border: none;
+    font-size: 20px;
+    line-height: 1;
+    cursor: pointer;
+    color: inherit;
+    opacity: 0.5;
+    padding: 0;
+    width: 22px;
+    height: 22px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: opacity 0.15s ease;
+    &:hover { opacity: 1; }
+  }
+}
+
+.admin--toast-enter-active,
+.admin--toast-leave-active {
+  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
+}
+.admin--toast-enter-from,
+.admin--toast-leave-to {
+  transform: translate(-50%, 140%);
+  opacity: 0;
+}
+.admin--toast-enter-to,
+.admin--toast-leave-from {
+  transform: translate(-50%, 0);
+  opacity: 1;
 }

+ 373 - 114
app/pages/site-manager/species/list.vue

@@ -1,25 +1,31 @@
 <template>
   <div class="admin--field-list">
-    <!-- 상단 검색/액션 영역 -->
+    <!-- 상단 액션 영역 -->
     <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">
-        <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>
 
@@ -28,49 +34,158 @@
       <table class="admin--table">
         <thead>
           <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>
         </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>
+              <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">
             <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
           </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
             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>
             </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>
         </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
@@ -79,157 +194,301 @@
         :disabled="currentPage === 1"
         @click="changePage(1)"
         title="처음"
-      >
-        ◀◀
-      </button>
+      >◀◀</button>
       <button
         class="admin--pagination-btn"
         :disabled="currentPage === 1"
         @click="changePage(currentPage - 1)"
         title="이전"
-      >
-        ◀
-      </button>
+      >◀</button>
       <button
         v-for="page in visiblePages"
         :key="page"
         class="admin--pagination-btn"
         :class="{ 'is-active': page === currentPage }"
         @click="changePage(page)"
-      >
-        {{ page }}
-      </button>
+      >{{ page }}</button>
       <button
         class="admin--pagination-btn"
         :disabled="currentPage === totalPages"
         @click="changePage(currentPage + 1)"
         title="다음"
-      >
-        ▶
-      </button>
+      >▶</button>
       <button
         v-if="totalPages > 2"
         class="admin--pagination-btn"
         :disabled="currentPage === totalPages"
         @click="changePage(totalPages)"
         title="끝"
-      >
-        ▶▶
-      </button>
+      >▶▶</button>
     </div>
   </div>
 </template>
 
 <script setup>
   import { ref, computed, onMounted } from "vue";
-  import { useRouter } from "vue-router";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
 
   definePageMeta({
     layout: "admin",
     middleware: ["auth"],
   });
 
-  const router = useRouter();
-  const { get } = useApi();
+  const { get, post } = useApi();
 
   const isLoading = ref(false);
-  const fields = ref([]);
+  const isSaving = ref(false);
+  const items = ref([]);
   const currentPage = ref(1);
   const perPage = ref(10);
   const totalCount = 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 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);
-    }
+    if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
+    for (let i = start; i <= end; i++) pages.push(i);
     return pages;
   });
 
   // 데이터 로드
-  const loadFields = async () => {
+  const loadItems = async () => {
     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) {
-      console.error("[FieldList] 목록 로드 실패:", error);
-      fields.value = [];
+      console.error("[SpeciesList] 목록 로드 실패:", error);
+      items.value = [];
       totalCount.value = 0;
       totalPages.value = 0;
     } else if (data?.success && data?.data) {
-      fields.value = data.data.items || [];
+      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;
-    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) => {
     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;
-    loadFields();
+    loadItems();
     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 getStatusBadgeClass = (status) =>
-    status === "Y" ? "admin--badge-active" : "admin--badge-ended";
+  const getStatusBadgeClass = (status) => (status === "Y" ? "admin--badge-active" : "admin--badge-ended");
 
-  // 날짜 포맷
   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",
-    });
+    return date.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
   };
 
   onMounted(() => {
-    loadFields();
+    loadItems();
   });
 </script>

+ 7 - 0
backend/app/Config/Routes.php

@@ -43,6 +43,13 @@ $routes->post('api/area', 'Api\FishingAreaController::create');
 $routes->put('api/area/(:num)', 'Api\FishingAreaController::update/$1');
 $routes->delete('api/area/(:num)', 'Api\FishingAreaController::delete/$1');
 
+// Species (어종구분)
+$routes->get('api/species/list', 'Api\SpeciesController::index');
+$routes->post('api/species', 'Api\SpeciesController::create');
+$routes->put('api/species/(:num)', 'Api\SpeciesController::update/$1');
+$routes->post('api/species/bulk-save', 'Api\SpeciesController::bulkSave');
+$routes->post('api/species/bulk-delete', 'Api\SpeciesController::bulkDelete');
+
 // Item (아이템)
 $routes->get('api/item/list', 'Api\ItemController::index');
 $routes->get('api/item/(:num)', 'Api\ItemController::show/$1');

+ 385 - 0
backend/app/Controllers/Api/SpeciesController.php

@@ -0,0 +1,385 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class SpeciesController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'species_type';
+
+    /**
+     * 어종구분 목록
+     * GET /api/species/list
+     */
+    public function index()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $page = (int) ($this->request->getGet('page') ?? 1);
+            $perPage = (int) ($this->request->getGet('per_page') ?? 10);
+            if ($page < 1) $page = 1;
+            if ($perPage < 1) $perPage = 10;
+            $offset = ($page - 1) * $perPage;
+
+            $search = trim((string) $this->request->getGet('search'));
+            $status = trim((string) $this->request->getGet('status'));
+
+            $db = $this->getDB();
+            $builder = $db->table($this->table)->where('deleted_YN', 'N');
+            if ($search !== '') $builder->like('name', $search);
+            if ($status === 'Y' || $status === 'N') $builder->where('status_YN', $status);
+
+            $total = $builder->countAllResults(false);
+
+            $items = $builder
+                ->select('id, name, sort_order, status_YN, created_at')
+                ->orderBy('sort_order', 'ASC')
+                ->orderBy('id', 'ASC')
+                ->limit($perPage, $offset)
+                ->get()
+                ->getResult();
+
+            return $this->respondSuccess([
+                'items'       => $items,
+                'total'       => $total,
+                'page'        => $page,
+                'per_page'    => $perPage,
+                'total_pages' => (int) ceil($total / $perPage),
+            ]);
+        } catch (\Exception $e) {
+            log_message('error', 'SpeciesController index error: ' . $e->getMessage());
+            return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 어종구분 등록
+     * POST /api/species
+     */
+    public function create()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getPost() ?? [];
+            }
+
+            $name = trim((string) ($payload['name'] ?? ''));
+            $sortOrder = $payload['sort_order'] ?? 1;
+            $status = trim((string) ($payload['status_YN'] ?? 'Y'));
+
+            if ($name === '') {
+                return $this->respondError('구분명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 30) {
+                return $this->respondError('구분명은 30자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!is_numeric($sortOrder) || (int) $sortOrder < 0) {
+                return $this->respondError('정렬순서는 0 이상의 숫자여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            $status = ($status === 'N') ? 'N' : 'Y';
+
+            $db = $this->getDB();
+
+            // 중복 검사
+            $dupe = $db->table($this->table)
+                ->where('name', $name)->where('deleted_YN', 'N')->countAllResults();
+            if ($dupe > 0) {
+                return $this->respondError('이미 등록된 구분명입니다.', ResponseInterface::HTTP_CONFLICT);
+            }
+
+            $insertData = [
+                'name'       => $name,
+                'sort_order' => (int) $sortOrder,
+                'status_YN'  => $status,
+                'created_at' => date('Y-m-d H:i:s'),
+            ];
+
+            if (!$db->table($this->table)->insert($insertData)) {
+                return $this->respondError('등록에 실패했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+
+            $newId = $db->insertID();
+            $row = $db->table($this->table)->where('id', $newId)->get()->getRow();
+
+            return $this->respondSuccess($row, '어종구분이 등록되었습니다.', ResponseInterface::HTTP_CREATED);
+        } catch (\Exception $e) {
+            log_message('error', 'SpeciesController create error: ' . $e->getMessage());
+            return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 어종구분 수정 (인라인)
+     * PUT /api/species/:id
+     */
+    public function update($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getRawInput() ?? [];
+            }
+
+            $name = trim((string) ($payload['name'] ?? ''));
+            $sortOrder = $payload['sort_order'] ?? 1;
+            $status = trim((string) ($payload['status_YN'] ?? 'Y'));
+
+            if ($name === '') {
+                return $this->respondError('구분명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 30) {
+                return $this->respondError('구분명은 30자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!is_numeric($sortOrder) || (int) $sortOrder < 0) {
+                return $this->respondError('정렬순서는 0 이상의 숫자여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            $status = ($status === 'N') ? 'N' : 'Y';
+
+            $db = $this->getDB();
+
+            $exists = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
+            if ($exists === 0) {
+                return $this->respondError('해당 항목을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            // 자기 자신 제외 중복 검사
+            $dupe = $db->table($this->table)
+                ->where('name', $name)
+                ->where('id !=', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($dupe > 0) {
+                return $this->respondError('이미 등록된 구분명입니다.', ResponseInterface::HTTP_CONFLICT);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'name'       => $name,
+                'sort_order' => (int) $sortOrder,
+                'status_YN'  => $status,
+            ]);
+
+            $row = $db->table($this->table)->where('id', (int) $id)->get()->getRow();
+            return $this->respondSuccess($row, '수정되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'SpeciesController update error: ' . $e->getMessage());
+            return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 어종구분 일괄 저장 (신규 + 수정 한 번에, 트랜잭션)
+     * POST /api/species/bulk-save
+     *   { creates: [{name, sort_order, status_YN}, ...],
+     *     updates: [{id, name, sort_order, status_YN}, ...] }
+     */
+    public function bulkSave()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getRawInput() ?? [];
+            }
+            $creates = is_array($payload['creates'] ?? null) ? $payload['creates'] : [];
+            $updates = is_array($payload['updates'] ?? null) ? $payload['updates'] : [];
+            $deletes = is_array($payload['deletes'] ?? null) ? $payload['deletes'] : [];
+
+            if (empty($creates) && empty($updates) && empty($deletes)) {
+                return $this->respondError('저장할 내용이 없습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+            $db->transBegin();
+
+            $seenNames = [];
+            $createdCount = 0;
+            $updatedCount = 0;
+            $deletedCount = 0;
+
+            // creates
+            foreach ($creates as $i => $c) {
+                $rowLabel = '신규 ' . ($i + 1) . '행';
+                $name = trim((string) ($c['name'] ?? ''));
+                $sortOrder = $c['sort_order'] ?? 1;
+                $status = trim((string) ($c['status_YN'] ?? 'Y'));
+
+                if ($name === '') {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 구분명을 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if (mb_strlen($name) > 30) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 구분명은 30자 이내", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if (!is_numeric($sortOrder) || (int) $sortOrder < 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 정렬순서는 0 이상 숫자", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if (isset($seenNames[$name])) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 같은 batch에 '{$name}' 중복", ResponseInterface::HTTP_CONFLICT);
+                }
+                $dupe = $db->table($this->table)->where('name', $name)->where('deleted_YN', 'N')->countAllResults();
+                if ($dupe > 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 이미 등록된 '{$name}'", ResponseInterface::HTTP_CONFLICT);
+                }
+                $seenNames[$name] = true;
+
+                $db->table($this->table)->insert([
+                    'name'       => $name,
+                    'sort_order' => (int) $sortOrder,
+                    'status_YN'  => ($status === 'N') ? 'N' : 'Y',
+                    'created_at' => date('Y-m-d H:i:s'),
+                ]);
+                $createdCount++;
+            }
+
+            // updates
+            foreach ($updates as $i => $u) {
+                $rowLabel = '수정 ' . ($i + 1) . '행';
+                $id = (int) ($u['id'] ?? 0);
+                $name = trim((string) ($u['name'] ?? ''));
+                $sortOrder = $u['sort_order'] ?? 1;
+                $status = trim((string) ($u['status_YN'] ?? 'Y'));
+
+                if ($id <= 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: ID가 올바르지 않습니다.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if ($name === '') {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 구분명을 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if (mb_strlen($name) > 30) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 구분명은 30자 이내", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if (!is_numeric($sortOrder) || (int) $sortOrder < 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 정렬순서는 0 이상 숫자", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if (isset($seenNames[$name])) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 같은 batch에 '{$name}' 중복", ResponseInterface::HTTP_CONFLICT);
+                }
+                $exists = $db->table($this->table)->where('id', $id)->where('deleted_YN', 'N')->countAllResults();
+                if ($exists === 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 대상이 없습니다.", ResponseInterface::HTTP_NOT_FOUND);
+                }
+                $dupe = $db->table($this->table)
+                    ->where('name', $name)->where('id !=', $id)->where('deleted_YN', 'N')->countAllResults();
+                if ($dupe > 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: 이미 등록된 '{$name}'", ResponseInterface::HTTP_CONFLICT);
+                }
+                $seenNames[$name] = true;
+
+                $db->table($this->table)->where('id', $id)->update([
+                    'name'       => $name,
+                    'sort_order' => (int) $sortOrder,
+                    'status_YN'  => ($status === 'N') ? 'N' : 'Y',
+                ]);
+                $updatedCount++;
+            }
+
+            // deletes 처리 (soft delete)
+            $deleteIds = [];
+            foreach ($deletes as $d) {
+                $id = (int) (is_array($d) ? ($d['id'] ?? 0) : $d);
+                if ($id > 0) $deleteIds[] = $id;
+            }
+            if (!empty($deleteIds)) {
+                $db->table($this->table)
+                    ->whereIn('id', array_values(array_unique($deleteIds)))
+                    ->where('deleted_YN', 'N')
+                    ->update(['deleted_YN' => 'Y']);
+                $deletedCount = count(array_unique($deleteIds));
+            }
+
+            if ($db->transStatus() === false) {
+                $db->transRollback();
+                return $this->respondError('저장 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+            $db->transCommit();
+
+            $total = $createdCount + $updatedCount + $deletedCount;
+            return $this->respondSuccess(
+                ['created' => $createdCount, 'updated' => $updatedCount, 'deleted' => $deletedCount],
+                "{$total}건이 저장되었습니다. (신규 {$createdCount} / 수정 {$updatedCount} / 삭제 {$deletedCount})"
+            );
+        } catch (\Exception $e) {
+            log_message('error', 'SpeciesController bulkSave error: ' . $e->getMessage());
+            return $this->respondError('저장 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 어종구분 일괄 삭제 (soft delete)
+     * POST /api/species/bulk-delete   { ids: [1,2,3] }
+     */
+    public function bulkDelete()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getPost() ?? [];
+            }
+            $ids = $payload['ids'] ?? [];
+            if (!is_array($ids) || empty($ids)) {
+                return $this->respondError('삭제할 항목을 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            $intIds = [];
+            foreach ($ids as $v) {
+                $n = (int) $v;
+                if ($n > 0) $intIds[] = $n;
+            }
+            if (empty($intIds)) {
+                return $this->respondError('올바른 ID가 없습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+            $db->table($this->table)
+                ->whereIn('id', $intIds)
+                ->where('deleted_YN', 'N')
+                ->update(['deleted_YN' => 'Y']);
+
+            return $this->respondSuccess(['count' => count($intIds)], count($intIds) . '건이 삭제되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'SpeciesController bulkDelete error: ' . $e->getMessage());
+            return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+}