DESKTOP-T61HUSC\user 1 месяц назад
Родитель
Сommit
367a385fbc

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

@@ -2011,7 +2011,7 @@ footer {
   .admin--form-select {
     width: 100%;
     padding: 12px;
-    background: var(--admin-bg-tertiary);
+    background: #fff;
     border: 1px solid var(--admin-border-color);
     border-radius: 6px;
     color: var(--admin-text-primary);
@@ -2020,9 +2020,9 @@ footer {
     transition: all 0.3s ease;
 
     &:focus {
-      outline: none;
-      border-color: var(--admin-accent-primary);
-      box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
+      // outline: none;
+      // border-color: var(--admin-accent-primary);
+      // box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
     }
 
     &::placeholder {
@@ -2032,6 +2032,7 @@ footer {
     &:disabled {
       opacity: 0.5;
       cursor: not-allowed;
+      background-color: var(--admin-bg-tertiary);
     }
   }
 
@@ -7683,7 +7684,8 @@ footer {
   }
 }
 
-.admin--info--box{
+.admin--info--box,
+.admin--inf--box{
   background-color: #fff8de;
   border-radius: 6px;
   margin-top: 32px;
@@ -7714,4 +7716,11 @@ footer {
       }
     }
   }
+}
+.admin--inf--box{
+  margin-top: 16px;
+  background-color: #F8F9FB;
+  h3{
+    color: #666B75;
+  }
 }

+ 2 - 2
app/layouts/admin.vue

@@ -33,8 +33,8 @@
         },
         {
           title: "지역관리",
-          path: "/site-manager/showroom/list",
-          pattern: /^\/site-manager\/showroom\/(list|create|edit)/,
+          path: "/site-manager/area/list",
+          pattern: /^\/site-manager\/area\/(list|create|edit|detail)/,
         },
       ],
     },

+ 129 - 0
app/pages/site-manager/area/create.vue

@@ -0,0 +1,129 @@
+<template>
+  <div class="admin--page-content">
+    <div class="admin--form">
+      <form @submit.prevent="handleSubmit" class="">
+        <!-- 낚시분야 -->
+        <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="예: 부산/울산"
+                  required
+                  />
+                  <p>시도 단위 지역명. 1~20자. 인접 시도 통합 시 슬래시(/) 사용 (예: 충남/세종)</p>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+  
+        <div class="admin--info--box">
+          <h3>💡 지역 등록 안내</h3>
+          <ul>
+            <li>지역 = 회원 가입 시 선호 지역 선택 + 낚시어선/낚시터 분류 기준</li>
+            <li>중복 등록 차단. 인접 시도 통합 표기 권장 (예: "충남" 대신 "충남/세종")</li>
+          </ul>
+        </div>
+  
+        <!-- 버튼 영역 -->
+        <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="">
+            삭제
+          </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 } from "vue";
+  import { useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { post } = useApi();
+
+  const isSaving = ref(false);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+
+  const formData = ref({
+    name: "",
+  });
+
+  // 폼 제출
+  const handleSubmit = async () => {
+    successMessage.value = "";
+    errorMessage.value = "";
+
+    const name = formData.value.name.trim();
+
+    // 유효성 검사
+    if (!name) {
+      errorMessage.value = "지역명을 입력하세요.";
+      return;
+    }
+    if (name.length > 20) {
+      errorMessage.value = "지역명은 20자 이내로 입력하세요.";
+      return;
+    }
+
+    isSaving.value = true;
+
+    try {
+      const { data, error } = await post("/area", { name });
+
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
+      } else {
+        successMessage.value = data.message || "낚시지역이 등록되었습니다.";
+        setTimeout(() => {
+          router.push("/site-manager/area/list");
+        }, 1000);
+      }
+    } catch (error) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Save error:", error);
+    } finally {
+      isSaving.value = false;
+    }
+  };
+
+  // 목록으로 이동
+  const goToList = () => {
+    router.push("/site-manager/area/list");
+  };
+</script>

+ 152 - 0
app/pages/site-manager/area/detail/[id].vue

@@ -0,0 +1,152 @@
+<template>
+  <div class="admin--page-content">
+    <div class="admin--form">
+      <!-- 낚시지역 상세 -->
+      <table class="admin--form--table">
+        <colgroup>
+          <col style="width: 120px;">
+          <col>
+        </colgroup>
+        <tbody>
+          <tr>
+            <th><div>지역명</div></th>
+            <td>{{ formData.name || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>등록일</div></th>
+            <td>{{ formatDateTime(formData.created_at) }}</td>
+          </tr>
+          <tr>
+            <th><div>최근 수정</div></th>
+            <td>{{ formatDateTime(formData.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 areaId = route.params.id;
+
+  const formData = ref({
+    name: "",
+    created_at: "",
+    updated_at: "",
+  });
+
+  // 알림 모달
+  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, error } = await get(`/area/${areaId}`);
+
+    if (error || !data?.success) {
+      showAlert(error?.message || data?.message || "조회에 실패했습니다.", "오류");
+      return;
+    }
+
+    const row = data.data || {};
+    formData.value = {
+      name: row.name ?? "",
+      created_at: row.created_at ?? "",
+      updated_at: row.updated_at ?? "",
+    };
+  };
+
+  // 삭제
+  const handleDelete = () => {
+    showConfirm(
+      `'${formData.value.name}' 낚시지역을 삭제하시겠습니까?`,
+      async () => {
+        const { data, error } = await del(`/area/${areaId}`);
+        if (error || !data?.success) {
+          showAlert(error?.message || data?.message || "삭제에 실패했습니다.", "오류");
+        } else {
+          showAlert(data.message || "삭제되었습니다.", "성공");
+          setTimeout(() => router.push("/site-manager/area/list"), 800);
+        }
+      },
+      "낚시지역 삭제"
+    );
+  };
+
+  // 이동
+  const goToList = () => router.push("/site-manager/area/list");
+  const goToEdit = () => router.push(`/site-manager/area/edit/${areaId}`);
+
+  // 일시 포맷
+  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>

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

@@ -0,0 +1,145 @@
+<template>
+  <div class="admin--page-content">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 낚시지역 -->
+      <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="예: 부산/울산"
+                  required
+                />
+                <p>시도 단위 지역명. 1~20자. 인접 시도 통합 시 슬래시(/) 사용 (예: 충남/세종)</p>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      <div class="admin--info--box">
+        <h3>💡 지역 수정 안내</h3>
+        <ul>
+          <li>지역 = 회원 가입 시 선호 지역 선택 + 낚시어선/낚시터 분류 기준</li>
+          <li>중복 등록 차단. 인접 시도 통합 표기 권장 (예: "충남" 대신 "충남/세종")</li>
+        </ul>
+      </div>
+
+      <div class="admin--inf--box">
+        <h3>🔄 영향</h3>
+        <ul>
+          <li>이 지역에 등록된 낚시어선, 낚시터의 지역 표시 즉시 갱신</li>
+          <li>이 지역을 선호 지역으로 등록한 회원의 화면 표시 즉시 갱신</li>
+          <li>외부 시스템(공공 데이터 API) 연동된 낚시어선/낚시터는 동기화 필요</li>
+        </ul>
+      </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>
+</template>
+
+<script setup>
+  import { ref, onMounted } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+  const { get, put } = useApi();
+
+  const areaId = route.params.id;
+
+  const isSaving = ref(false);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+
+  const formData = ref({
+    name: "",
+  });
+
+  // 기존 데이터 로드
+  const loadDetail = async () => {
+    const { data, error } = await get(`/area/${areaId}`);
+
+    if (error || !data?.success) {
+      errorMessage.value = error?.message || data?.message || "조회에 실패했습니다.";
+      return;
+    }
+
+    const row = data.data || {};
+    formData.value = {
+      name: row.name ?? "",
+    };
+  };
+
+  // 폼 제출
+  const handleSubmit = async () => {
+    successMessage.value = "";
+    errorMessage.value = "";
+
+    const name = formData.value.name.trim();
+
+    if (!name) return (errorMessage.value = "지역명을 입력하세요.");
+    if (name.length > 20) return (errorMessage.value = "지역명은 20자 이내로 입력하세요.");
+
+    isSaving.value = true;
+    try {
+      const { data, error } = await put(`/area/${areaId}`, { name });
+
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "수정에 실패했습니다.";
+      } else {
+        successMessage.value = data.message || "수정되었습니다.";
+        setTimeout(() => {
+          router.push(`/site-manager/area/detail/${areaId}`);
+        }, 800);
+      }
+    } catch (e) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Update error:", e);
+    } finally {
+      isSaving.value = false;
+    }
+  };
+
+  // 이동
+  const goToList = () => router.push(`/site-manager/area/list`);
+
+  onMounted(() => {
+    loadDetail();
+  });
+</script>

+ 199 - 0
app/pages/site-manager/area/list.vue

@@ -0,0 +1,199 @@
+<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: 60%;">지역</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="date">{{ formatDate(field.created_at) }}</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
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+      >
+        이전
+      </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)"
+      >
+        다음
+      </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 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;
+
+    const { data, error } = await get("/area/list", { params });
+
+    if (error) {
+      console.error("[AreaList] 목록 로드 실패:", 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 = "";
+    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/area/create");
+  const goToDetail = (id) => router.push(`/site-manager/area/detail/${id}`);
+  const goToEdit = (id) => router.push(`/site-manager/area/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(() => {
+    loadFields();
+  });
+</script>

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

@@ -47,7 +47,7 @@
             </td>
           </tr>
           <tr>
-            <th><div>상태</div></th>
+            <th><div>상태  <span class="admin--required">*</span></div></th>
             <td>
               <div class="input--wrap">
                 <label class="admin--radio-label">

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

@@ -36,6 +36,13 @@ $routes->post('api/field', 'Api\FishingFieldController::create');
 $routes->put('api/field/(:num)', 'Api\FishingFieldController::update/$1');
 $routes->delete('api/field/(:num)', 'Api\FishingFieldController::delete/$1');
 
+// Fishing Area (낚시지역)
+$routes->get('api/area/list', 'Api\FishingAreaController::index');
+$routes->get('api/area/(:num)', 'Api\FishingAreaController::show/$1');
+$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');
+
 // File Upload
 $routes->post('api/upload/file', 'Api\UploadController::uploadFile');
 $routes->post('api/upload/image', 'Api\UploadController::uploadImage');

+ 261 - 0
backend/app/Controllers/Api/FishingAreaController.php

@@ -0,0 +1,261 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class FishingAreaController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'fishing_area';
+
+    /**
+     * 낚시지역 목록
+     * GET /api/area/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'));
+
+            $db = $this->getDB();
+            $builder = $db->table($this->table);
+
+            // soft delete 제외
+            $builder->where('deleted_YN', 'N');
+
+            if ($search !== '') {
+                $builder->like('name', $search);
+            }
+
+            $total = $builder->countAllResults(false);
+
+            $items = $builder
+                ->select('id, name, 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', 'FishingAreaController index error: ' . $e->getMessage());
+            return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시지역 등록
+     * POST /api/area
+     */
+    public function create()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getPost() ?? [];
+            }
+
+            $name = trim((string) ($payload['name'] ?? ''));
+
+            // 지역명 검증: 1~20자, 필수
+            if ($name === '') {
+                return $this->respondError('지역명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 20) {
+                return $this->respondError('지역명은 20자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+
+            // 중복 검사 (soft delete 제외)
+            $exists = $db->table($this->table)
+                ->where('name', $name)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($exists > 0) {
+                return $this->respondError('이미 등록된 지역명입니다.', ResponseInterface::HTTP_CONFLICT);
+            }
+
+            $insertData = [
+                'name'       => $name,
+                'created_at' => date('Y-m-d H:i:s'),
+            ];
+
+            if (!$db->table($this->table)->insert($insertData)) {
+                return $this->respondError('등록에 실패했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+
+            $newId = $db->insertID();
+            $row = $db->table($this->table)->where('id', $newId)->get()->getRow();
+
+            return $this->respondSuccess($row, '낚시지역이 등록되었습니다.', ResponseInterface::HTTP_CREATED);
+        } catch (\Exception $e) {
+            log_message('error', 'FishingAreaController create error: ' . $e->getMessage());
+            return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시지역 상세 조회
+     * GET /api/area/: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)
+                ->select('id, name, created_at, updated_at')
+                ->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', 'FishingAreaController show error: ' . $e->getMessage());
+            return $this->respondError('조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시지역 수정
+     * PUT /api/area/:id
+     */
+    public function update($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getRawInput() ?? [];
+            }
+
+            $name = trim((string) ($payload['name'] ?? ''));
+
+            // 지역명 검증
+            if ($name === '') {
+                return $this->respondError('지역명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 20) {
+                return $this->respondError('지역명은 20자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+
+            // 대상 행 존재 확인
+            $exists = $db->table($this->table)
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($exists === 0) {
+                return $this->respondError('해당 낚시지역을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            // 중복 검사 (자기 자신 제외)
+            $dupe = $db->table($this->table)
+                ->where('name', $name)
+                ->where('id !=', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($dupe > 0) {
+                return $this->respondError('이미 등록된 지역명입니다.', ResponseInterface::HTTP_CONFLICT);
+            }
+
+            $updateData = [
+                'name'       => $name,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ];
+
+            $db->table($this->table)->where('id', (int) $id)->update($updateData);
+
+            $row = $db->table($this->table)->where('id', (int) $id)->get()->getRow();
+            return $this->respondSuccess($row, '낚시지역이 수정되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'FishingAreaController update error: ' . $e->getMessage());
+            return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시지역 삭제 (soft delete)
+     * DELETE /api/area/: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', 'FishingAreaController delete error: ' . $e->getMessage());
+            return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+}

+ 119 - 7
db.vuerd.json

@@ -5,7 +5,7 @@
     "width": 2000,
     "height": 2000,
     "scrollTop": 0,
-    "scrollLeft": -23,
+    "scrollLeft": -21,
     "zoomLevel": 1,
     "show": 431,
     "database": 4,
@@ -77,20 +77,32 @@
       },
       "U1M3DNhPb8li5y4zccrUR": {
         "id": "U1M3DNhPb8li5y4zccrUR",
-        "name": "admin2",
-        "comment": "",
-        "columnIds": [],
-        "seqColumnIds": [],
+        "name": "fishing_area",
+        "comment": "낚시지역",
+        "columnIds": [
+          "9O46Rt9JdNS_X3fayZRJu",
+          "f8zBZhPsOcC-_cZhB-6L5",
+          "F9KvYIDxlbJlOrhpa1Bcn",
+          "HVPE2qCQ_2OT-F95MI1OX",
+          "GwpDGG7bv-oliz1jukS1a"
+        ],
+        "seqColumnIds": [
+          "9O46Rt9JdNS_X3fayZRJu",
+          "f8zBZhPsOcC-_cZhB-6L5",
+          "F9KvYIDxlbJlOrhpa1Bcn",
+          "HVPE2qCQ_2OT-F95MI1OX",
+          "GwpDGG7bv-oliz1jukS1a"
+        ],
         "ui": {
           "x": 552,
           "y": 97,
           "zIndex": 13,
-          "widthName": 60,
+          "widthName": 65,
           "widthComment": 60,
           "color": ""
         },
         "meta": {
-          "updateAt": 1779771469353,
+          "updateAt": 1779869237859,
           "createAt": 1779771453475
         }
       }
@@ -235,6 +247,106 @@
           "updateAt": 1779862694090,
           "createAt": 1779862680279
         }
+      },
+      "9O46Rt9JdNS_X3fayZRJu": {
+        "id": "9O46Rt9JdNS_X3fayZRJu",
+        "tableId": "U1M3DNhPb8li5y4zccrUR",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1779869043791,
+          "createAt": 1779869035317
+        }
+      },
+      "f8zBZhPsOcC-_cZhB-6L5": {
+        "id": "f8zBZhPsOcC-_cZhB-6L5",
+        "tableId": "U1M3DNhPb8li5y4zccrUR",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1779869060268,
+          "createAt": 1779869048665
+        }
+      },
+      "F9KvYIDxlbJlOrhpa1Bcn": {
+        "id": "F9KvYIDxlbJlOrhpa1Bcn",
+        "tableId": "U1M3DNhPb8li5y4zccrUR",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1779869218233,
+          "createAt": 1779869203848
+        }
+      },
+      "HVPE2qCQ_2OT-F95MI1OX": {
+        "id": "HVPE2qCQ_2OT-F95MI1OX",
+        "tableId": "U1M3DNhPb8li5y4zccrUR",
+        "name": "updated_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 62,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1779869229910,
+          "createAt": 1779869219267
+        }
+      },
+      "GwpDGG7bv-oliz1jukS1a": {
+        "id": "GwpDGG7bv-oliz1jukS1a",
+        "tableId": "U1M3DNhPb8li5y4zccrUR",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "'N'",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1779869255332,
+          "createAt": 1779869237859
+        }
       }
     },
     "relationshipEntities": {},