Bladeren bron

[챌린지 어종관리] 완료

DESKTOP-T61HUSC\user 2 weken geleden
bovenliggende
commit
1d8271d664

+ 42 - 5
app/assets/scss/admin.scss

@@ -2379,6 +2379,13 @@ footer {
 }
 }
 
 
 // 테이블
 // 테이블
+.admin--table--top--text{
+  color: #666b75;
+  margin-bottom: 18px;
+  font-size: 12px;
+  font-weight: 600;
+}
+
 .admin--table-wrapper {
 .admin--table-wrapper {
   background: var(--admin-bg-secondary);
   background: var(--admin-bg-secondary);
   border: 1px solid var(--admin-border-color);
   border: 1px solid var(--admin-border-color);
@@ -2387,6 +2394,13 @@ footer {
 }
 }
 
 
 .admin--table {
 .admin--table {
+  &.fish--table{
+    // 챌린지&퀘스트 어종관리 테이블
+    // 인라인 수정 콘텐츠가 많아 패딩 간격 줄임
+    th, td{
+      padding: 14px 8px!important;
+    }
+  }
   // 테이블 안 체크박스 — SVG 체크/대시
   // 테이블 안 체크박스 — SVG 체크/대시
   input[type="checkbox"] {
   input[type="checkbox"] {
     appearance: none;
     appearance: none;
@@ -2483,7 +2497,18 @@ footer {
       &.admin--table-row-new {
       &.admin--table-row-new {
         background: #fffbe6;
         background: #fffbe6;
         td {
         td {
-          padding: 8px 12px;
+          padding: 8px!important;
+        }
+        .admin--range-cell{
+          gap: 8px;
+          display: flex;
+          align-items: center;
+          color: #a8adb8;
+          font-size: 11px;
+          font-weight: 400;
+          input{
+            text-align: center;
+          }
         }
         }
       }
       }
     }
     }
@@ -2492,21 +2517,29 @@ footer {
     .admin--inline-input {
     .admin--inline-input {
       width: 100%;
       width: 100%;
       color: #333;
       color: #333;
-      padding: 12px;
+      padding: 12px 8px;
       font-size: 13px;
       font-size: 13px;
+      font-weight: 500;
       border: 1px solid #e8eaef;
       border: 1px solid #e8eaef;
       border-radius: 4px;
       border-radius: 4px;
       background: #fff;
       background: #fff;
       &.center{
       &.center{
         text-align: center;
         text-align: center;
       }
       }
-      &::placeholder{
-        font-weight: 400;
-      }
       &:focus {
       &:focus {
         outline: none;
         outline: none;
         border-color: var(--admin-accent-primary) !important;
         border-color: var(--admin-accent-primary) !important;
       }
       }
+      // number 입력 spinner(증감 화살표) 제거
+      &[type="number"] {
+        -moz-appearance: textfield;
+        appearance: textfield;
+        &::-webkit-inner-spin-button,
+        &::-webkit-outer-spin-button {
+          -webkit-appearance: none;
+          margin: 0;
+        }
+      }
       &.admin--form-select{
       &.admin--form-select{
         cursor: pointer;
         cursor: pointer;
         appearance: none;
         appearance: none;
@@ -2648,6 +2681,10 @@ footer {
   line-height: normal;
   line-height: normal;
   min-width: 100px;
   min-width: 100px;
 
 
+  &.mw--0{
+    min-width: 0;
+  }
+
   &.admin--btn-small-primary {
   &.admin--btn-small-primary {
     background: var(--admin-accent-primary);
     background: var(--admin-accent-primary);
     color: #fff;
     color: #fff;

+ 4 - 4
app/layouts/admin.vue

@@ -83,13 +83,13 @@
         },
         },
         {
         {
           title: "챌린지 어종관리",
           title: "챌린지 어종관리",
-          path: "/site-manager/challenge_species/list",
-          pattern: /^\/site-manager\/challenge_species\/(list|create|edit|detail)/,
+          path: "/site-manager/species_challenge/list",
+          pattern: /^\/site-manager\/species_challenge\/(list|create|edit|detail)/,
         },
         },
         {
         {
           title: "퀘스트 어종관리",
           title: "퀘스트 어종관리",
-          path: "/site-manager/quest_species/list",
-          pattern: /^\/site-manager\/quest_species\/(list|create|edit|detail)/,
+          path: "/site-manager/species_quest/list",
+          pattern: /^\/site-manager\/species_quest\/(list|create|edit|detail)/,
         }
         }
       ],
       ],
     },
     },

+ 3 - 2
app/pages/site-manager/species/list.vue

@@ -20,11 +20,12 @@
         </button>
         </button>
         <button class="admin--btn-small admin--btn-small-primary" @click="addNewRow">+ 구분 추가</button>
         <button class="admin--btn-small admin--btn-small-primary" @click="addNewRow">+ 구분 추가</button>
         <button
         <button
+          v-if="hasChanges || isSaving"
           class="admin--btn-small admin--btn-small-danger"
           class="admin--btn-small admin--btn-small-danger"
-          :disabled="!hasChanges || isSaving"
+          :disabled="isSaving"
           @click="bulkSave"
           @click="bulkSave"
         >
         >
-          {{ isSaving ? "저장 중..." : `일괄 저장${hasChanges ? ` (${changeCount})` : ""}` }}
+          {{ isSaving ? "저장 중..." : `일괄 저장 (${changeCount})` }}
         </button>
         </button>
       </div>
       </div>
     </div>
     </div>

+ 586 - 0
app/pages/site-manager/species_challenge/list.vue

@@ -0,0 +1,586 @@
+<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>

+ 586 - 0
app/pages/site-manager/species_quest/list.vue

@@ -0,0 +1,586 @@
+<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>

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

@@ -50,6 +50,10 @@ $routes->put('api/species/(:num)', 'Api\SpeciesController::update/$1');
 $routes->post('api/species/bulk-save', 'Api\SpeciesController::bulkSave');
 $routes->post('api/species/bulk-save', 'Api\SpeciesController::bulkSave');
 $routes->post('api/species/bulk-delete', 'Api\SpeciesController::bulkDelete');
 $routes->post('api/species/bulk-delete', 'Api\SpeciesController::bulkDelete');
 
 
+// Species Challenge (어종 챌린지)
+$routes->get('api/species-challenge/list', 'Api\SpeciesChallengeController::index');
+$routes->post('api/species-challenge/bulk-save', 'Api\SpeciesChallengeController::bulkSave');
+
 // Item (아이템)
 // Item (아이템)
 $routes->get('api/item/list', 'Api\ItemController::index');
 $routes->get('api/item/list', 'Api\ItemController::index');
 $routes->get('api/item/(:num)', 'Api\ItemController::show/$1');
 $routes->get('api/item/(:num)', 'Api\ItemController::show/$1');

+ 244 - 0
backend/app/Controllers/Api/SpeciesChallengeController.php

@@ -0,0 +1,244 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class SpeciesChallengeController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'species_challenge';
+
+    /**
+     * 어종 챌린지 목록 (구분 JOIN)
+     * GET /api/species-challenge/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'));
+            $typeIdRaw = $this->request->getGet('type_id');     // '', 'null', 또는 숫자
+            $startDate = trim((string) $this->request->getGet('start_date'));
+            $endDate   = trim((string) $this->request->getGet('end_date'));
+
+            $db = $this->getDB();
+            $builder = $db->table($this->table . ' sc')
+                ->join('species_type st', 'st.id = sc.type_id', 'left')
+                ->where('sc.deleted_YN', 'N');
+
+            if ($search !== '') $builder->like('sc.name', $search);
+            if ($typeIdRaw === 'null') {
+                $builder->where('sc.type_id IS NULL', null, false);
+            } elseif (is_numeric($typeIdRaw) && (int) $typeIdRaw > 0) {
+                $builder->where('sc.type_id', (int) $typeIdRaw);
+            }
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
+                $builder->where('sc.created_at >=', $startDate . ' 00:00:00');
+            }
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+                $builder->where('sc.created_at <=', $endDate . ' 23:59:59');
+            }
+
+            $total = $builder->countAllResults(false);
+
+            $items = $builder
+                ->select('sc.*, st.name as type_name')
+                ->orderBy('sc.id', 'DESC')
+                ->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', 'SpeciesChallengeController index error: ' . $e->getMessage());
+            return $this->respondError('목록 조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 어종 챌린지 일괄 저장 (creates + updates + deletes, 트랜잭션)
+     * POST /api/species-challenge/bulk-save
+     */
+    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();
+
+            $createdCount = 0;
+            $updatedCount = 0;
+            $deletedCount = 0;
+
+            // creates
+            foreach ($creates as $i => $c) {
+                $err = $this->validateRow($c, '신규 ' . ($i + 1) . '행', $db);
+                if ($err) { $db->transRollback(); return $this->respondError($err, ResponseInterface::HTTP_BAD_REQUEST); }
+
+                $db->table($this->table)->insert([
+                    'type_id'    => ((int) $c['type_id']) > 0 ? (int) $c['type_id'] : null,
+                    'name'       => trim((string) $c['name']),
+                    'min'        => (int) $c['min'],
+                    'max'        => (int) $c['max'],
+                    'round1_min' => (int) $c['round1_min'],
+                    'round1_max' => (int) $c['round1_max'],
+                    'round2_min' => (int) $c['round2_min'],
+                    'round2_max' => (int) $c['round2_max'],
+                    'round3_min' => (int) $c['round3_min'],
+                    'round3_max' => (int) $c['round3_max'],
+                    'round4_min' => (int) $c['round4_min'],
+                    'round4_max' => (int) $c['round4_max'],
+                    'round5_min' => (int) $c['round5_min'],
+                    'round5_max' => (int) $c['round5_max'],
+                    'created_at' => date('Y-m-d H:i:s'),
+                ]);
+                $createdCount++;
+            }
+
+            // updates
+            foreach ($updates as $i => $u) {
+                $rowLabel = '수정 ' . ($i + 1) . '행';
+                $id = (int) ($u['id'] ?? 0);
+                if ($id <= 0) {
+                    $db->transRollback();
+                    return $this->respondError("{$rowLabel}: ID가 올바르지 않습니다.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                $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);
+                }
+
+                $err = $this->validateRow($u, $rowLabel, $db);
+                if ($err) { $db->transRollback(); return $this->respondError($err, ResponseInterface::HTTP_BAD_REQUEST); }
+
+                $db->table($this->table)->where('id', $id)->update([
+                    'type_id'    => ((int) $u['type_id']) > 0 ? (int) $u['type_id'] : null,
+                    'name'       => trim((string) $u['name']),
+                    'min'        => (int) $u['min'],
+                    'max'        => (int) $u['max'],
+                    'round1_min' => (int) $u['round1_min'],
+                    'round1_max' => (int) $u['round1_max'],
+                    'round2_min' => (int) $u['round2_min'],
+                    'round2_max' => (int) $u['round2_max'],
+                    'round3_min' => (int) $u['round3_min'],
+                    'round3_max' => (int) $u['round3_max'],
+                    'round4_min' => (int) $u['round4_min'],
+                    'round4_max' => (int) $u['round4_max'],
+                    'round5_min' => (int) $u['round5_min'],
+                    'round5_max' => (int) $u['round5_max'],
+                ]);
+                $updatedCount++;
+            }
+
+            // deletes
+            $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', 'SpeciesChallengeController bulkSave error: ' . $e->getMessage());
+            return $this->respondError('저장 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 한 행 검증 — 에러 메시지 반환 (null이면 OK)
+     */
+    private function validateRow($row, string $label, $db)
+    {
+        $typeId = (int) ($row['type_id'] ?? 0);
+        $name = trim((string) ($row['name'] ?? ''));
+        $min = $row['min'] ?? null;
+        $max = $row['max'] ?? null;
+
+        if ($name === '') return "{$label}: 어종명을 입력하세요.";
+        if (mb_strlen($name) > 50) return "{$label}: 어종명은 50자 이내";
+
+        // type_id가 지정된 경우에만 존재 확인 (미선택 허용)
+        if ($typeId > 0) {
+            $typeExists = $db->table('species_type')
+                ->where('id', $typeId)->where('deleted_YN', 'N')->countAllResults();
+            if ($typeExists === 0) return "{$label}: 존재하지 않는 구분입니다.";
+        }
+
+        if ($min === null || $min === '' || !is_numeric($min)) return "{$label}: 최소금지를 입력하세요.";
+        if ($max === null || $max === '' || !is_numeric($max)) return "{$label}: 최대길이를 입력하세요.";
+        if ((int) $min < 0) return "{$label}: 최소금지는 0 이상";
+        if ((int) $max < (int) $min) return "{$label}: 최대길이는 최소금지 이상이어야 합니다.";
+
+        for ($r = 1; $r <= 5; $r++) {
+            $rmin = $row["round{$r}_min"] ?? null;
+            $rmax = $row["round{$r}_max"] ?? null;
+            if ($rmin === null || $rmin === '' || !is_numeric($rmin)) return "{$label}: {$r}라운드 최소를 입력하세요.";
+            if ($rmax === null || $rmax === '' || !is_numeric($rmax)) return "{$label}: {$r}라운드 최대를 입력하세요.";
+            $rmin = (int) $rmin;
+            $rmax = (int) $rmax;
+            if ($rmin > $rmax) return "{$label}: {$r}라운드 최소는 최대보다 작거나 같아야 합니다.";
+            // 각 라운드 max <= 최대길이
+            if ($rmax > (int) $max) {
+                return "{$label}: {$r}라운드 최대는 최대길이({$max})보다 클 수 없습니다.";
+            }
+        }
+
+        // 1라운드 min >= 최소금지
+        if ((int) $row['round1_min'] < (int) $min) {
+            return "{$label}: 1라운드 최소는 최소금지({$min})보다 작을 수 없습니다.";
+        }
+
+        return null;
+    }
+}

+ 1296 - 166
db.vuerd.json

@@ -2,10 +2,10 @@
   "$schema": "https://raw.githubusercontent.com/dineug/erd-editor/main/json-schema/schema.json",
   "$schema": "https://raw.githubusercontent.com/dineug/erd-editor/main/json-schema/schema.json",
   "version": "3.0.0",
   "version": "3.0.0",
   "settings": {
   "settings": {
-    "width": 2000,
-    "height": 2000,
-    "scrollTop": -1000,
-    "scrollLeft": -556,
+    "width": 3000,
+    "height": 3000,
+    "scrollTop": -1559,
+    "scrollLeft": -12,
     "zoomLevel": 0.97,
     "zoomLevel": 0.97,
     "show": 431,
     "show": 431,
     "database": 4,
     "database": 4,
@@ -38,7 +38,10 @@
       "f0HLDJeJHSxkXnVkJNkFN",
       "f0HLDJeJHSxkXnVkJNkFN",
       "Rm_FvXbyIhbNwaAzppHJH",
       "Rm_FvXbyIhbNwaAzppHJH",
       "dvwjXJtxKUI-09IaRj7WY",
       "dvwjXJtxKUI-09IaRj7WY",
-      "0qOhpokdRsP9PKwViW3I4"
+      "0qOhpokdRsP9PKwViW3I4",
+      "kx1Wu65aSaH1nPc3asZqQ",
+      "M0_u-aSCZODbw1yxM1xbr",
+      "2sbVPAHfKmCEZ5M-oWOvb"
     ],
     ],
     "relationshipIds": [
     "relationshipIds": [
       "02Rf0D1riQbaw0LqkaD6r",
       "02Rf0D1riQbaw0LqkaD6r",
@@ -46,7 +49,9 @@
       "UyIJVtBW8x18iGTJ7LZuv",
       "UyIJVtBW8x18iGTJ7LZuv",
       "z3AF_pXmB6YPyWNFzaQmH",
       "z3AF_pXmB6YPyWNFzaQmH",
       "6dWnTNRq3psvKvtiYAk_j",
       "6dWnTNRq3psvKvtiYAk_j",
-      "dPC1hrjZp8SIR9PkoUaLL"
+      "dPC1hrjZp8SIR9PkoUaLL",
+      "-W2zak31bgJwKqTSi4rfs",
+      "o4qoNtcaO_NiHs5Z6SF67"
     ],
     ],
     "indexIds": [],
     "indexIds": [],
     "memoIds": []
     "memoIds": []
@@ -109,15 +114,15 @@
           "GwpDGG7bv-oliz1jukS1a"
           "GwpDGG7bv-oliz1jukS1a"
         ],
         ],
         "ui": {
         "ui": {
-          "x": 56.1237,
-          "y": 322.8041,
+          "x": 60.2471,
+          "y": 644.4537,
           "zIndex": 13,
           "zIndex": 13,
           "widthName": 65,
           "widthName": 65,
           "widthComment": 60,
           "widthComment": 60,
           "color": ""
           "color": ""
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780291586351,
+          "updateAt": 1780900144613,
           "createAt": 1779771453475
           "createAt": 1779771453475
         }
         }
       },
       },
@@ -279,15 +284,15 @@
           "h_PV6rCFXbJSKVePfzVgN"
           "h_PV6rCFXbJSKVePfzVgN"
         ],
         ],
         "ui": {
         "ui": {
-          "x": 685.5666,
-          "y": 673.1958,
+          "x": 685.5667,
+          "y": 938.1442,
           "zIndex": 150,
           "zIndex": 150,
           "widthName": 60,
           "widthName": 60,
           "widthComment": 60,
           "widthComment": 60,
           "color": ""
           "color": ""
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780293590881,
+          "updateAt": 1780900153437,
           "createAt": 1780291013586
           "createAt": 1780291013586
         }
         }
       },
       },
@@ -322,15 +327,15 @@
           "zO1UMeqAKkf-A511wUToG"
           "zO1UMeqAKkf-A511wUToG"
         ],
         ],
         "ui": {
         "ui": {
-          "x": 1227.8358,
-          "y": 669.0721,
+          "x": 1226.8048,
+          "y": 464.9484,
           "zIndex": 339,
           "zIndex": 339,
           "widthName": 79,
           "widthName": 79,
           "widthComment": 77,
           "widthComment": 77,
           "color": ""
           "color": ""
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780293768819,
+          "updateAt": 1780900227326,
           "createAt": 1780293637693
           "createAt": 1780293637693
         }
         }
       },
       },
@@ -369,15 +374,15 @@
           "-6bgTEe_q870tmIqHw9M1"
           "-6bgTEe_q870tmIqHw9M1"
         ],
         ],
         "ui": {
         "ui": {
-          "x": 1231.289,
-          "y": 1030.9279,
+          "x": 1331.289,
+          "y": 1100.6954,
           "zIndex": 468,
           "zIndex": 468,
           "widthName": 60,
           "widthName": 60,
           "widthComment": 60,
           "widthComment": 60,
           "color": ""
           "color": ""
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780374834161,
+          "updateAt": 1780900281312,
           "createAt": 1780299725141
           "createAt": 1780299725141
         }
         }
       },
       },
@@ -402,17 +407,162 @@
           "UW5iGbQHdo2szbqff2XTV"
           "UW5iGbQHdo2szbqff2XTV"
         ],
         ],
         "ui": {
         "ui": {
-          "x": 683.866,
-          "y": 1278.3505,
+          "x": 685.9278,
+          "y": 1276.2887,
           "zIndex": 608,
           "zIndex": 608,
           "widthName": 68,
           "widthName": 68,
           "widthComment": 60,
           "widthComment": 60,
           "color": ""
           "color": ""
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780383614742,
+          "updateAt": 1780900029280,
           "createAt": 1780383472103
           "createAt": 1780383472103
         }
         }
+      },
+      "kx1Wu65aSaH1nPc3asZqQ": {
+        "id": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "species_challenge",
+        "comment": "챌린지 어종",
+        "columnIds": [
+          "myCdRl4MHgzHrwC60jBkx",
+          "txDc37wfly679q2svCgzI",
+          "BYytkHh_31xikTzmQbz9Y",
+          "6FC5Fv33t1MXjaRgs6NeM",
+          "oJ4hSDILsb7n88Fs-WpAv",
+          "vO2XKtXJj0bM9VeXr_Fvs",
+          "ZzJoCDdXAR0KZi_H50rg5",
+          "F9Ujlqf3Jxo4KUXBM50g_",
+          "TW-yWplIw3vwnqHQe2kjT",
+          "wxknRDplRVV8oYVV584_C",
+          "2VrjJXMa3hFLl3e6u24rZ",
+          "gD1olVZFLILTvsSsrsH1j",
+          "PVk9JSLilqB5fHB59N6OQ",
+          "dOPkqj0X5IXsKJTwqwDZD",
+          "9KrrB4wvCQv8Cp0EvTByh",
+          "RQAkkoLbECq_1Qw7264Ad",
+          "s-AdQSDQ2UbWr5ysY6Pn8"
+        ],
+        "seqColumnIds": [
+          "myCdRl4MHgzHrwC60jBkx",
+          "txDc37wfly679q2svCgzI",
+          "pB9ZWZB6w9F_-lj0tzO7L",
+          "BYytkHh_31xikTzmQbz9Y",
+          "6FC5Fv33t1MXjaRgs6NeM",
+          "oJ4hSDILsb7n88Fs-WpAv",
+          "vO2XKtXJj0bM9VeXr_Fvs",
+          "ZzJoCDdXAR0KZi_H50rg5",
+          "F9Ujlqf3Jxo4KUXBM50g_",
+          "TW-yWplIw3vwnqHQe2kjT",
+          "wxknRDplRVV8oYVV584_C",
+          "2VrjJXMa3hFLl3e6u24rZ",
+          "gD1olVZFLILTvsSsrsH1j",
+          "PVk9JSLilqB5fHB59N6OQ",
+          "dOPkqj0X5IXsKJTwqwDZD",
+          "9KrrB4wvCQv8Cp0EvTByh",
+          "RQAkkoLbECq_1Qw7264Ad",
+          "s-AdQSDQ2UbWr5ysY6Pn8",
+          "zzigmS2eus6Dqmq6B0T8f"
+        ],
+        "ui": {
+          "x": 488.4439,
+          "y": 1643.6346,
+          "zIndex": 609,
+          "widthName": 96,
+          "widthComment": 65,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1780900874715,
+          "createAt": 1780897281420
+        }
+      },
+      "M0_u-aSCZODbw1yxM1xbr": {
+        "id": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "species_quest",
+        "comment": "퀘스트 어종",
+        "columnIds": [
+          "IX-qf5QUfjxAxnoS9Gobp",
+          "fgdsMrriRk3jn-w8yCWTD",
+          "HxW8glOq9wsSOZ3kwaT3d",
+          "ZluLEI9WZjJm38V1HbyDw",
+          "OK5QkOFj75e8w_6T-MkmK",
+          "DYIETM7hHChJLzu3HoCT6",
+          "w8Mbf_Fyg38OiPCdIqARA",
+          "1h5FmbC1kvc66CoEEKcPz",
+          "6FgKenK-i9vE7H0ouTpPn",
+          "moXSyZgXBkx7Wmn2kpwrc",
+          "crqyC1aNLwfZ5dxBBpxiJ",
+          "1FEFaMWLQZNscou6-hnzu",
+          "6QBt2bf-rzrfob-cnYKn7",
+          "_ERiYo35EfGl88KXj-St2",
+          "4Pzr9nRS8Xs5tvCE4d_v8",
+          "G4PNzSGFIH3Id8FR1uKEL",
+          "wrYVvwUvdLSL-rQaNsyfq"
+        ],
+        "seqColumnIds": [
+          "IX-qf5QUfjxAxnoS9Gobp",
+          "fgdsMrriRk3jn-w8yCWTD",
+          "HxW8glOq9wsSOZ3kwaT3d",
+          "ZluLEI9WZjJm38V1HbyDw",
+          "OK5QkOFj75e8w_6T-MkmK",
+          "DYIETM7hHChJLzu3HoCT6",
+          "w8Mbf_Fyg38OiPCdIqARA",
+          "1h5FmbC1kvc66CoEEKcPz",
+          "6FgKenK-i9vE7H0ouTpPn",
+          "moXSyZgXBkx7Wmn2kpwrc",
+          "crqyC1aNLwfZ5dxBBpxiJ",
+          "1FEFaMWLQZNscou6-hnzu",
+          "6QBt2bf-rzrfob-cnYKn7",
+          "_ERiYo35EfGl88KXj-St2",
+          "4Pzr9nRS8Xs5tvCE4d_v8",
+          "G4PNzSGFIH3Id8FR1uKEL",
+          "wrYVvwUvdLSL-rQaNsyfq"
+        ],
+        "ui": {
+          "x": 74.0074,
+          "y": 1641.7407,
+          "zIndex": 772,
+          "widthName": 75,
+          "widthComment": 65,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1780901303203,
+          "createAt": 1780899956621
+        }
+      },
+      "2sbVPAHfKmCEZ5M-oWOvb": {
+        "id": "2sbVPAHfKmCEZ5M-oWOvb",
+        "name": "species_type",
+        "comment": "어종구분",
+        "columnIds": [
+          "jeoER3USmy3PxmFh_gTZI",
+          "sVV3grzl6ryzCO4yb93Q4",
+          "LFbRhOTPfaw_0kt1r9Mml",
+          "lIiE0L9XPirC4RMgw_eZm",
+          "4yxxEtqCiCWTU5M-4SDRB",
+          "oBsIX3J95o6dOBfFkvyc_"
+        ],
+        "seqColumnIds": [
+          "jeoER3USmy3PxmFh_gTZI",
+          "sVV3grzl6ryzCO4yb93Q4",
+          "LFbRhOTPfaw_0kt1r9Mml",
+          "lIiE0L9XPirC4RMgw_eZm",
+          "4yxxEtqCiCWTU5M-4SDRB",
+          "oBsIX3J95o6dOBfFkvyc_"
+        ],
+        "ui": {
+          "x": 92.2875,
+          "y": 1157.326,
+          "zIndex": 813,
+          "widthName": 68,
+          "widthComment": 60,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1780900507879,
+          "createAt": 1780900301081
+        }
       }
       }
     },
     },
     "tableColumnEntities": {
     "tableColumnEntities": {
@@ -2435,175 +2585,1155 @@
           "updateAt": 1780383591546,
           "updateAt": 1780383591546,
           "createAt": 1780383578130
           "createAt": 1780383578130
         }
         }
-      }
-    },
-    "relationshipEntities": {
-      "02Rf0D1riQbaw0LqkaD6r": {
-        "id": "02Rf0D1riQbaw0LqkaD6r",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "FISm81kEr0UVuk9sKYz4j",
-          "columnIds": [
-            "tVtTK-A1GhU8WRdiZT89G"
-          ],
-          "x": 427.1134,
-          "y": 93.0825,
-          "direction": 2
-        },
-        "end": {
-          "tableId": "cH4_5N71LdebT2IHo_Dd9",
-          "columnIds": [
-            "zH8U7DiFE0_b0k7y88KoF"
-          ],
-          "x": 687.2182,
-          "y": 155.835,
-          "direction": 1
+      },
+      "myCdRl4MHgzHrwC60jBkx": {
+        "id": "myCdRl4MHgzHrwC60jBkx",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1779935483922,
-          "createAt": 1779935483922
+          "updateAt": 1780897971981,
+          "createAt": 1780897751238
         }
         }
       },
       },
-      "lo9HDcBWr2MulQ_cz9_mS": {
-        "id": "lo9HDcBWr2MulQ_cz9_mS",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "cH4_5N71LdebT2IHo_Dd9",
-          "columnIds": [
-            "T0k9O_Ol_BAHTkKFWaaVb"
-          ],
-          "x": 1087.2182,
-          "y": 295.835,
-          "direction": 2
-        },
-        "end": {
-          "tableId": "zUvkqgDCrDrdAi4llCexp",
-          "columnIds": [
-            "9zEjG5L24xZPSGCUtmP49"
-          ],
-          "x": 1229.6505,
-          "y": 177.233,
-          "direction": 1
+      "BYytkHh_31xikTzmQbz9Y": {
+        "id": "BYytkHh_31xikTzmQbz9Y",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1779935535175,
-          "createAt": 1779935535175
+          "updateAt": 1780897985143,
+          "createAt": 1780897975498
         }
         }
       },
       },
-      "UyIJVtBW8x18iGTJ7LZuv": {
-        "id": "UyIJVtBW8x18iGTJ7LZuv",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "U1M3DNhPb8li5y4zccrUR",
-          "columnIds": [
-            "9O46Rt9JdNS_X3fayZRJu"
-          ],
-          "x": 429.1237,
-          "y": 366.8041,
-          "direction": 2
-        },
-        "end": {
-          "tableId": "cH4_5N71LdebT2IHo_Dd9",
-          "columnIds": [
-            "jOU5il8WO13kfZaWytFCs"
-          ],
-          "x": 687.2182,
-          "y": 435.83500000000004,
-          "direction": 1
+      "pB9ZWZB6w9F_-lj0tzO7L": {
+        "id": "pB9ZWZB6w9F_-lj0tzO7L",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "type_id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1779935590724,
-          "createAt": 1779935590724
+          "updateAt": 1780898436559,
+          "createAt": 1780898108282
         }
         }
       },
       },
-      "z3AF_pXmB6YPyWNFzaQmH": {
-        "id": "z3AF_pXmB6YPyWNFzaQmH",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "FISm81kEr0UVuk9sKYz4j",
-          "columnIds": [
-            "tVtTK-A1GhU8WRdiZT89G"
-          ],
-          "x": 427.1134,
-          "y": 217.08249999999998,
-          "direction": 2
-        },
-        "end": {
-          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
-          "columnIds": [
-            "RA54uihL-Tg51rhi61MhV"
-          ],
-          "x": 785.5666,
-          "y": 673.1958,
-          "direction": 4
+      "6FC5Fv33t1MXjaRgs6NeM": {
+        "id": "6FC5Fv33t1MXjaRgs6NeM",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780291548815,
-          "createAt": 1780291548815
+          "updateAt": 1780898908712,
+          "createAt": 1780898445965
         }
         }
       },
       },
-      "6dWnTNRq3psvKvtiYAk_j": {
-        "id": "6dWnTNRq3psvKvtiYAk_j",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "U1M3DNhPb8li5y4zccrUR",
-          "columnIds": [
-            "9O46Rt9JdNS_X3fayZRJu"
-          ],
-          "x": 429.1237,
-          "y": 454.8041,
-          "direction": 2
+      "oJ4hSDILsb7n88Fs-WpAv": {
+        "id": "oJ4hSDILsb7n88Fs-WpAv",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
-        "end": {
-          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
-          "columnIds": [
-            "U84ia2uANWjZ6-tF7KhhS"
-          ],
-          "x": 985.5666,
-          "y": 673.1958,
-          "direction": 4
+        "meta": {
+          "updateAt": 1780898909881,
+          "createAt": 1780898488031
+        }
+      },
+      "vO2XKtXJj0bM9VeXr_Fvs": {
+        "id": "vO2XKtXJj0bM9VeXr_Fvs",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round1_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780291627101,
-          "createAt": 1780291627101
+          "updateAt": 1780898910631,
+          "createAt": 1780898500668
         }
         }
       },
       },
-      "dPC1hrjZp8SIR9PkoUaLL": {
-        "id": "dPC1hrjZp8SIR9PkoUaLL",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
-          "columnIds": [
-            "V6flU3IcG8jhBv4HetFn7"
-          ],
-          "x": 1085.5666,
-          "y": 941.1958,
-          "direction": 2
+      "ZzJoCDdXAR0KZi_H50rg5": {
+        "id": "ZzJoCDdXAR0KZi_H50rg5",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round1_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
-        "end": {
-          "tableId": "Rm_FvXbyIhbNwaAzppHJH",
-          "columnIds": [
-            "5i3irVRE6-tHCSnLf6_y4"
-          ],
-          "x": 1227.8358,
-          "y": 829.0721,
-          "direction": 1
+        "meta": {
+          "updateAt": 1780898911172,
+          "createAt": 1780898507861
+        }
+      },
+      "F9Ujlqf3Jxo4KUXBM50g_": {
+        "id": "F9Ujlqf3Jxo4KUXBM50g_",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round2_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         },
         "meta": {
         "meta": {
-          "updateAt": 1780293685859,
-          "createAt": 1780293685859
+          "updateAt": 1780898911682,
+          "createAt": 1780898514312
+        }
+      },
+      "TW-yWplIw3vwnqHQe2kjT": {
+        "id": "TW-yWplIw3vwnqHQe2kjT",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round2_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780898912139,
+          "createAt": 1780898520078
+        }
+      },
+      "wxknRDplRVV8oYVV584_C": {
+        "id": "wxknRDplRVV8oYVV584_C",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round3_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899117977,
+          "createAt": 1780898913212
+        }
+      },
+      "2VrjJXMa3hFLl3e6u24rZ": {
+        "id": "2VrjJXMa3hFLl3e6u24rZ",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round3_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899118973,
+          "createAt": 1780898913548
+        }
+      },
+      "gD1olVZFLILTvsSsrsH1j": {
+        "id": "gD1olVZFLILTvsSsrsH1j",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round4_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899119956,
+          "createAt": 1780898916354
+        }
+      },
+      "PVk9JSLilqB5fHB59N6OQ": {
+        "id": "PVk9JSLilqB5fHB59N6OQ",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round4_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899121374,
+          "createAt": 1780898916586
+        }
+      },
+      "dOPkqj0X5IXsKJTwqwDZD": {
+        "id": "dOPkqj0X5IXsKJTwqwDZD",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round5_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899122283,
+          "createAt": 1780898917051
+        }
+      },
+      "9KrrB4wvCQv8Cp0EvTByh": {
+        "id": "9KrrB4wvCQv8Cp0EvTByh",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "round5_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899123579,
+          "createAt": 1780898918356
+        }
+      },
+      "RQAkkoLbECq_1Qw7264Ad": {
+        "id": "RQAkkoLbECq_1Qw7264Ad",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899084228,
+          "createAt": 1780898920509
+        }
+      },
+      "s-AdQSDQ2UbWr5ysY6Pn8": {
+        "id": "s-AdQSDQ2UbWr5ysY6Pn8",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "N",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780899068570,
+          "createAt": 1780898953231
+        }
+      },
+      "IX-qf5QUfjxAxnoS9Gobp": {
+        "id": "IX-qf5QUfjxAxnoS9Gobp",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901199317,
+          "createAt": 1780899967007
+        }
+      },
+      "HxW8glOq9wsSOZ3kwaT3d": {
+        "id": "HxW8glOq9wsSOZ3kwaT3d",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901225367,
+          "createAt": 1780899975062
+        }
+      },
+      "ZluLEI9WZjJm38V1HbyDw": {
+        "id": "ZluLEI9WZjJm38V1HbyDw",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901230315,
+          "createAt": 1780900032320
+        }
+      },
+      "jeoER3USmy3PxmFh_gTZI": {
+        "id": "jeoER3USmy3PxmFh_gTZI",
+        "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900502518,
+          "createAt": 1780900339907
+        }
+      },
+      "sVV3grzl6ryzCO4yb93Q4": {
+        "id": "sVV3grzl6ryzCO4yb93Q4",
+        "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900572395,
+          "createAt": 1780900340837
+        }
+      },
+      "LFbRhOTPfaw_0kt1r9Mml": {
+        "id": "LFbRhOTPfaw_0kt1r9Mml",
+        "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+        "name": "sort_order",
+        "comment": "",
+        "dataType": "INT",
+        "default": "1",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900581143,
+          "createAt": 1780900341400
+        }
+      },
+      "lIiE0L9XPirC4RMgw_eZm": {
+        "id": "lIiE0L9XPirC4RMgw_eZm",
+        "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+        "name": "status_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "Y",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900583634,
+          "createAt": 1780900345201
+        }
+      },
+      "4yxxEtqCiCWTU5M-4SDRB": {
+        "id": "4yxxEtqCiCWTU5M-4SDRB",
+        "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900564355,
+          "createAt": 1780900345598
+        }
+      },
+      "oBsIX3J95o6dOBfFkvyc_": {
+        "id": "oBsIX3J95o6dOBfFkvyc_",
+        "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "N",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900586609,
+          "createAt": 1780900346027
+        }
+      },
+      "zzigmS2eus6Dqmq6B0T8f": {
+        "id": "zzigmS2eus6Dqmq6B0T8f",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900611085,
+          "createAt": 1780900611084
+        }
+      },
+      "fgdsMrriRk3jn-w8yCWTD": {
+        "id": "fgdsMrriRk3jn-w8yCWTD",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "type_id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 2,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901216191,
+          "createAt": 1780900620398
+        }
+      },
+      "txDc37wfly679q2svCgzI": {
+        "id": "txDc37wfly679q2svCgzI",
+        "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+        "name": "type_id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 2,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780900875781,
+          "createAt": 1780900865092
+        }
+      },
+      "OK5QkOFj75e8w_6T-MkmK": {
+        "id": "OK5QkOFj75e8w_6T-MkmK",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901247628,
+          "createAt": 1780901236003
+        }
+      },
+      "DYIETM7hHChJLzu3HoCT6": {
+        "id": "DYIETM7hHChJLzu3HoCT6",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901311722,
+          "createAt": 1780901252841
+        }
+      },
+      "w8Mbf_Fyg38OiPCdIqARA": {
+        "id": "w8Mbf_Fyg38OiPCdIqARA",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901312247,
+          "createAt": 1780901253298
+        }
+      },
+      "1h5FmbC1kvc66CoEEKcPz": {
+        "id": "1h5FmbC1kvc66CoEEKcPz",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901312697,
+          "createAt": 1780901254266
+        }
+      },
+      "6FgKenK-i9vE7H0ouTpPn": {
+        "id": "6FgKenK-i9vE7H0ouTpPn",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901313208,
+          "createAt": 1780901254760
+        }
+      },
+      "moXSyZgXBkx7Wmn2kpwrc": {
+        "id": "moXSyZgXBkx7Wmn2kpwrc",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901313747,
+          "createAt": 1780901255330
+        }
+      },
+      "crqyC1aNLwfZ5dxBBpxiJ": {
+        "id": "crqyC1aNLwfZ5dxBBpxiJ",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901314234,
+          "createAt": 1780901255690
+        }
+      },
+      "1FEFaMWLQZNscou6-hnzu": {
+        "id": "1FEFaMWLQZNscou6-hnzu",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901314685,
+          "createAt": 1780901256148
+        }
+      },
+      "6QBt2bf-rzrfob-cnYKn7": {
+        "id": "6QBt2bf-rzrfob-cnYKn7",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901315128,
+          "createAt": 1780901256448
+        }
+      },
+      "_ERiYo35EfGl88KXj-St2": {
+        "id": "_ERiYo35EfGl88KXj-St2",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_min",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 65,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901315705,
+          "createAt": 1780901256770
+        }
+      },
+      "4Pzr9nRS8Xs5tvCE4d_v8": {
+        "id": "4Pzr9nRS8Xs5tvCE4d_v8",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "round1_max",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 67,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901316170,
+          "createAt": 1780901257083
+        }
+      },
+      "G4PNzSGFIH3Id8FR1uKEL": {
+        "id": "G4PNzSGFIH3Id8FR1uKEL",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901286065,
+          "createAt": 1780901258772
+        }
+      },
+      "wrYVvwUvdLSL-rQaNsyfq": {
+        "id": "wrYVvwUvdLSL-rQaNsyfq",
+        "tableId": "M0_u-aSCZODbw1yxM1xbr",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "N",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780901292953,
+          "createAt": 1780901258938
+        }
+      }
+    },
+    "relationshipEntities": {
+      "02Rf0D1riQbaw0LqkaD6r": {
+        "id": "02Rf0D1riQbaw0LqkaD6r",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "FISm81kEr0UVuk9sKYz4j",
+          "columnIds": [
+            "tVtTK-A1GhU8WRdiZT89G"
+          ],
+          "x": 427.1134,
+          "y": 93.0825,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "cH4_5N71LdebT2IHo_Dd9",
+          "columnIds": [
+            "zH8U7DiFE0_b0k7y88KoF"
+          ],
+          "x": 687.2182,
+          "y": 295.835,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1779935483922,
+          "createAt": 1779935483922
+        }
+      },
+      "lo9HDcBWr2MulQ_cz9_mS": {
+        "id": "lo9HDcBWr2MulQ_cz9_mS",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "cH4_5N71LdebT2IHo_Dd9",
+          "columnIds": [
+            "T0k9O_Ol_BAHTkKFWaaVb"
+          ],
+          "x": 1087.2182,
+          "y": 295.835,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "zUvkqgDCrDrdAi4llCexp",
+          "columnIds": [
+            "9zEjG5L24xZPSGCUtmP49"
+          ],
+          "x": 1229.6505,
+          "y": 177.233,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1779935535175,
+          "createAt": 1779935535175
+        }
+      },
+      "UyIJVtBW8x18iGTJ7LZuv": {
+        "id": "UyIJVtBW8x18iGTJ7LZuv",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "U1M3DNhPb8li5y4zccrUR",
+          "columnIds": [
+            "9O46Rt9JdNS_X3fayZRJu"
+          ],
+          "x": 433.2471,
+          "y": 688.4537,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "cH4_5N71LdebT2IHo_Dd9",
+          "columnIds": [
+            "jOU5il8WO13kfZaWytFCs"
+          ],
+          "x": 887.2182,
+          "y": 575.835,
+          "direction": 8
+        },
+        "meta": {
+          "updateAt": 1779935590724,
+          "createAt": 1779935590724
+        }
+      },
+      "z3AF_pXmB6YPyWNFzaQmH": {
+        "id": "z3AF_pXmB6YPyWNFzaQmH",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "FISm81kEr0UVuk9sKYz4j",
+          "columnIds": [
+            "tVtTK-A1GhU8WRdiZT89G"
+          ],
+          "x": 427.1134,
+          "y": 217.08249999999998,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+          "columnIds": [
+            "RA54uihL-Tg51rhi61MhV"
+          ],
+          "x": 752.2333666666667,
+          "y": 938.1442,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780291548815,
+          "createAt": 1780291548815
+        }
+      },
+      "6dWnTNRq3psvKvtiYAk_j": {
+        "id": "6dWnTNRq3psvKvtiYAk_j",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "U1M3DNhPb8li5y4zccrUR",
+          "columnIds": [
+            "9O46Rt9JdNS_X3fayZRJu"
+          ],
+          "x": 433.2471,
+          "y": 776.4537,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+          "columnIds": [
+            "U84ia2uANWjZ6-tF7KhhS"
+          ],
+          "x": 885.5667000000001,
+          "y": 938.1442,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780291627101,
+          "createAt": 1780291627101
+        }
+      },
+      "dPC1hrjZp8SIR9PkoUaLL": {
+        "id": "dPC1hrjZp8SIR9PkoUaLL",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+          "columnIds": [
+            "V6flU3IcG8jhBv4HetFn7"
+          ],
+          "x": 1018.9000333333335,
+          "y": 938.1442,
+          "direction": 4
+        },
+        "end": {
+          "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+          "columnIds": [
+            "5i3irVRE6-tHCSnLf6_y4"
+          ],
+          "x": 1226.8048,
+          "y": 624.9484,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1780293685859,
+          "createAt": 1780293685859
+        }
+      },
+      "mEN-PWyIH4MC2F31vizO-": {
+        "id": "mEN-PWyIH4MC2F31vizO-",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 1,
+        "start": {
+          "tableId": "0qOhpokdRsP9PKwViW3I4",
+          "columnIds": [
+            "zrzX4czwgIrVMdPxnO-66"
+          ],
+          "x": 1058.9278,
+          "y": 1376.2887,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+          "columnIds": [
+            "pB9ZWZB6w9F_-lj0tzO7L"
+          ],
+          "x": 1331.9587,
+          "y": 1721.6907,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1780898108282,
+          "createAt": 1780898108282
+        }
+      },
+      "za5YEBGQgQoVUzljdUzeH": {
+        "id": "za5YEBGQgQoVUzljdUzeH",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "0qOhpokdRsP9PKwViW3I4",
+          "columnIds": [
+            "zrzX4czwgIrVMdPxnO-66"
+          ],
+          "x": 872.4278,
+          "y": 1476.2887,
+          "direction": 8
+        },
+        "end": {
+          "tableId": "M0_u-aSCZODbw1yxM1xbr",
+          "columnIds": [
+            "ZluLEI9WZjJm38V1HbyDw"
+          ],
+          "x": 984.9179,
+          "y": 1646.3919,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780900032320,
+          "createAt": 1780900032320
+        }
+      },
+      "yTS9hf-TmRFFp-AlPBk7F": {
+        "id": "yTS9hf-TmRFFp-AlPBk7F",
+        "identification": false,
+        "relationshipType": 2,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+          "columnIds": [
+            "jeoER3USmy3PxmFh_gTZI"
+          ],
+          "x": 465.2875,
+          "y": 1257.326,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+          "columnIds": [
+            "zzigmS2eus6Dqmq6B0T8f"
+          ],
+          "x": 676.9439,
+          "y": 1643.6346,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780900611085,
+          "createAt": 1780900611085
+        }
+      },
+      "-W2zak31bgJwKqTSi4rfs": {
+        "id": "-W2zak31bgJwKqTSi4rfs",
+        "identification": false,
+        "relationshipType": 2,
+        "startRelationshipType": 1,
+        "start": {
+          "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+          "columnIds": [
+            "jeoER3USmy3PxmFh_gTZI"
+          ],
+          "x": 278.7875,
+          "y": 1357.326,
+          "direction": 8
+        },
+        "end": {
+          "tableId": "M0_u-aSCZODbw1yxM1xbr",
+          "columnIds": [
+            "fgdsMrriRk3jn-w8yCWTD"
+          ],
+          "x": 262.5074,
+          "y": 1641.7407,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780900620399,
+          "createAt": 1780900620399
+        }
+      },
+      "o4qoNtcaO_NiHs5Z6SF67": {
+        "id": "o4qoNtcaO_NiHs5Z6SF67",
+        "identification": false,
+        "relationshipType": 2,
+        "startRelationshipType": 1,
+        "start": {
+          "tableId": "2sbVPAHfKmCEZ5M-oWOvb",
+          "columnIds": [
+            "jeoER3USmy3PxmFh_gTZI"
+          ],
+          "x": 465.2875,
+          "y": 1257.326,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "kx1Wu65aSaH1nPc3asZqQ",
+          "columnIds": [
+            "txDc37wfly679q2svCgzI"
+          ],
+          "x": 676.9439,
+          "y": 1643.6346,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780900865092,
+          "createAt": 1780900865092
         }
         }
       }
       }
     },
     },