Ver código fonte

[아이템관리] 완료

DESKTOP-T61HUSC\user 3 semanas atrás
pai
commit
85427b4961

+ 125 - 1
app/assets/scss/admin.scss

@@ -2268,7 +2268,24 @@ footer {
         min-width: 60px;
         padding: 12px 0;
         color: #1a2b4a;
-        
+
+      }
+    }
+    .admin--filter-radio{
+      display: flex;
+      gap: 6px;
+      .filter--btn{
+        width: 64px;
+        font-weight: 400;
+        min-width: 60px;
+        padding: 12px 0;
+        color: #1a2b4a;
+        transition: all 0.15s ease;
+        &.is-active{
+          background: var(--admin-accent-primary);
+          color: #fff;
+          border-color: var(--admin-accent-primary);
+        }
       }
     }
   }
@@ -2423,6 +2440,10 @@ footer {
         font-size: 12px;
         font-weight: 400;
       }
+      &.point{
+        color: #e85d3f;
+        font-weight: 700;
+      }
     }
 
     .admin--table-title {
@@ -2486,6 +2507,21 @@ footer {
     background: rgba(107, 114, 128, 0.1);
     color: #6b7280;
   }
+
+  &.item--ticket{
+    background-color: #E8F0FE;
+    color: #3c80f2;
+  }
+
+  &.item--point{
+    color: #e85d3f;
+    background-color: #FFF8DE;
+  }
+
+  &.item--badge{
+    color: #2DB672;
+    background-color: #E8F7ED;
+  }
 }
 
 // 작은 버튼
@@ -7383,6 +7419,16 @@ footer {
           width: 300px;
         }
       }
+      input[type=radio]{
+        appearance: none;
+        width: 18px;
+        height: 18px;
+        border: 1.5px solid #c8d5e6;
+        border-radius: 50%;
+        &:checked{
+          border: 5px solid #3C80F2;
+        }
+      }
     }
   }
 }
@@ -7429,6 +7475,69 @@ footer {
       }
     }
   }
+  &.bg--white{
+    background-color: #fff;
+    border: 1px solid var(--admin-border-color);
+    h3{
+      color: #1a2b4a;
+      margin-bottom: 15px;
+      padding-bottom: 13px;
+      border-bottom: 1px solid var(--admin-border-color);
+    }
+  }
+  .item--info--box{
+    display: flex;
+    gap: 24px;
+    justify-content: space-between;
+    .item--info{
+      display: flex;
+      flex-direction: column;
+      width: 33.3333%;
+      .item--badge{
+        display: inline-block;
+        padding: 4px 12px;
+        border-radius: 12px;
+        font-size: 12px;
+        font-weight: 700;
+        white-space: nowrap;
+        width: fit-content;
+        line-height: 1;
+        background-color: #E8F0FE;
+        border-radius: 12px;
+        color: #3c80f2;
+      }
+      p{
+        margin: 8px 0;
+        color: #666b75;
+        font-size: 12px;
+        font-weight: 400;
+      }
+      span{
+        color: #3c80f2;
+        font-weight: 600;
+        font-size: 12px;
+        font-weight: 600;
+      }
+      &:nth-child(2){
+        .item--badge{
+          color: #e85d3f;
+          background-color: #FFF8DE;
+        }
+        span{
+          color: #e85d3f;
+        }
+      }
+      &:nth-child(3){
+        .item--badge{
+          color: #2DB672;
+          background-color: #E8F7ED;
+        }
+        span{
+          color: #2DB672;
+        }
+      }
+    }
+  }
 }
 .admin--inf--box{
   margin-top: 16px;
@@ -7438,6 +7547,21 @@ footer {
   }
 }
 
+.item--thumb {
+  width: 36px;
+  height: 36px;
+  border-radius: 6px;
+  overflow: hidden;
+  border: 1px solid #e8eaef;
+  margin: 0 auto;
+}
+.item--thumb img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
 // 선상 & 낚시터 사진 추가
 .onboard--photo-grid {
   display: flex;

+ 12 - 2
app/composables/useImage.js

@@ -17,9 +17,19 @@ export const useImage = () => {
       return path
     }
 
-    // 상대 경로인 경우 imageBase 붙이기
+    // 1순위: imageBase 설정값
     const imageBase = config.public.imageBase
-    return `${imageBase}${path}`
+    if (imageBase) return `${imageBase}${path}`
+
+    // 2순위(fallback): apiBase에서 origin 추출 (로컬 개발 환경 자동 대응)
+    const apiBase = config.public.apiBase
+    if (apiBase) {
+      try {
+        return `${new URL(apiBase).origin}${path}`
+      } catch (_) { /* noop */ }
+    }
+
+    return path
   }
 
   /**

+ 76 - 26
app/layouts/admin.vue

@@ -24,7 +24,7 @@
     },
     {
       id: "field",
-      title: "🗺️ 분야 및 지역관리",
+      title: "🗺️ 분야 및 지역 관리",
       children: [
         {
           title: "낚시분야",
@@ -54,6 +54,51 @@
         }
       ],
     },
+    {
+      id: "challenge",
+      title: "🏆 챌린지 관리",
+      path: "/site-manager/challenge/list",
+      pattern: /^\/site-manager\/challenge\/(list|create|edit|detail)/,
+    },
+    {
+      id: "quest",
+      title: "🏅 퀘스트 관리",
+      path: "/site-manager/quest/list",
+      pattern: /^\/site-manager\/quest\/(list|create|edit|detail)/,
+    },
+    {
+      id: "item",
+      title: "🎁 아이템 관리",
+      path: "/site-manager/item/list",
+      pattern: /^\/site-manager\/item\/(list|create|edit|detail)/,
+    },
+    {
+      id: "species",
+      title: "🐟 어종 관리",
+      children: [
+        {
+          title: "어종구분",
+          path: "/site-manager/species/list",
+          pattern: /^\/site-manager\/species\/(list|create|edit|detail)/,
+        },
+        {
+          title: "챌린지 어종관리",
+          path: "/site-manager/challenge_species/list",
+          pattern: /^\/site-manager\/challenge_species\/(list|create|edit|detail)/,
+        },
+        {
+          title: "퀘스트 어종관리",
+          path: "/site-manager/quest_species/list",
+          pattern: /^\/site-manager\/quest_species\/(list|create|edit|detail)/,
+        }
+      ],
+    },
+    {
+      id: "user",
+      title: "👥 회원 관리",
+      path: "/site-manager/user/list",
+      pattern: /^\/site-manager\/user\/(list|create|edit|detail)/,
+    },
   ]);
 
   // 메뉴 토글
@@ -66,14 +111,10 @@
     }
   };
 
-  // 현재 활성 라우트 체크 (단일 path 메뉴 — children 없는 메뉴용)
-  const isActiveRoute = (path) => {
-    return route.path === path;
-  };
-
-  // 서브메뉴 활성 여부 (pattern 우선, 없으면 path 정확 일치)
+  // 메뉴 활성 여부 (pattern 우선, 없으면 path 정확 일치)
+  // children 없는 단일 메뉴, 서브메뉴 모두에 사용
   const isSubmenuActive = (item) => {
-    if (item?.pattern && item.pattern.test(route.path)) return true;
+    if (item?.pattern instanceof RegExp && item.pattern.test(route.path)) return true;
     return route.path === item?.path;
   };
 
@@ -89,6 +130,9 @@
     for (const menu of menuItems.value) {
       // children이 없는 단일 메뉴는 자기 자신이 매칭 대상
       if (!menu.children) {
+        if (menu.pattern instanceof RegExp && menu.pattern.test(currentPath)) {
+          return { menu, child: { title: menu.title, path: menu.path } };
+        }
         if (menu.path && currentPath === menu.path) {
           return { menu, child: { title: menu.title, path: menu.path } };
         }
@@ -97,7 +141,7 @@
 
       for (const child of menu.children) {
         // pattern이 있으면 정규식으로 매칭, 없으면 정확히 일치하는지 확인
-        if (child.pattern && child.pattern.test(currentPath)) {
+        if (child.pattern instanceof RegExp && child.pattern.test(currentPath)) {
           return { menu, child };
         } else if (currentPath === child.path) {
           return { menu, child };
@@ -159,27 +203,33 @@
     }
 
     const { menu, child } = findCurrentMenu();
-
-    if (menu && child) {
-      // 메뉴 그룹 추가
+    if (!menu || !child) return crumbs;
+
+    // 현재 페이지의 액션 라벨 (등록/수정/상세/인쇄 등)
+    let subLabel = null;
+    if (currentPath.includes("/create")) subLabel = "등록";
+    else if (currentPath.includes("/edit/")) subLabel = "수정";
+    else if (currentPath.includes("/detail/")) subLabel = "상세";
+    else if (currentPath.includes("/print/")) subLabel = "인쇄";
+    else if (currentPath.includes("/print-a2")) subLabel = "인쇄 (A2)";
+
+    if (menu.children) {
+      // 서브메뉴 있는 메뉴: 메뉴그룹 → 서브메뉴 → (액션)
       crumbs.push({ title: menu.title, path: null });
-
-      // 현재 페이지 타이틀 추가
-      if (currentPath.includes("/create")) {
+      if (subLabel) {
         crumbs.push({ title: child.title, path: child.path });
-        crumbs.push({ title: "등록", path: null });
-      } else if (currentPath.includes("/edit/")) {
-        crumbs.push({ title: child.title, path: child.path });
-        crumbs.push({ title: "수정", path: null });
-      } else if (currentPath.includes("/print/")) {
-        crumbs.push({ title: child.title, path: child.path });
-        crumbs.push({ title: "인쇄", path: null });
-      } else if (currentPath.includes("/print-a2")) {
-        crumbs.push({ title: child.title, path: child.path });
-        crumbs.push({ title: "인쇄 (A2)", path: null });
+        crumbs.push({ title: subLabel, path: null });
       } else {
         crumbs.push({ title: child.title, path: null });
       }
+    } else {
+      // 단일 메뉴 (children 없음): 메뉴 → (액션) 만, 중복 없이
+      if (subLabel) {
+        crumbs.push({ title: menu.title, path: menu.path });
+        crumbs.push({ title: subLabel, path: null });
+      } else {
+        crumbs.push({ title: menu.title, path: null });
+      }
     }
 
     return crumbs;
@@ -362,7 +412,7 @@
               v-if="!menu.children"
               :to="menu.path"
               class="admin--gnb-title admin--gnb-title-link"
-              :class="{ 'is-active': isActiveRoute(menu.path) }"
+              :class="{ 'is-active': isSubmenuActive(menu) }"
             >
               {{ menu.title }}
             </NuxtLink>

+ 3 - 3
app/pages/site-manager/area/edit/[id].vue

@@ -49,8 +49,8 @@
 
       <!-- 버튼 영역 -->
       <div class="admin--form-actions">
-        <button type="button" class="admin--btn" @click="goToList">
-          ← 목록으로
+        <button type="button" class="admin--btn" @click="goToDetail">
+          ← 취소
         </button>
         <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
           {{ isSaving ? "저장 중..." : "저장" }}
@@ -137,7 +137,7 @@
   };
 
   // 이동
-  const goToList = () => router.push(`/site-manager/area/list`);
+    const goToDetail = () => router.push(`/site-manager/area/detail/${areaId}`);
 
   onMounted(() => {
     loadDetail();

+ 3 - 3
app/pages/site-manager/field/edit/[id].vue

@@ -72,8 +72,8 @@
 
       <!-- 버튼 영역 -->
       <div class="admin--form-actions">
-        <button type="button" class="admin--btn" @click="goToList">
-          ← 목록으로
+        <button type="button" class="admin--btn" @click="goToDetail">
+          ← 취소
         </button>
         <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
           {{ isSaving ? "저장 중..." : "저장" }}
@@ -172,7 +172,7 @@
   };
 
   // 이동
-  const goToList = () => router.push(`/site-manager/field/list`);
+    const goToDetail = () => router.push(`/site-manager/field/detail/${fieldId}`);
 
   onMounted(() => {
     loadDetail();

+ 260 - 0
app/pages/site-manager/item/create.vue

@@ -0,0 +1,260 @@
+<template>
+  <div class="admin--page-content">
+    <div class="admin--form">
+      <form @submit.prevent="handleSubmit">
+        <table class="admin--form--table">
+          <colgroup>
+            <col style="width: 120px;">
+            <col>
+          </colgroup>
+          <tbody>
+            <tr>
+              <th><div>아이템명 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model="formData.name"
+                    type="text"
+                    class="admin--form-input"
+                    placeholder="예: 등급 포인트 +100"
+                    required
+                  />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>구분 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <label class="admin--radio-label">
+                    <input type="radio" v-model="formData.type" value="T" /> 진출권
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.type" value="P" /> 포인트
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.type" value="B" /> 뱃지
+                  </label>
+                </div>
+              </td>
+            </tr>
+            <tr v-if="formData.type !== 'T'">
+              <th>
+                <div>
+                  포인트 <span v-if="formData.type === 'P'" class="admin--required">*</span>
+                </div>
+              </th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model.number="formData.point"
+                    type="number"
+                    min="0"
+                    class="admin--form-input w--200"
+                    placeholder="예: 100"
+                  />
+                </div>
+                <p class="mt--10" v-if="formData.type === 'P'">아이템 구분이 "포인트"인 경우 필수 입력</p>
+                <p class="mt--10" v-else>아이템 구분이 "뱃지"인 경우 선택 입력</p>
+              </td>
+            </tr>
+            <tr>
+              <th><div>이미지</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    ref="imageInput"
+                    type="file"
+                    accept="image/*"
+                    class="admin--form-file-hidden"
+                    @change="onImageChange"
+                  />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
+                    이미지 선택
+                  </button>
+                  <span v-if="image" class="ml--16">{{ image.file.name }}</span>
+                </div>
+                <p class="mt--10">JPG/PNG/GIF/WebP, 10MB 이하 (선택 사항)</p>
+                <div v-if="image" class="onboard--photo-grid mt--10">
+                  <div class="onboard--photo-item">
+                    <img :src="image.preview" alt="미리보기" />
+                    <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
+                  </div>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>상태</div></th>
+              <td>
+                <div class="input--wrap">
+                  <label class="admin--radio-label">
+                    <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
+                  </label>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <div class="admin--info--box bg--white">
+          <h3>💡 아이템 구분별 지급 조건</h3>
+          <div class="item--info--box">
+            <div class="item--info">
+              <span class="item--badge">진출권</span>
+              <p>챌린지 진행 시 사용자가 조건의 물고기를 획득하면 다음 라운드에 진출할 수 있는 아이템</p>
+              <span>ㆍ포인트 필드 미사용</span>
+            </div>
+            <div class="item--info">
+              <span class="item--badge">포인트</span>
+              <p>챌린지에서 조건의 물고기를 획득하면 지급. 사용자 등급 포인트를 지급해주는 아이템</p>
+              <span>ㆍ포인트 필드 필수</span>
+            </div>
+            <div class="item--info">
+              <span class="item--badge">뱃지</span>
+              <p>퀘스트 진행 시 조건 달성한 사용자에게 지급되는 아이템</p>
+              <span>ㆍ포인트 필드 사용 가능</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 버튼 영역 -->
+        <div class="admin--form-actions">
+          <button type="button" class="admin--btn" @click="goToList">
+            ← 목록으로
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+            {{ isSaving ? "저장 중..." : "저장" }}
+          </button>
+        </div>
+
+        <!-- 성공/에러 메시지 -->
+        <div v-if="successMessage" class="admin--alert admin--alert-success">
+          {{ successMessage }}
+        </div>
+        <div v-if="errorMessage" class="admin--alert admin--alert-error">
+          {{ errorMessage }}
+        </div>
+      </form>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, watch } from "vue";
+  import { useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { upload } = useApi();
+
+  const isSaving = ref(false);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+
+  const formData = ref({
+    name: "",
+    type: "T",        // T(진출권) / P(포인트) / B(뱃지)
+    point: null,
+    status_YN: "Y",
+  });
+
+  // 이미지 1장 — { file, preview }
+  const imageInput = ref(null);
+  const image = ref(null);
+  const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
+
+  const triggerImageInput = () => imageInput.value?.click();
+
+  const onImageChange = (e) => {
+    const file = (e.target.files || [])[0];
+    e.target.value = "";
+    if (!file) return;
+    if (!file.type.startsWith("image/")) {
+      errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
+      return;
+    }
+    if (file.size > MAX_IMAGE_SIZE) {
+      errorMessage.value = "이미지가 10MB를 초과합니다.";
+      return;
+    }
+    if (image.value) URL.revokeObjectURL(image.value.preview);
+    image.value = { file, preview: URL.createObjectURL(file) };
+  };
+
+  const removeImage = () => {
+    if (image.value) {
+      URL.revokeObjectURL(image.value.preview);
+      image.value = null;
+    }
+  };
+
+  // 진출권으로 바뀌면 포인트 값 초기화 (UI는 v-if로 숨김)
+  watch(
+    () => formData.value.type,
+    (val) => {
+      if (val === "T") formData.value.point = null;
+    }
+  );
+
+  // 폼 제출
+  const handleSubmit = async () => {
+    successMessage.value = "";
+    errorMessage.value = "";
+
+    const name = formData.value.name.trim();
+    if (!name) return (errorMessage.value = "아이템명을 입력하세요.");
+    if (name.length > 50) return (errorMessage.value = "아이템명은 50자 이내로 입력하세요.");
+
+    if (!["T", "P", "B"].includes(formData.value.type)) {
+      return (errorMessage.value = "구분을 선택하세요.");
+    }
+
+    // 포인트 검증
+    if (formData.value.type === "P") {
+      if (formData.value.point === null || formData.value.point === "" || Number(formData.value.point) < 0) {
+        return (errorMessage.value = "포인트를 입력하세요.");
+      }
+    } else if (formData.value.type === "B") {
+      if (formData.value.point !== null && formData.value.point !== "" && Number(formData.value.point) < 0) {
+        return (errorMessage.value = "포인트는 0 이상의 숫자여야 합니다.");
+      }
+    }
+
+    isSaving.value = true;
+    try {
+      const fd = new FormData();
+      fd.append("name", name);
+      fd.append("type", formData.value.type);
+      if (formData.value.type !== "T" && formData.value.point !== null && formData.value.point !== "") {
+        fd.append("point", String(formData.value.point));
+      }
+      fd.append("status_YN", formData.value.status_YN);
+      if (image.value) fd.append("image", image.value.file);
+
+      const { data, error } = await upload("/item", fd);
+
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
+      } else {
+        successMessage.value = data.message || "아이템이 등록되었습니다.";
+        setTimeout(() => {
+          router.push("/site-manager/item/list");
+        }, 1000);
+      }
+    } catch (e) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Save error:", e);
+    } finally {
+      isSaving.value = false;
+    }
+  };
+
+  const goToList = () => router.push("/site-manager/item/list");
+</script>

+ 185 - 0
app/pages/site-manager/item/detail/[id].vue

@@ -0,0 +1,185 @@
+<template>
+  <div class="admin--page-content">
+    <div class="admin--form">
+      <table class="admin--form--table">
+        <colgroup>
+          <col style="width: 140px;">
+          <col>
+        </colgroup>
+        <tbody>
+          <tr>
+            <th><div>아이템명</div></th>
+            <td class="admin--table-title">{{ data.name || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>구분</div></th>
+            <td>
+              <span :class="['admin--badge', typeBadgeClass(data.type)]">{{ typeLabel(data.type) }}</span>
+            </td>
+          </tr>
+          <tr v-if="data.type !== 'T'">
+            <th><div>포인트</div></th>
+            <td>{{ data.point !== null && data.point !== undefined ? data.point + "P" : "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>이미지</div></th>
+            <td>
+              <div v-if="data.file_path" class="onboard--photo-item">
+                <img :src="getImageUrl(data.file_path)" :alt="data.file_name || data.name" />
+              </div>
+              <template v-else>-</template>
+            </td>
+          </tr>
+          <tr>
+            <th><div>상태</div></th>
+            <td>
+              <span :class="['admin--badge', data.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
+                {{ data.status_YN === "Y" ? "사용중" : "미사용" }}
+              </span>
+            </td>
+          </tr>
+          <tr>
+            <th><div>등록일</div></th>
+            <td>{{ formatDateTime(data.created_at) }}</td>
+          </tr>
+          <tr>
+            <th><div>최근 수정</div></th>
+            <td>{{ formatDateTime(data.updated_at) }}</td>
+          </tr>
+        </tbody>
+      </table>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button type="button" class="admin--btn" @click="goToList">
+          ← 목록으로
+        </button>
+        <button type="button" class="admin--btn admin--btn-red-border ml--auto" @click="handleDelete">
+          삭제
+        </button>
+        <button type="button" class="admin--btn admin--btn-red" @click="goToEdit">
+          수정
+        </button>
+      </div>
+
+      <!-- 알림 모달 -->
+      <AdminAlertModal
+        v-if="alertModal.show"
+        :title="alertModal.title"
+        :message="alertModal.message"
+        :type="alertModal.type"
+        @confirm="handleAlertConfirm"
+        @cancel="handleAlertCancel"
+        @close="closeAlertModal"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, onMounted } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+  const { get, del } = useApi();
+  const { getImageUrl } = useImage();
+
+  const itemId = route.params.id;
+
+  const data = ref({
+    name: "",
+    type: "",
+    point: null,
+    file_name: "",
+    file_path: "",
+    status_YN: "Y",
+    created_at: "",
+    updated_at: "",
+  });
+
+  // 구분 라벨/뱃지
+  const typeLabel = (t) => (t === "T" ? "진출권" : t === "P" ? "포인트" : t === "B" ? "뱃지" : "-");
+  const typeBadgeClass = (t) =>
+    t === "T" ? "item--ticket" : t === "P" ? "item--point" : t === "B" ? "item--badge" : "";
+
+  // 알림 모달
+  const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
+  const showAlert = (message, title = "알림") => {
+    alertModal.value = { show: true, 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 loadDetail = async () => {
+    const { data: res, error } = await get(`/item/${itemId}`);
+    if (error || !res?.success) {
+      showAlert(error?.message || res?.message || "조회에 실패했습니다.", "오류");
+      return;
+    }
+    const row = res.data || {};
+    data.value = {
+      name: row.name ?? "",
+      type: row.type ?? "",
+      point: row.point ?? null,
+      file_name: row.file_name ?? "",
+      file_path: row.file_path ?? "",
+      status_YN: row.status_YN ?? "Y",
+      created_at: row.created_at ?? "",
+      updated_at: row.updated_at ?? "",
+    };
+  };
+
+  // 삭제
+  const handleDelete = () => {
+    showConfirm(
+      `'${data.value.name}' 아이템을 삭제하시겠습니까?`,
+      async () => {
+        const { data: res, error } = await del(`/item/${itemId}`);
+        if (error || !res?.success) {
+          showAlert(error?.message || res?.message || "삭제에 실패했습니다.", "오류");
+        } else {
+          showAlert(res.message || "삭제되었습니다.", "성공");
+          setTimeout(() => router.push("/site-manager/item/list"), 800);
+        }
+      },
+      "아이템 삭제"
+    );
+  };
+
+  // 이동
+  const goToList = () => router.push("/site-manager/item/list");
+  const goToEdit = () => router.push(`/site-manager/item/edit/${itemId}`);
+
+  // 일시 포맷
+  const formatDateTime = (dateString) => {
+    if (!dateString) return "-";
+    const date = new Date(dateString.replace(" ", "T"));
+    if (isNaN(date.getTime())) return dateString;
+    return date.toLocaleString("ko-KR", {
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  };
+
+  onMounted(() => {
+    loadDetail();
+  });
+</script>

+ 310 - 0
app/pages/site-manager/item/edit/[id].vue

@@ -0,0 +1,310 @@
+<template>
+  <div class="admin--page-content">
+    <div v-if="isLoading" class="admin--loading">데이터를 불러오는 중...</div>
+    <div v-else class="admin--form">
+      <form @submit.prevent="handleSubmit">
+        <table class="admin--form--table">
+          <colgroup>
+            <col style="width: 120px;">
+            <col>
+          </colgroup>
+          <tbody>
+            <tr>
+              <th><div>아이템명 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.name" type="text" class="admin--form-input" placeholder="예: 등급 포인트 +100" required />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>구분 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <label class="admin--radio-label">
+                    <input type="radio" v-model="formData.type" value="T" /> 진출권
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.type" value="P" /> 포인트
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.type" value="B" /> 뱃지
+                  </label>
+                </div>
+              </td>
+            </tr>
+            <tr v-if="formData.type !== 'T'">
+              <th>
+                <div>
+                  포인트 <span v-if="formData.type === 'P'" class="admin--required">*</span>
+                </div>
+              </th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model.number="formData.point" type="number" min="0" class="admin--form-input w--200" placeholder="예: 100" />
+                </div>
+                <p class="mt--10" v-if="formData.type === 'P'">아이템 구분이 "포인트"인 경우 필수 입력</p>
+                <p class="mt--10" v-else>아이템 구분이 "뱃지"인 경우 선택 입력</p>
+              </td>
+            </tr>
+            <tr>
+              <th><div>이미지</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    ref="imageInput"
+                    type="file"
+                    accept="image/*"
+                    class="admin--form-file-hidden"
+                    @change="onImageChange"
+                  />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
+                    이미지 선택
+                  </button>
+                  <span v-if="newImage" class="ml--16">{{ newImage.file.name }} (새 이미지)</span>
+                </div>
+                <p class="mt--10">JPG/PNG/GIF/WebP, 10MB 이하. 새 이미지 선택 시 기존 이미지가 교체됩니다.</p>
+                <!-- 새 이미지 미리보기 (선택 시) -->
+                <div v-if="newImage" class="onboard--photo-item mt--10">
+                  <img :src="newImage.preview" alt="새 이미지 미리보기" />
+                  <button type="button" class="onboard--photo-remove" @click="cancelNewImage"></button>
+                  <span class="onboard--photo-new">NEW</span>
+                </div>
+                <!-- 기존 이미지 (새 이미지 선택 안 했을 때만) -->
+                <div v-else-if="existingImagePath" class="onboard--photo-item mt--10">
+                  <img :src="getImageUrl(existingImagePath)" alt="기존 이미지" />
+                  <button type="button" class="onboard--photo-remove" @click="removeExistingImage"></button>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>상태</div></th>
+              <td>
+                <div class="input--wrap">
+                  <label class="admin--radio-label">
+                    <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
+                  </label>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <!-- 버튼 영역 -->
+        <div class="admin--form-actions">
+          <button type="button" class="admin--btn" @click="goToDetail">
+            ← 취소
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+            {{ isSaving ? "저장 중..." : "저장" }}
+          </button>
+        </div>
+
+        <!-- 성공/에러 메시지 -->
+        <div v-if="successMessage" class="admin--alert admin--alert-success">
+          {{ successMessage }}
+        </div>
+        <div v-if="errorMessage" class="admin--alert admin--alert-error">
+          {{ errorMessage }}
+        </div>
+      </form>
+    </div>
+
+    <!-- 알림 모달 -->
+    <AdminAlertModal
+      v-if="alertModal.show"
+      :title="alertModal.title"
+      :message="alertModal.message"
+      :type="alertModal.type"
+      @confirm="handleAlertConfirm"
+      @cancel="handleAlertCancel"
+      @close="closeAlertModal"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { ref, watch, onMounted } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+  const { get, put, upload, del } = useApi();
+  const { getImageUrl } = useImage();
+
+  const itemId = route.params.id;
+
+  const isLoading = ref(true);
+  const isSaving = ref(false);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+
+  const formData = ref({
+    name: "",
+    type: "T",
+    point: null,
+    status_YN: "Y",
+  });
+
+  // 이미지 상태
+  const imageInput = ref(null);
+  const existingImagePath = ref("");      // 기존 image_path
+  const removedExisting = ref(false);     // 기존 이미지 제거 의도
+  const newImage = ref(null);              // { file, preview }
+  const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
+
+  const triggerImageInput = () => imageInput.value?.click();
+
+  const onImageChange = (e) => {
+    const file = (e.target.files || [])[0];
+    e.target.value = "";
+    if (!file) return;
+    if (!file.type.startsWith("image/")) {
+      errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
+      return;
+    }
+    if (file.size > MAX_IMAGE_SIZE) {
+      errorMessage.value = "이미지가 10MB를 초과합니다.";
+      return;
+    }
+    if (newImage.value) URL.revokeObjectURL(newImage.value.preview);
+    newImage.value = { file, preview: URL.createObjectURL(file) };
+  };
+
+  const cancelNewImage = () => {
+    if (newImage.value) {
+      URL.revokeObjectURL(newImage.value.preview);
+      newImage.value = null;
+    }
+  };
+
+  const removeExistingImage = () => {
+    removedExisting.value = true;
+    existingImagePath.value = "";
+  };
+
+  // 진출권으로 바뀌면 포인트 초기화
+  watch(
+    () => formData.value.type,
+    (val) => {
+      if (val === "T") formData.value.point = null;
+    }
+  );
+
+  // 알림 모달
+  const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
+  const showAlert = (message, title = "알림") => {
+    alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
+  };
+  const closeAlertModal = () => { alertModal.value.show = false; };
+  const handleAlertConfirm = () => {
+    if (alertModal.value.onConfirm) alertModal.value.onConfirm();
+    closeAlertModal();
+  };
+  const handleAlertCancel = () => closeAlertModal();
+
+  // 기존 데이터 로드
+  const loadDetail = async () => {
+    const { data, error } = await get(`/item/${itemId}`);
+    if (error || !data?.success) {
+      errorMessage.value = error?.message || data?.message || "조회에 실패했습니다.";
+      isLoading.value = false;
+      return;
+    }
+    const row = data.data || {};
+    formData.value = {
+      name: row.name ?? "",
+      type: row.type ?? "T",
+      point: row.point ?? null,
+      status_YN: row.status_YN ?? "Y",
+    };
+    existingImagePath.value = row.file_path ?? "";
+    removedExisting.value = false;
+    newImage.value = null;
+    isLoading.value = false;
+  };
+
+  // 폼 제출
+  const handleSubmit = async () => {
+    successMessage.value = "";
+    errorMessage.value = "";
+
+    const name = formData.value.name.trim();
+    if (!name) return (errorMessage.value = "아이템명을 입력하세요.");
+    if (name.length > 50) return (errorMessage.value = "아이템명은 50자 이내로 입력하세요.");
+    if (!["T", "P", "B"].includes(formData.value.type)) {
+      return (errorMessage.value = "구분을 선택하세요.");
+    }
+    if (formData.value.type === "P") {
+      if (formData.value.point === null || formData.value.point === "" || Number(formData.value.point) < 0) {
+        return (errorMessage.value = "포인트를 입력하세요.");
+      }
+    } else if (formData.value.type === "B") {
+      if (formData.value.point !== null && formData.value.point !== "" && Number(formData.value.point) < 0) {
+        return (errorMessage.value = "포인트는 0 이상의 숫자여야 합니다.");
+      }
+    }
+
+    isSaving.value = true;
+    try {
+      // 1) 텍스트 필드 수정 (PUT)
+      const payload = {
+        name,
+        type: formData.value.type,
+        status_YN: formData.value.status_YN,
+      };
+      if (formData.value.type !== "T" && formData.value.point !== null && formData.value.point !== "") {
+        payload.point = Number(formData.value.point);
+      }
+      const { data, error } = await put(`/item/${itemId}`, payload);
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "수정에 실패했습니다.";
+        return;
+      }
+
+      // 2) 이미지 처리 — 새 이미지 우선, 그 다음 제거 의도
+      if (newImage.value) {
+        const fd = new FormData();
+        fd.append("image", newImage.value.file);
+        const { data: imgRes, error: imgErr } = await upload(`/item/${itemId}/image`, fd);
+        if (imgErr || !imgRes?.success) {
+          errorMessage.value = "아이템은 수정됐지만 이미지 교체에 실패했습니다.";
+          setTimeout(() => router.push(`/site-manager/item/detail/${itemId}`), 1500);
+          return;
+        }
+      } else if (removedExisting.value) {
+        const { data: rmRes, error: rmErr } = await del(`/item/${itemId}/image`);
+        if (rmErr || !rmRes?.success) {
+          errorMessage.value = "아이템은 수정됐지만 이미지 제거에 실패했습니다.";
+          setTimeout(() => router.push(`/site-manager/item/detail/${itemId}`), 1500);
+          return;
+        }
+      }
+
+      successMessage.value = data.message || "수정되었습니다.";
+      setTimeout(() => {
+        router.push(`/site-manager/item/detail/${itemId}`);
+      }, 1000);
+    } catch (e) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Update error:", e);
+    } finally {
+      isSaving.value = false;
+    }
+  };
+
+  const goToDetail = () => router.push(`/site-manager/item/detail/${itemId}`);
+
+  onMounted(() => {
+    loadDetail();
+  });
+</script>

+ 280 - 0
app/pages/site-manager/item/list.vue

@@ -0,0 +1,280 @@
+<template>
+  <div class="admin--field-list">
+    <!-- 상단 검색/액션 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <!-- <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
+          <option value="">전체 상태</option>
+          <option value="Y">사용중</option>
+          <option value="N">미사용</option>
+        </select> -->
+        <input
+          v-model="searchQuery"
+          type="text"
+          placeholder="아이템명으로 검색"
+          @keyup.enter="onSearch"
+          class="admin--form-input admin--search-input"
+        />
+        <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
+        <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
+        <div class="admin--filter-radio">
+          <button
+            type="button"
+            class="admin--btn-small admin--btn-small-secondary filter--btn"
+            :class="{ 'is-active': filterType === '' }"
+            @click="selectType('')"
+          >전체</button>
+          <button
+            type="button"
+            class="admin--btn-small admin--btn-small-secondary filter--btn"
+            :class="{ 'is-active': filterType === 'T' }"
+            @click="selectType('T')"
+          >진출권</button>
+          <button
+            type="button"
+            class="admin--btn-small admin--btn-small-secondary filter--btn"
+            :class="{ 'is-active': filterType === 'P' }"
+            @click="selectType('P')"
+          >포인트</button>
+          <button
+            type="button"
+            class="admin--btn-small admin--btn-small-secondary filter--btn"
+            :class="{ 'is-active': filterType === 'B' }"
+            @click="selectType('B')"
+          >뱃지</button>
+        </div>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn-add" @click="goToCreate">+ 새 아이템 추가</button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th style="width: 80px;">번호</th>
+            <th style="width: 120px;">이미지</th>
+            <th>아이템명</th>
+            <th style="width: 120px;">구분</th>
+            <th style="width: 120px;">포인트</th>
+            <th style="width: 120px;">상태</th>
+            <th style="width: 120px;">등록일</th>
+            <th style="width: 120px;">관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="8" class="admin--table-loading">데이터를 불러오는 중...</td>
+          </tr>
+          <tr v-else-if="!items || items.length === 0">
+            <td colspan="8" class="admin--table-empty">등록된 아이템이 없습니다.</td>
+          </tr>
+          <tr
+            v-else
+            v-for="(item, index) in items"
+            :key="item.id"
+            class="admin--table-row-clickable"
+            @click="goToDetail(item.id)"
+          >
+            <td class="date">{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td>
+              <div v-if="item.file_path" class="item--thumb">
+                <img :src="getImageUrl(item.file_path)" :alt="item.file_name || item.name" />
+              </div>
+              <template v-else>-</template>
+            </td>
+            <td class="admin--table-title">{{ item.name }}</td>
+            <td>
+              <span :class="['admin--badge', typeBadgeClass(item.type)]">{{ typeLabel(item.type) }}</span>
+            </td>
+            <td :class="[item.point ? 'point' : '']">{{ item.point !== null && item.point !== undefined ? item.point + "P" : "-" }}</td>
+            <td>
+              <span :class="['admin--badge', item.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
+                {{ item.status_YN === "Y" ? "사용중" : "미사용" }}
+              </span>
+            </td>
+            <td class="date">{{ formatDate(item.created_at) }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(item.id)">
+                  수정
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <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 { useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { get } = useApi();
+  const { getImageUrl } = useImage();
+
+  // 구분 라벨/뱃지
+  const typeLabel = (t) => (t === "T" ? "진출권" : t === "P" ? "포인트" : t === "B" ? "뱃지" : "-");
+  const typeBadgeClass = (t) =>
+    t === "T" ? "item--ticket" : t === "P" ? "item--point" : t === "B" ? "item--badge" : "";
+
+  const isLoading = ref(false);
+  const items = ref([]);
+  const currentPage = ref(1);
+  const perPage = ref(10);
+  const totalCount = ref(0);
+  const totalPages = ref(0);
+
+  const searchQuery = ref("");
+  const filterType = ref(""); // '', T, P, B
+
+  // 구분 필터 선택
+  const selectType = (t) => {
+    filterType.value = t;
+    currentPage.value = 1;
+    loadItems();
+  };
+
+  // 보이는 페이지 번호 계산
+  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;
+  });
+
+  // 데이터 로드
+  const loadItems = async () => {
+    isLoading.value = true;
+
+    const params = {
+      page: currentPage.value,
+      per_page: perPage.value,
+    };
+    if (searchQuery.value) params.search = searchQuery.value;
+    if (filterType.value) params.type = filterType.value;
+
+    const { data, error } = await get("/item/list", { params });
+
+    if (error) {
+      console.error("[ItemList] 목록 로드 실패:", 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 = "";
+    filterType.value = "";
+    currentPage.value = 1;
+    loadItems();
+  };
+
+  // 페이지 변경
+  const changePage = (page) => {
+    if (page < 1 || page > totalPages.value) return;
+    currentPage.value = page;
+    loadItems();
+    window.scrollTo({ top: 0, behavior: "smooth" });
+  };
+
+  // 이동
+  const goToCreate = () => router.push("/site-manager/item/create");
+  const goToDetail = (id) => router.push(`/site-manager/item/detail/${id}`);
+  const goToEdit = (id) => router.push(`/site-manager/item/edit/${id}`);
+
+  // 날짜 포맷
+  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(() => {
+    loadItems();
+  });
+</script>

+ 235 - 0
app/pages/site-manager/species/list.vue

@@ -0,0 +1,235 @@
+<template>
+  <div class="admin--field-list">
+    <!-- 상단 검색/액션 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
+          <option value="">전체</option>
+          <option value="Y">사용중</option>
+          <option value="N">미사용</option>
+        </select>
+        <input
+          v-model="searchQuery"
+          type="text"
+          placeholder="분야명으로 검색"
+          @keyup.enter="onSearch"
+          class="admin--form-input admin--search-input"
+        />
+        <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
+        <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn-add" @click="goToCreate">+ 새 분야 추가</button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th style="width: 80px;">번호</th>
+            <th style="width: 40%;">분야</th>
+            <th>가중치</th>
+            <th>등록일</th>
+            <th>상태</th>
+            <th style="width: 120px;">관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
+          </tr>
+          <tr v-else-if="!fields || fields.length === 0">
+            <td colspan="6" class="admin--table-empty">등록된 낚시분야가 없습니다.</td>
+          </tr>
+          <tr
+            v-else
+            v-for="(field, index) in fields"
+            :key="field.id"
+            class="admin--table-row-clickable"
+            @click="goToDetail(field.id)"
+          >
+            <td class="date">{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td class="admin--table-title">{{ field.name }}</td>
+            <td class="color--yellow">{{ field.weight }}</td>
+            <td class="date">{{ formatDate(field.created_at) }}</td>
+            <td>
+              <span :class="['admin--badge', getStatusBadgeClass(field.status_YN)]">
+                {{ getStatusLabel(field.status_YN) }}
+              </span>
+            </td>
+            <td>
+              <div class="admin--table-actions">
+                <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(field.id)">
+                  수정
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <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 { useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { get } = useApi();
+
+  const isLoading = ref(false);
+  const fields = ref([]);
+  const currentPage = ref(1);
+  const perPage = ref(10);
+  const totalCount = ref(0);
+  const totalPages = ref(0);
+
+  const searchQuery = ref("");
+  const filterStatus = ref("");
+
+  // 보이는 페이지 번호 계산
+  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;
+  });
+
+  // 데이터 로드
+  const loadFields = async () => {
+    isLoading.value = true;
+
+    const params = {
+      page: currentPage.value,
+      per_page: perPage.value,
+    };
+    if (searchQuery.value) params.search = searchQuery.value;
+    if (filterStatus.value) params.status = filterStatus.value;
+
+    const { data, error } = await get("/field/list", { params });
+
+    if (error) {
+      console.error("[FieldList] 목록 로드 실패:", error);
+      fields.value = [];
+      totalCount.value = 0;
+      totalPages.value = 0;
+    } else if (data?.success && data?.data) {
+      fields.value = data.data.items || [];
+      totalCount.value = data.data.total || 0;
+      totalPages.value = data.data.total_pages || 0;
+    }
+
+    isLoading.value = false;
+  };
+
+  // 검색
+  const onSearch = () => {
+    currentPage.value = 1;
+    loadFields();
+  };
+
+  // 검색 초기화
+  const resetSearch = () => {
+    searchQuery.value = "";
+    filterStatus.value = "";
+    currentPage.value = 1;
+    loadFields();
+  };
+
+  // 페이지 변경
+  const changePage = (page) => {
+    if (page < 1 || page > totalPages.value) return;
+    currentPage.value = page;
+    loadFields();
+    window.scrollTo({ top: 0, behavior: "smooth" });
+  };
+
+  // 이동
+  const goToCreate = () => router.push("/site-manager/field/create");
+  const goToDetail = (id) => router.push(`/site-manager/field/detail/${id}`);
+  const goToEdit = (id) => router.push(`/site-manager/field/edit/${id}`);
+
+  // 상태 라벨 / 뱃지 클래스
+  const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
+  const getStatusBadgeClass = (status) =>
+    status === "Y" ? "admin--badge-active" : "admin--badge-ended";
+
+  // 날짜 포맷
+  const 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(() => {
+    loadFields();
+  });
+</script>

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

@@ -43,6 +43,15 @@ $routes->post('api/area', 'Api\FishingAreaController::create');
 $routes->put('api/area/(:num)', 'Api\FishingAreaController::update/$1');
 $routes->delete('api/area/(:num)', 'Api\FishingAreaController::delete/$1');
 
+// Item (아이템)
+$routes->get('api/item/list', 'Api\ItemController::index');
+$routes->get('api/item/(:num)', 'Api\ItemController::show/$1');
+$routes->post('api/item', 'Api\ItemController::create');
+$routes->put('api/item/(:num)', 'Api\ItemController::update/$1');
+$routes->post('api/item/(:num)/image', 'Api\ItemController::uploadImage/$1');
+$routes->delete('api/item/(:num)/image', 'Api\ItemController::deleteImage/$1');
+$routes->delete('api/item/(:num)', 'Api\ItemController::delete/$1');
+
 // Fishing (낚시터)
 $routes->get('api/fishing/list', 'Api\FishingController::index');
 $routes->get('api/fishing/(:num)', 'Api\FishingController::show/$1');

+ 406 - 0
backend/app/Controllers/Api/ItemController.php

@@ -0,0 +1,406 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class ItemController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'item';
+
+    /**
+     * 아이템 목록
+     * GET /api/item/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'));
+            $type   = trim((string) $this->request->getGet('type'));
+            $status = trim((string) $this->request->getGet('status'));
+
+            $db = $this->getDB();
+            $builder = $db->table($this->table);
+            $builder->where('deleted_YN', 'N');
+
+            if ($search !== '') {
+                $builder->like('name', $search);
+            }
+            if (in_array($type, ['T', 'P', 'B'], true)) {
+                $builder->where('type', $type);
+            }
+            if ($status === 'Y' || $status === 'N') {
+                $builder->where('status_YN', $status);
+            }
+
+            $total = $builder->countAllResults(false);
+
+            $items = $builder
+                ->select('id, name, type, point, file_name, file_path, status_YN, created_at, updated_at')
+                ->orderBy('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', 'ItemController index error: ' . $e->getMessage());
+            return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이템 상세 조회
+     * GET /api/item/:id
+     */
+    public function show($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $row = $this->getDB()->table($this->table)
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->get()
+                ->getRow();
+
+            if (!$row) {
+                return $this->respondError('해당 아이템을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            return $this->respondSuccess($row);
+        } catch (\Exception $e) {
+            log_message('error', 'ItemController show error: ' . $e->getMessage());
+            return $this->respondError('조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이템 수정 (텍스트 필드)
+     * PUT /api/item/:id    (JSON)
+     */
+    public function update($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getRawInput() ?? [];
+            }
+
+            $name   = trim((string) ($payload['name'] ?? ''));
+            $type   = trim((string) ($payload['type'] ?? ''));
+            $point  = $payload['point'] ?? null;
+            $status = trim((string) ($payload['status_YN'] ?? 'Y'));
+
+            if ($name === '') {
+                return $this->respondError('아이템명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 50) {
+                return $this->respondError('아이템명은 50자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!in_array($type, ['T', 'P', 'B'], true)) {
+                return $this->respondError('구분을 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $pointValue = null;
+            if ($type === 'P') {
+                if ($point === null || $point === '' || !is_numeric($point) || (int) $point < 0) {
+                    return $this->respondError('포인트를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                $pointValue = (int) $point;
+            } elseif ($type === 'B') {
+                if ($point !== null && $point !== '') {
+                    if (!is_numeric($point) || (int) $point < 0) {
+                        return $this->respondError('포인트는 0 이상의 숫자여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+                    }
+                    $pointValue = (int) $point;
+                }
+            }
+            $status = ($status === 'N') ? 'N' : 'Y';
+
+            $db = $this->getDB();
+            $exists = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
+            if ($exists === 0) {
+                return $this->respondError('해당 아이템을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'name'       => $name,
+                'type'       => $type,
+                'point'      => $pointValue,
+                'status_YN'  => $status,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            $row = $db->table($this->table)->where('id', (int) $id)->get()->getRow();
+            return $this->respondSuccess($row, '아이템이 수정되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ItemController update error: ' . $e->getMessage());
+            return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이템 이미지 교체
+     * POST /api/item/:id/image   (multipart: image)
+     */
+    public function uploadImage($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $db = $this->getDB();
+            $row = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow();
+            if (!$row) {
+                return $this->respondError('해당 아이템을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            $file = $this->request->getFile('image');
+            if (!$file || !$file->isValid()) {
+                return $this->respondError('이미지가 전송되지 않았습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+            $mime = $file->getMimeType();
+            if (!in_array($mime, $allowed, true)) {
+                return $this->respondError('이미지 형식이 올바르지 않습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $uploadPath = FCPATH . 'uploads/item/';
+            if (!is_dir($uploadPath)) {
+                mkdir($uploadPath, 0755, true);
+            }
+            $fileName = $file->getClientName();
+            $stored = $file->getRandomName();
+            $file->move($uploadPath, $stored);
+
+            // 기존 이미지 삭제
+            if (!empty($row->file_path)) {
+                $oldFull = FCPATH . ltrim($row->file_path, '/');
+                if (is_file($oldFull)) @unlink($oldFull);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'file_name'  => $fileName,
+                'file_path'  => '/uploads/item/' . $stored,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            $updated = $db->table($this->table)->where('id', (int) $id)->get()->getRow();
+            return $this->respondSuccess($updated, '이미지가 교체되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ItemController uploadImage error: ' . $e->getMessage());
+            return $this->respondError('이미지 업로드 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이템 이미지 제거
+     * DELETE /api/item/:id/image
+     */
+    public function deleteImage($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $db = $this->getDB();
+            $row = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow();
+            if (!$row) {
+                return $this->respondError('해당 아이템을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            if (!empty($row->file_path)) {
+                $full = FCPATH . ltrim($row->file_path, '/');
+                if (is_file($full)) @unlink($full);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'file_name'  => null,
+                'file_path'  => null,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            return $this->respondSuccess(null, '이미지가 제거되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ItemController deleteImage error: ' . $e->getMessage());
+            return $this->respondError('이미지 제거 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이템 삭제 (soft delete)
+     * DELETE /api/item/:id
+     */
+    public function delete($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $db = $this->getDB();
+            $exists = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
+            if ($exists === 0) {
+                return $this->respondError('해당 아이템을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'deleted_YN' => 'Y',
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            return $this->respondSuccess(null, '아이템이 삭제되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ItemController delete error: ' . $e->getMessage());
+            return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 아이템 등록
+     * POST /api/item   (multipart: name, type, point, status_YN, image)
+     */
+    public function create()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $name   = trim((string) $this->request->getPost('name'));
+            $type   = trim((string) $this->request->getPost('type'));    // T(진출권) / P(포인트) / B(뱃지)
+            $point  = $this->request->getPost('point');
+            $status = trim((string) $this->request->getPost('status_YN'));
+            $file   = $this->request->getFile('image');
+
+            // 필수 검증
+            if ($name === '') {
+                return $this->respondError('아이템명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 50) {
+                return $this->respondError('아이템명은 50자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!in_array($type, ['T', 'P', 'B'], true)) {
+                return $this->respondError('구분을 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // type 별 포인트 처리
+            // T(진출권): 포인트 무시 (null), P(포인트): 필수 + 양수, B(뱃지): 선택
+            $pointValue = null;
+            if ($type === 'P') {
+                if ($point === null || $point === '' || !is_numeric($point) || (int) $point < 0) {
+                    return $this->respondError('포인트를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                $pointValue = (int) $point;
+            } elseif ($type === 'B') {
+                if ($point !== null && $point !== '') {
+                    if (!is_numeric($point) || (int) $point < 0) {
+                        return $this->respondError('포인트는 0 이상의 숫자여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+                    }
+                    $pointValue = (int) $point;
+                }
+            }
+
+            $status = ($status === 'N') ? 'N' : 'Y';
+
+            // 이미지 업로드 (선택)
+            $fileName = null;
+            $filePath = null;
+            if ($file && $file->isValid()) {
+                $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+                $mime = $file->getMimeType();
+                if (!in_array($mime, $allowed, true)) {
+                    return $this->respondError('이미지 형식이 올바르지 않습니다. (JPG/PNG/GIF/WebP)', ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                $uploadPath = FCPATH . 'uploads/item/';
+                if (!is_dir($uploadPath)) {
+                    mkdir($uploadPath, 0755, true);
+                }
+                $fileName = $file->getClientName();
+                $stored = $file->getRandomName();
+                $file->move($uploadPath, $stored);
+                $filePath = '/uploads/item/' . $stored;
+            }
+
+            $insertData = [
+                'name'       => $name,
+                'type'       => $type,
+                'point'      => $pointValue,
+                'file_name'  => $fileName,
+                'file_path'  => $filePath,
+                'status_YN'  => $status,
+                'created_at' => date('Y-m-d H:i:s'),
+            ];
+
+            $db = $this->getDB();
+            if (!$db->table($this->table)->insert($insertData)) {
+                return $this->respondError('등록에 실패했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+
+            $newId = $db->insertID();
+            $row = $db->table($this->table)->where('id', $newId)->get()->getRow();
+
+            return $this->respondSuccess($row, '아이템이 등록되었습니다.', ResponseInterface::HTTP_CREATED);
+        } catch (\Exception $e) {
+            log_message('error', 'ItemController create error: ' . $e->getMessage());
+            return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+}

+ 525 - 3
db.vuerd.json

@@ -4,8 +4,8 @@
   "settings": {
     "width": 2000,
     "height": 2000,
-    "scrollTop": -482,
-    "scrollLeft": -454.3501,
+    "scrollTop": -1000,
+    "scrollLeft": -556,
     "zoomLevel": 0.97,
     "show": 431,
     "database": 4,
@@ -36,7 +36,9 @@
       "zUvkqgDCrDrdAi4llCexp",
       "cH4_5N71LdebT2IHo_Dd9",
       "f0HLDJeJHSxkXnVkJNkFN",
-      "Rm_FvXbyIhbNwaAzppHJH"
+      "Rm_FvXbyIhbNwaAzppHJH",
+      "dvwjXJtxKUI-09IaRj7WY",
+      "0qOhpokdRsP9PKwViW3I4"
     ],
     "relationshipIds": [
       "02Rf0D1riQbaw0LqkaD6r",
@@ -331,6 +333,86 @@
           "updateAt": 1780293768819,
           "createAt": 1780293637693
         }
+      },
+      "dvwjXJtxKUI-09IaRj7WY": {
+        "id": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "item",
+        "comment": "아이템",
+        "columnIds": [
+          "0R217dUTcov8Wf981Tyqm",
+          "TbKi32F4Y921IKQSzqI0x",
+          "ltOGKGUgfgDNfgc-apGio",
+          "GbOj7X9vREiTuX-pm8yMp",
+          "PAnypwPiYpwMsDZtvZn_u",
+          "JiRdMA7YTTw29YKOH1Rs4",
+          "1hON8-2NCXwnTqsilg1YQ",
+          "z71NSikFcuNNiroz9NuXP",
+          "fBGe3ovAGNtO-VQ61O3Uy",
+          "-6bgTEe_q870tmIqHw9M1"
+        ],
+        "seqColumnIds": [
+          "0R217dUTcov8Wf981Tyqm",
+          "TbKi32F4Y921IKQSzqI0x",
+          "ltOGKGUgfgDNfgc-apGio",
+          "GbOj7X9vREiTuX-pm8yMp",
+          "PAnypwPiYpwMsDZtvZn_u",
+          "acxTKUcGFrZyxg-a7AJDJ",
+          "Nrq6L_y6DPriV-et2z2ft",
+          "JiRdMA7YTTw29YKOH1Rs4",
+          "bpQxts-zmDUAnB-qSJlp1",
+          "rxXKCdZ8pKbU50IRf4BHn",
+          "xWvpSfDpcgiCksGNEHnKp",
+          "n3_f_Efft51yqbRrFu51x",
+          "1hON8-2NCXwnTqsilg1YQ",
+          "z71NSikFcuNNiroz9NuXP",
+          "fBGe3ovAGNtO-VQ61O3Uy",
+          "-6bgTEe_q870tmIqHw9M1"
+        ],
+        "ui": {
+          "x": 1231.289,
+          "y": 1030.9279,
+          "zIndex": 468,
+          "widthName": 60,
+          "widthComment": 60,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1780374834161,
+          "createAt": 1780299725141
+        }
+      },
+      "0qOhpokdRsP9PKwViW3I4": {
+        "id": "0qOhpokdRsP9PKwViW3I4",
+        "name": "species_type",
+        "comment": "어종구분",
+        "columnIds": [
+          "zrzX4czwgIrVMdPxnO-66",
+          "44ArXMoHz57DHQDiiXSX8",
+          "shPUMVKh6Vsw3ww8m2SEi",
+          "XeIFH6IWDqylVxN9MFVwC",
+          "C9F2vG1hx9OjZO7x1IC5z",
+          "UW5iGbQHdo2szbqff2XTV"
+        ],
+        "seqColumnIds": [
+          "zrzX4czwgIrVMdPxnO-66",
+          "44ArXMoHz57DHQDiiXSX8",
+          "shPUMVKh6Vsw3ww8m2SEi",
+          "XeIFH6IWDqylVxN9MFVwC",
+          "C9F2vG1hx9OjZO7x1IC5z",
+          "UW5iGbQHdo2szbqff2XTV"
+        ],
+        "ui": {
+          "x": 683.866,
+          "y": 1278.3505,
+          "zIndex": 608,
+          "widthName": 68,
+          "widthComment": 60,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1780383614742,
+          "createAt": 1780383472103
+        }
       }
     },
     "tableColumnEntities": {
@@ -1913,6 +1995,446 @@
           "updateAt": 1780293752321,
           "createAt": 1780293701853
         }
+      },
+      "0R217dUTcov8Wf981Tyqm": {
+        "id": "0R217dUTcov8Wf981Tyqm",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780302750588,
+          "createAt": 1780302739261
+        }
+      },
+      "acxTKUcGFrZyxg-a7AJDJ": {
+        "id": "acxTKUcGFrZyxg-a7AJDJ",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "original_name",
+        "comment": "",
+        "dataType": "",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 76,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780303047870,
+          "createAt": 1780302939791
+        }
+      },
+      "Nrq6L_y6DPriV-et2z2ft": {
+        "id": "Nrq6L_y6DPriV-et2z2ft",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "stored_name",
+        "comment": "",
+        "dataType": "",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 70,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780303057917,
+          "createAt": 1780303053662
+        }
+      },
+      "JiRdMA7YTTw29YKOH1Rs4": {
+        "id": "JiRdMA7YTTw29YKOH1Rs4",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "file_path",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364602012,
+          "createAt": 1780303059598
+        }
+      },
+      "bpQxts-zmDUAnB-qSJlp1": {
+        "id": "bpQxts-zmDUAnB-qSJlp1",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "file_size",
+        "comment": "",
+        "dataType": "",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780303069087,
+          "createAt": 1780303065876
+        }
+      },
+      "rxXKCdZ8pKbU50IRf4BHn": {
+        "id": "rxXKCdZ8pKbU50IRf4BHn",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "mime_type",
+        "comment": "",
+        "dataType": "",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780303083511,
+          "createAt": 1780303079593
+        }
+      },
+      "xWvpSfDpcgiCksGNEHnKp": {
+        "id": "xWvpSfDpcgiCksGNEHnKp",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "width",
+        "comment": "",
+        "dataType": "",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780303087027,
+          "createAt": 1780303084947
+        }
+      },
+      "n3_f_Efft51yqbRrFu51x": {
+        "id": "n3_f_Efft51yqbRrFu51x",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "height",
+        "comment": "",
+        "dataType": "",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780303091822,
+          "createAt": 1780303090130
+        }
+      },
+      "z71NSikFcuNNiroz9NuXP": {
+        "id": "z71NSikFcuNNiroz9NuXP",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364595352,
+          "createAt": 1780303098951
+        }
+      },
+      "fBGe3ovAGNtO-VQ61O3Uy": {
+        "id": "fBGe3ovAGNtO-VQ61O3Uy",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "updated_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 62,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364597497,
+          "createAt": 1780364584290
+        }
+      },
+      "TbKi32F4Y921IKQSzqI0x": {
+        "id": "TbKi32F4Y921IKQSzqI0x",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364905245,
+          "createAt": 1780364611732
+        }
+      },
+      "GbOj7X9vREiTuX-pm8yMp": {
+        "id": "GbOj7X9vREiTuX-pm8yMp",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "point",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364885348,
+          "createAt": 1780364617020
+        }
+      },
+      "PAnypwPiYpwMsDZtvZn_u": {
+        "id": "PAnypwPiYpwMsDZtvZn_u",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "file_name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364897160,
+          "createAt": 1780364626267
+        }
+      },
+      "ltOGKGUgfgDNfgc-apGio": {
+        "id": "ltOGKGUgfgDNfgc-apGio",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "type",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364831819,
+          "createAt": 1780364821072
+        }
+      },
+      "1hON8-2NCXwnTqsilg1YQ": {
+        "id": "1hON8-2NCXwnTqsilg1YQ",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "status_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "'Y'",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780364861134,
+          "createAt": 1780364847208
+        }
+      },
+      "-6bgTEe_q870tmIqHw9M1": {
+        "id": "-6bgTEe_q870tmIqHw9M1",
+        "tableId": "dvwjXJtxKUI-09IaRj7WY",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "'N'",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780374847177,
+          "createAt": 1780374834161
+        }
+      },
+      "zrzX4czwgIrVMdPxnO-66": {
+        "id": "zrzX4czwgIrVMdPxnO-66",
+        "tableId": "0qOhpokdRsP9PKwViW3I4",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780383503249,
+          "createAt": 1780383496357
+        }
+      },
+      "44ArXMoHz57DHQDiiXSX8": {
+        "id": "44ArXMoHz57DHQDiiXSX8",
+        "tableId": "0qOhpokdRsP9PKwViW3I4",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780383513607,
+          "createAt": 1780383507209
+        }
+      },
+      "shPUMVKh6Vsw3ww8m2SEi": {
+        "id": "shPUMVKh6Vsw3ww8m2SEi",
+        "tableId": "0qOhpokdRsP9PKwViW3I4",
+        "name": "sort_order",
+        "comment": "",
+        "dataType": "INT",
+        "default": "1",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780383536269,
+          "createAt": 1780383515908
+        }
+      },
+      "XeIFH6IWDqylVxN9MFVwC": {
+        "id": "XeIFH6IWDqylVxN9MFVwC",
+        "tableId": "0qOhpokdRsP9PKwViW3I4",
+        "name": "status_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "Y",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780383561260,
+          "createAt": 1780383531958
+        }
+      },
+      "UW5iGbQHdo2szbqff2XTV": {
+        "id": "UW5iGbQHdo2szbqff2XTV",
+        "tableId": "0qOhpokdRsP9PKwViW3I4",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "N",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780383576573,
+          "createAt": 1780383555367
+        }
+      },
+      "C9F2vG1hx9OjZO7x1IC5z": {
+        "id": "C9F2vG1hx9OjZO7x1IC5z",
+        "tableId": "0qOhpokdRsP9PKwViW3I4",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780383591546,
+          "createAt": 1780383578130
+        }
       }
     },
     "relationshipEntities": {

+ 48 - 2
info.md

@@ -68,12 +68,50 @@
   - **기간 검색** (등록일, DatePicker + 빠른 버튼: 오늘/7일/15일/1개월/3개월/1년)
   - 행 클릭 → 상세, "수정" 버튼 → edit
 
+### ⛵ 낚시터 관리 (Fishing)
+- **테이블**: `fishing` (id, field_id, area_id, name, operating_hours, fish_species, zip_code, address, address_detail, address_refer, lat, lng, bank_code, account_number(암호화), account_holder, partnership_YN, status_YN, deleted_YN, created_at, updated_at)
+- **테이블**: `fishing_photos` (FK → fishing, ON DELETE CASCADE, hard delete)
+- **페이지**: list / create / detail / edit
+- **API**:
+  - `GET /api/fishing/list` (분야·지역 JOIN, 검색·필터·페이지네이션)
+  - `GET /api/fishing/:id` (사진 목록 포함, 계좌번호 복호화)
+  - `POST /api/fishing` (계좌번호 암호화)
+  - `PUT /api/fishing/:id` (계좌번호 재암호화)
+  - `DELETE /api/fishing/:id` (soft delete)
+  - `POST /api/fishing/:id/photos` (다중 사진 업로드, MIME 검증)
+  - `DELETE /api/fishing/photo/:photoId` (파일 + DB hard delete)
+- **주요 기능**:
+  - 선상 관리와 동일한 패턴 (분야/지역 select, 우편번호 검색, 좌표 자동, 계좌 암호화, 사진 다중 업로드, 검색 4종 + 기간검색)
+  - **낚시터 전용 컬럼**: `operating_hours` (운영시간 자유 텍스트), `fish_species` (주요 어종, 콤마 구분 VARCHAR)
+  - **어종 검색** 지원: 검색대상 select에 "주요어종" 옵션 추가, `fs.fish_species LIKE` 매칭
+  - 비제휴 선택 시 계좌 행 자동 숨김
+  - 행 클릭 → 상세, "수정" 버튼 → edit
+
+### 🎁 아이템 관리 (Item)
+- **테이블**: `item` (id, name, type, point, file_name, file_path, status_YN, deleted_YN, created_at, updated_at)
+- **페이지**: list / create / detail / edit
+- **API**:
+  - `GET /api/item/list` (검색·구분 필터·페이지네이션)
+  - `GET /api/item/:id`
+  - `POST /api/item` (multipart — 이미지와 텍스트 한 번에)
+  - `PUT /api/item/:id` (텍스트 필드 수정, JSON)
+  - `POST /api/item/:id/image` (이미지 교체, multipart, 기존 파일 자동 삭제)
+  - `DELETE /api/item/:id/image` (이미지 제거)
+  - `DELETE /api/item/:id` (soft delete)
+- **주요 기능**:
+  - **구분 3종**: T(진출권) / P(포인트) / B(뱃지)
+  - **동적 폼**: 진출권 선택 시 포인트 행 자동 숨김 + 값 초기화
+  - **포인트 검증**: P 필수, B 선택 입력, T 미사용
+  - **단일 이미지 업로드**: 미리보기 + 교체/제거 분리 처리 (수정 시 새 이미지 > 제거 > 변경없음 우선순위)
+  - **검색**: 아이템명 입력 + 구분 필터 라디오 버튼 그룹(전체/진출권/포인트/뱃지)
+  - 행 클릭 → 상세, "수정" 버튼 → edit, soft delete
+
 ---
 
 ## 🟡 작업 예정 (메뉴)
 
-- ⛵ 낚시터 관리 (Fishing Spot)
 - 📊 대시보드 (현재 빈 페이지)
+- 🎯 챌린지 / 퀘스트 관리 (보상 아이템 M:N 관계)
 
 ---
 
@@ -96,7 +134,14 @@ encryption.key = hex2bin:...       # 계좌번호 암호화 키 (분실 시 복
 [backend/app/Config/App.php](backend/app/Config/App.php) — `appTimezone = 'Asia/Seoul'`
 
 ### 업로드 위치
-`backend/public/uploads/onboard/` (기존 패턴과 통일, 웹 직접 서빙)
+`backend/public/uploads/{도메인}/` (기존 패턴과 통일, 웹 직접 서빙)
+- 선상 사진: `backend/public/uploads/onboard/`
+- 낚시터 사진: `backend/public/uploads/fishing/`
+- 아이템 이미지: `backend/public/uploads/item/`
+
+### 이미지 URL 생성 ([useImage.js](app/composables/useImage.js))
+- 1순위: `NUXT_PUBLIC_IMAGE_BASE` 환경변수
+- 2순위(fallback): `NUXT_PUBLIC_API_BASE`의 origin 자동 추출 → **로컬에서 imageBase 비워도 이미지 표시됨**
 
 ---
 
@@ -113,3 +158,4 @@ ERD 새로 짜면서 기존 비즈니스 컨트롤러(Branch, Showroom, Service,
   - 종속 데이터(선상 사진): **hard delete** + 파일 직접 삭제
 - **TIMESTAMP 사용 시 주의** — MySQL이 첫 TIMESTAMP에 자동으로 `ON UPDATE CURRENT_TIMESTAMP` 부여하므로 `created_at`은 `DEFAULT CURRENT_TIMESTAMP`만, `updated_at`은 `NULL DEFAULT NULL`로 정의
 - **계좌번호 컬럼 길이** — 암호화하면 base64로 ~135자 → `VARCHAR(255)` 권장
+- **신규 SCSS 추가 시** — 어드민 페이지의 `<style scoped>` 안 만들고 [app/assets/scss/admin.scss](app/assets/scss/admin.scss) 에 통합 (일관성 + 관리 단순화)