Browse Source

[지역관리] 지역별 해당 낚시어선 / 낚시터 기능 완료

DESKTOP-T61HUSC\user 2 weeks ago
parent
commit
1b6166d995

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

@@ -1882,6 +1882,32 @@ footer {
   border-radius: 8px;
   padding: 24px;
   border: 1px solid var(--admin-border-color);
+
+  .admin--sub--table--title{
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 25px;
+    color: #1a2b4a;
+    font-size: 16px;
+    font-weight: 700;
+    align-items: center;
+    .sub--table--info{
+      display: flex;
+      gap: 10px;
+      span{
+        padding: 4px 12px;
+        border-radius: 12px;
+        font-size: 12px;
+        font-weight: 600;
+        background-color: #E8F0FE;
+        color: #3c80f2;
+        &:last-child{
+          background-color: rgba(16, 185, 129, 0.1);
+          color: var(--admin-success);
+        }
+      }
+    }
+  }
 }
 
 // Admin Dashboard
@@ -2099,6 +2125,13 @@ footer {
     border-top: 1px solid var(--admin-border-color);
   }
 
+  .txt--btn{
+    color: #3c80f2;
+    font-weight: 600;
+    font-size: 14px;
+    cursor: pointer;
+  }
+
   .admin--btn {
     padding: 12px;
     border: none;
@@ -7852,4 +7885,31 @@ footer {
 .admin--toast-leave-from {
   transform: translate(-50%, 0);
   opacity: 1;
-}
+}
+
+.admin--place--btn--wrap{
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 24px;
+  .admin--pagination{
+    margin-top: 0;
+  }
+  .admin--btn {
+    padding: 12px;
+    border: none;
+    border-radius: 4px;
+    font-size: 13px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    min-width: 100px;
+    color: #666b75;
+    border: 1px solid #e8eaef;
+  
+    &:disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+  }
+}

+ 1 - 1
app/layouts/admin.vue

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

+ 155 - 45
app/pages/site-manager/area/detail/[id].vue

@@ -1,51 +1,116 @@
 <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>
+    <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>
+    <div class="admin--page-content mt--20">
+      <div class="admin--sub--table--title">
+        <p>해당 지역의 낚시어선 / 낚시터</p>
+        <div class="sub--table--info">
+          <span>🎣 낚시터 {{ fishingCount }}</span>
+          <span>🚢 낚시어선 {{ onboardCount }}</span>
+        </div>
+      </div>
+      <!-- 해당 지역의 어선 / 낚시터 -->
+      <div class="admin--table-wrapper">
+        <table class="admin--table">
+          <thead>
+            <tr>
+              <th style="width: 80px;">번호</th>
+              <th style="width: 180px;">구분</th>
+              <th>이름</th>
+              <th>주소</th>
+              <th>상태</th>
+              <th style="width: 120px;">등록일</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-if="isPlacesLoading">
+              <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
+            </tr>
+            <tr v-else-if="!places || places.length === 0">
+              <td colspan="6" class="admin--table-empty">해당 지역에 등록된 낚시어선/낚시터가 없습니다.</td>
+            </tr>
+            <tr
+              v-else
+              v-for="(p, index) in places"
+              :key="p.place_type + '-' + p.id"
+              class="admin--table-row-clickable"
+              @click="goToPlace(p)"
+            >
+              <td class="date">{{ (onboardCount + fishingCount) - index }}</td>
+              <td>
+                <span :class="['admin--badge', p.place_type === 'onboard' ? 'admin--badge-active' : 'admin--badge-html']">
+                  {{ p.place_type === "onboard" ? "낚시어선" : "낚시터" }}
+                </span>
+              </td>
+              <td class="admin--table-title">{{ p.name }}</td>
+              <td>{{ p.address || "-" }}</td>
+              <td>
+                <span :class="['admin--badge', p.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
+                  {{ p.status_YN === "Y" ? "사용중" : "미사용" }}
+                </span>
+              </td>
+              <td class="date">{{ formatDate(p.created_at) }}</td>
+            </tr>
+          </tbody>
+        </table>
       </div>
 
-      <!-- 알림 모달 -->
-      <AdminAlertModal
-        v-if="alertModal.show"
-        :title="alertModal.title"
-        :message="alertModal.message"
-        :type="alertModal.type"
-        @confirm="handleAlertConfirm"
-        @cancel="handleAlertCancel"
-        @close="closeAlertModal"
-      />
+      <!-- 전체보기 버튼 -->
+       <div class="admin--form" v-if="(onboardCount + fishingCount) > places.length">
+         <div class="admin--form-actions">
+           <button type="button" class="txt--btn ml--auto" @click="goToPlacesAll">
+             {{ onboardCount + fishingCount }}건 전체보기 ->
+            </button>
+          </div>
+        </div>
     </div>
   </div>
 </template>
@@ -72,6 +137,12 @@
     updated_at: "",
   });
 
+  // 해당 지역의 낚시어선 / 낚시터
+  const places = ref([]);
+  const onboardCount = ref(0);
+  const fishingCount = ref(0);
+  const isPlacesLoading = ref(false);
+
   // 알림 모달
   const alertModal = ref({
     show: false,
@@ -113,6 +184,16 @@
 
   // 삭제
   const handleDelete = () => {
+    // 연결된 데이터 있으면 차단
+    const total = onboardCount.value + fishingCount.value;
+    if (total > 0) {
+      showAlert(
+        `해당 지역에 등록된 낚시어선/낚시터가 있어 삭제할 수 없습니다.\n(낚시어선 ${onboardCount.value} / 낚시터 ${fishingCount.value})\n\n먼저 연결된 어선/낚시터를 다른 지역으로 옮기거나 삭제해 주세요.`,
+        "삭제 불가"
+      );
+      return;
+    }
+
     showConfirm(
       `'${formData.value.name}' 낚시지역을 삭제하시겠습니까?`,
       async () => {
@@ -131,6 +212,26 @@
   // 이동
   const goToList = () => router.push("/site-manager/area/list");
   const goToEdit = () => router.push(`/site-manager/area/edit/${areaId}`);
+  const goToPlace = (p) => {
+    if (p.place_type === "onboard") {
+      router.push(`/site-manager/onboard/detail/${p.id}`);
+    } else if (p.place_type === "fishing") {
+      router.push(`/site-manager/fishing/detail/${p.id}`);
+    }
+  };
+  const goToPlacesAll = () => router.push(`/site-manager/area/places/${areaId}`);
+
+  // 해당 지역의 낚시어선/낚시터 로드 (최근 등록순 8개 + 카운트)
+  const loadPlaces = async () => {
+    isPlacesLoading.value = true;
+    const { data, error } = await get(`/area/${areaId}/places`, { params: { limit: 8 } });
+    if (!error && data?.success && data?.data) {
+      places.value = data.data.items || [];
+      onboardCount.value = data.data.onboard_count || 0;
+      fishingCount.value = data.data.fishing_count || 0;
+    }
+    isPlacesLoading.value = false;
+  };
 
   // 일시 포맷
   const formatDateTime = (dateString) => {
@@ -146,7 +247,16 @@
     });
   };
 
+  // 날짜만
+  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(() => {
     loadDetail();
+    loadPlaces();
   });
 </script>

+ 186 - 0
app/pages/site-manager/area/places/[id].vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="admin--page-content">
+    <div class="admin--sub--table--title">
+      <p>{{ areaName ? `${areaName} 낚시어선 / 낚시터` : "낚시어선 / 낚시터" }}</p>
+      <div class="sub--table--info">
+        <span>🎣 낚시터 {{ fishingCount }}</span>
+        <span>🚢 낚시어선 {{ onboardCount }}</span>
+      </div>
+    </div>
+
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th style="width: 80px;">번호</th>
+            <th style="width: 180px;">구분</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="!places || places.length === 0">
+            <td colspan="6" class="admin--table-empty">해당 지역에 등록된 낚시어선/낚시터가 없습니다.</td>
+          </tr>
+          <tr
+            v-else
+            v-for="(p, index) in places"
+            :key="p.place_type + '-' + p.id"
+            class="admin--table-row-clickable"
+            @click="goToPlace(p)"
+          >
+            <td class="date">{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td>
+              <span :class="['admin--badge', p.place_type === 'onboard' ? 'admin--badge-active' : 'admin--badge-html']">
+                {{ p.place_type === "onboard" ? "낚시어선" : "낚시터" }}
+              </span>
+            </td>
+            <td class="admin--table-title">{{ p.name }}</td>
+            <td>{{ p.address || "-" }}</td>
+            <td>
+              <span :class="['admin--badge', p.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
+                {{ p.status_YN === "Y" ? "사용중" : "미사용" }}
+              </span>
+            </td>
+            <td class="date">{{ formatDate(p.created_at) }}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <div class="admin--place--btn--wrap">
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button type="button" class="admin--btn" @click="goBack">
+          ← 지역 상세로
+        </button>
+      </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>
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+  const { get } = useApi();
+
+  const areaId = route.params.id;
+
+  const isLoading = ref(false);
+  const places = ref([]);
+  const areaName = ref("");
+  const onboardCount = ref(0);
+  const fishingCount = ref(0);
+
+  const currentPage = ref(1);
+  const perPage = ref(10);
+  const totalCount = ref(0);
+  const totalPages = ref(0);
+
+  // 페이지네이션 표시 페이지 번호
+  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 loadPlaces = async () => {
+    isLoading.value = true;
+    const { data, error } = await get(`/area/${areaId}/places`, {
+      params: { page: currentPage.value, per_page: perPage.value },
+    });
+    if (!error && data?.success && data?.data) {
+      places.value = data.data.items || [];
+      areaName.value = data.data.area_name || "";
+      onboardCount.value = data.data.onboard_count || 0;
+      fishingCount.value = data.data.fishing_count || 0;
+      totalCount.value = data.data.total || 0;
+      totalPages.value = data.data.total_pages || 0;
+    }
+    isLoading.value = false;
+  };
+
+  // 페이지 변경
+  const changePage = (page) => {
+    if (page < 1 || page > totalPages.value) return;
+    currentPage.value = page;
+    loadPlaces();
+    window.scrollTo({ top: 0, behavior: "smooth" });
+  };
+
+  // 이동
+  const goToPlace = (p) => {
+    if (p.place_type === "onboard") {
+      router.push(`/site-manager/onboard/detail/${p.id}`);
+    } else if (p.place_type === "fishing") {
+      router.push(`/site-manager/fishing/detail/${p.id}`);
+    }
+  };
+  const goBack = () => router.push(`/site-manager/area/detail/${areaId}`);
+
+  // 날짜만
+  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(() => {
+    loadPlaces();
+  });
+</script>

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

@@ -38,6 +38,7 @@ $routes->delete('api/field/(:num)', 'Api\FishingFieldController::delete/$1');
 
 // Fishing Area (낚시지역)
 $routes->get('api/area/list', 'Api\FishingAreaController::index');
+$routes->get('api/area/(:num)/places', 'Api\FishingAreaController::places/$1');
 $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');

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

@@ -245,6 +245,18 @@ class FishingAreaController extends BaseApiController
                 return $this->respondError('해당 낚시지역을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
             }
 
+            // 해당 지역에 등록된 낚시어선/낚시터가 있으면 삭제 차단
+            $onboardCount = $db->table('onboard')
+                ->where('area_id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
+            $fishingCount = $db->table('fishing')
+                ->where('area_id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
+            if (($onboardCount + $fishingCount) > 0) {
+                return $this->respondError(
+                    "해당 지역에 등록된 낚시어선/낚시터가 있어 삭제할 수 없습니다. (낚시어선 {$onboardCount} / 낚시터 {$fishingCount})",
+                    ResponseInterface::HTTP_CONFLICT
+                );
+            }
+
             $db->table($this->table)
                 ->where('id', (int) $id)
                 ->update([
@@ -258,4 +270,85 @@ class FishingAreaController extends BaseApiController
             return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
         }
     }
+
+    /**
+     * 해당 지역에 속한 낚시어선 / 낚시터 통합 목록
+     * GET /api/area/:id/places?limit=8
+     */
+    public function places($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('지역 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $areaId = (int) $id;
+            $limit = (int) ($this->request->getGet('limit') ?? 0);   // 지정되면 단순 LIMIT 모드
+            $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;
+
+            $db = $this->getDB();
+
+            // 지역명
+            $area = $db->table($this->table)
+                ->select('name')
+                ->where('id', $areaId)->where('deleted_YN', 'N')
+                ->get()->getRow();
+            $areaName = $area ? $area->name : null;
+
+            // 카운트
+            $onboardCount = $db->table('onboard')
+                ->where('area_id', $areaId)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            $fishingCount = $db->table('fishing')
+                ->where('area_id', $areaId)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            $total = $onboardCount + $fishingCount;
+
+            // UNION ALL — 통합 정렬
+            if ($limit > 0) {
+                // 단순 LIMIT 모드 (detail 페이지의 8개 미리보기)
+                if ($limit > 1000) $limit = 1000;
+                $tail = "LIMIT {$limit}";
+                $totalPages = 1;
+            } else {
+                // 페이지네이션 모드 (전체보기 페이지)
+                $tail = "LIMIT {$perPage} OFFSET {$offset}";
+                $totalPages = (int) ceil($total / $perPage);
+            }
+
+            $sql = "(SELECT id, 'onboard' AS place_type, name, address, status_YN, created_at
+                     FROM onboard WHERE area_id = ? AND deleted_YN = 'N')
+                    UNION ALL
+                    (SELECT id, 'fishing' AS place_type, name, address, status_YN, created_at
+                     FROM fishing WHERE area_id = ? AND deleted_YN = 'N')
+                    ORDER BY created_at DESC
+                    {$tail}";
+            $items = $db->query($sql, [$areaId, $areaId])->getResult();
+
+            return $this->respondSuccess([
+                'items'         => $items,
+                'onboard_count' => $onboardCount,
+                'fishing_count' => $fishingCount,
+                'area_name'     => $areaName,
+                'total'         => $total,
+                'page'          => $page,
+                'per_page'      => $perPage,
+                'total_pages'   => $totalPages,
+            ]);
+        } catch (\Exception $e) {
+            log_message('error', 'FishingAreaController places error: ' . $e->getMessage());
+            return $this->respondError('조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
 }