Răsfoiți Sursa

[낚시터관리] 완료

DESKTOP-T61HUSC\user 3 săptămâni în urmă
părinte
comite
84ecaefdaf

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

@@ -2406,7 +2406,7 @@ footer {
 
     td {
       padding: 14px 16px;
-      font-size: 14px;
+      font-size: 13px;
       font-weight: 500;
       color: #1a2b4a;
       vertical-align: middle;

+ 2 - 0
app/layouts/admin.vue

@@ -135,6 +135,8 @@
         return `${child.title} 등록`;
       } else if (currentPath.includes("/edit/")) {
         return `${child.title} 수정`;
+      } else if (currentPath.includes("/detail/")) {
+        return `${child.title} 상세`;
       } else if (currentPath.includes("/print/")) {
         return `${child.title} 인쇄`;
       } else if (currentPath.includes("/print-a2")) {

+ 428 - 305
app/pages/site-manager/fishing/create.vue

@@ -1,317 +1,440 @@
 <template>
-  <div class="admin--showroom-form">
-    <form @submit.prevent="handleSubmit" class="admin--form">
-      <!-- 전시장명 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">전시장명 <span class="admin--required">*</span></label>
-        <input
-          v-model="formData.name"
-          type="text"
-          class="admin--form-input"
-          placeholder="전시장명을 입력하세요"
-          required
-        >
-      </div>
-
-      <!-- 소속명 선택 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">소속명 <span class="admin--required">*</span></label>
-        <select
-          v-model="formData.branch_id"
-          class="admin--form-select"
-          required
-        >
-          <option value="">소속 지점을 선택하세요</option>
-          <option v-for="branch in branches" :key="branch.id" :value="branch.id">
-            {{ branch.name }}
-          </option>
-        </select>
-      </div>
-
-      <!-- 대표번호 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">대표번호 <span class="admin--required">*</span></label>
-        <input
-          v-model="formData.phone"
-          type="tel"
-          class="admin--form-input"
-          placeholder="02-1234-5678"
-          required
-        >
-      </div>
-
-      <!-- 주소 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">주소 <span class="admin--required">*</span></label>
-        <input
-          v-model="formData.address"
-          type="text"
-          class="admin--form-input"
-          placeholder="주소를 입력하세요"
-          required
-        >
-      </div>
-
-      <!-- 상세주소 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">상세주소</label>
-        <input
-          v-model="formData.detail_address"
-          type="text"
-          class="admin--form-input"
-          placeholder="상세주소를 입력하세요"
-        >
-      </div>
-
-      <!-- 위도/경도 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">위치 좌표</label>
-        <div class="admin--coordinate-group">
-          <div class="admin--coordinate-item">
-            <label>위도</label>
-            <input
-              v-model.number="formData.latitude"
-              type="number"
-              step="any"
-              class="admin--form-input"
-              placeholder="37.5665"
-            >
-          </div>
-          <div class="admin--coordinate-item">
-            <label>경도</label>
-            <input
-              v-model.number="formData.longitude"
-              type="number"
-              step="any"
-              class="admin--form-input"
-              placeholder="126.9780"
-            >
-          </div>
-        </div>
-      </div>
-
-      <!-- 영업시간 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">영업시간</label>
-        <textarea
-          v-model="formData.business_hours"
-          class="admin--form-textarea"
-          rows="3"
-          placeholder="평일: 09:00 - 18:00&#10;주말: 10:00 - 17:00"
-        ></textarea>
-      </div>
-
-      <!-- 견적요청 링크 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">견적요청 링크</label>
-        <input
-          v-model="formData.quote_link"
-          type="url"
-          class="admin--form-input"
-          placeholder="https://example.com/quote"
-        >
-      </div>
-
-      <!-- 시승신청 링크 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">시승신청 링크</label>
-        <input
-          v-model="formData.test_drive_link"
-          type="url"
-          class="admin--form-input"
-          placeholder="https://example.com/test-drive"
-        >
-      </div>
-
-      <!-- 링크 관리 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">관련 링크</label>
-        <div class="admin--multi-input-wrapper">
-          <div
-            v-for="(link, index) in formData.links"
-            :key="index"
-            class="admin--multi-input-item"
-          >
-            <input
-              v-model="formData.links[index]"
-              type="url"
-              class="admin--form-input"
-              placeholder="https://example.com"
-            >
-            <button
-              type="button"
-              class="admin--btn-remove"
-              @click="removeLink(index)"
-            >
-              삭제
-            </button>
-          </div>
-          <button
-            type="button"
-            class="admin--btn-add"
-            @click="addLink"
-          >
-            + 링크 추가
+  <div class="admin--page-content">
+    <div class="admin--form">
+      <form @submit.prevent="handleSubmit">
+        <table class="admin--form--table">
+          <colgroup>
+            <col style="width: 140px;">
+            <col>
+          </colgroup>
+          <tbody>
+            <tr>
+              <th><div>분야 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="formData.field_id" class="admin--form-select w--240" required>
+                    <option value="">선택하세요</option>
+                    <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
+                  </select>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>지역 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="formData.area_id" class="admin--form-select w--240" required>
+                    <option value="">선택하세요</option>
+                    <option v-for="a in areaOptions" :key="a.id" :value="a.id">{{ a.name }}</option>
+                  </select>
+                </div>
+              </td>
+            </tr>
+            <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 />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>운영시간</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.operating_hours" type="text" class="admin--form-input w--full" placeholder="예: 하절기 07:00~16:00 / 동절기 08:00~16:00" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>주요 어종</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.fish_species" type="text" class="admin--form-input w--full" placeholder="예: 붕어, 잉어" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>우편번호</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.zip_code" type="text" class="admin--form-input w--160" placeholder="우편번호" readonly />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="openPostcode">
+                    우편번호 검색
+                  </button>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>주소</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.address" type="text" class="admin--form-input w--full" placeholder="우편번호 검색 시 자동 입력" readonly />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>상세주소</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.address_detail" type="text" class="admin--form-input w--full" placeholder="" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>참고항목</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.address_refer" type="text" class="admin--form-input w--full" placeholder="예: 선착장 입구 우측" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>좌표</div></th>
+              <td>
+                <div class="input--wrap admin--inline-group">
+                  <input v-model="formData.lat" type="text" class="admin--form-input w--200" placeholder="위도 (lat)" />
+                  <input v-model="formData.lng" type="text" class="admin--form-input w--200" placeholder="경도 (lng)" />
+                </div>
+                <p v-if="coordError" class="">{{ coordError }}</p>
+                <p v-else class="mt--10">주소를 검색하면 위도·경도가 자동 입력됩니다.</p>
+              </td>
+            </tr>
+            <tr>
+              <th><div>사진</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    ref="photoInput"
+                    type="file"
+                    accept="image/*"
+                    multiple
+                    class="admin--form-file-hidden"
+                    @change="onPhotoChange"
+                  />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerPhotoInput">
+                    사진 추가
+                  </button>
+                </div>
+                <p class="mt--10">JPG/PNG/WebP, 한 장당 10MB 이하. 첫 번째 사진이 대표 이미지로 사용됩니다.</p>
+                <div v-if="photos.length" class="mt--10 onboard--photo-grid">
+                  <div v-for="(p, i) in photos" :key="p.id" class="onboard--photo-item">
+                    <img :src="p.preview" alt="미리보기" />
+                    <button type="button" class="onboard--photo-remove" @click="removePhoto(i)"></button>
+                    <span v-if="i === 0" class="onboard--photo-badge">대표</span>
+                  </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.partnership_YN" value="Y" /> 제휴
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.partnership_YN" value="N" /> 비제휴
+                  </label>
+                </div>
+              </td>
+            </tr>
+            <tr v-if="isPartner">
+              <th><div>계좌번호</div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="formData.bank_code" class="admin--form-select w--120">
+                    <option value="">은행명</option>
+                    <option v-for="b in bankOptions" :key="b.code" :value="b.code">{{ b.name }}</option>
+                  </select>
+                  <input v-model="formData.account_number" type="text" class="admin--form-input w--300" placeholder="'-' 없이 숫자만 입력" />
+                  <input v-model="formData.account_holder" type="text" class="admin--form-input w--240" placeholder="예금주명" />
+                </div>
+                <p class="mt--10">정산 입금 계좌 (제휴 업체용)</p>
+              </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="goToList">
+            ← 목록으로
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+            {{ isSaving ? "저장 중..." : "저장" }}
           </button>
         </div>
-      </div>
-
-      <!-- 버튼 영역 -->
-      <div class="admin--form-actions">
-        <button
-          type="submit"
-          class="admin--btn admin--btn-primary"
-          :disabled="isSaving"
-        >
-          {{ isSaving ? '저장 중...' : '확인' }}
-        </button>
-        <button
-          type="button"
-          class="admin--btn admin--btn-secondary"
-          @click="goToList"
-        >
-          목록
-        </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 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, onMounted } from 'vue'
-import { useRouter } from 'vue-router'
-
-definePageMeta({
-  layout: 'admin',
-  middleware: ['auth']
-})
-
-const router = useRouter()
-const { get, post } = useApi()
-
-const isSaving = ref(false)
-const successMessage = ref('')
-const errorMessage = ref('')
-const branches = ref([])
-
-const formData = ref({
-  name: '',
-  branch_id: '',
-  phone: '',
-  address: '',
-  detail_address: '',
-  latitude: null,
-  longitude: null,
-  business_hours: '',
-  quote_link: '',
-  test_drive_link: '',
-  links: [''] // 초기 링크 하나
-})
-
-// 지점 목록 로드
-const loadBranches = async () => {
-  const { data, error } = await get('/branch/list', { params: { per_page: 1000 } })
-
-  if (data?.success && data?.data?.items) {
-    branches.value = data.data.items.filter(branch => branch.is_active == 1)
-  }
-}
-
-// 링크 추가
-const addLink = () => {
-  formData.value.links.push('')
-}
-
-// 링크 삭제
-const removeLink = (index) => {
-  formData.value.links.splice(index, 1)
-}
-
-// 폼 제출
-const handleSubmit = async () => {
-  successMessage.value = ''
-  errorMessage.value = ''
-
-  // 유효성 검사
-  if (!formData.value.name) {
-    errorMessage.value = '전시장명을 입력하세요.'
-    return
-  }
-
-  if (!formData.value.branch_id) {
-    errorMessage.value = '소속 지점을 선택하세요.'
-    return
-  }
-
-  if (!formData.value.phone) {
-    errorMessage.value = '대표번호를 입력하세요.'
-    return
-  }
-
-  if (!formData.value.address) {
-    errorMessage.value = '주소를 입력하세요.'
-    return
-  }
-
-  isSaving.value = true
-
-  try {
-    // 빈 링크 제거
-    const submitData = {
-      ...formData.value,
-      links: formData.value.links.filter(link => link.trim() !== '')
+  import { ref, computed, watch, onMounted } from "vue";
+  import { useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const config = useRuntimeConfig();
+  const { get, post, upload } = useApi();
+
+  const isSaving = ref(false);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+  const coordError = ref("");
+
+  // 분야 / 지역 select 옵션
+  const fieldOptions = ref([]);
+  const areaOptions = ref([]);
+
+  // 사진 업로드 (등록 시점엔 낚시터 id가 없어 파일을 보관만 함)
+  const photoInput = ref(null);
+  const photos = ref([]); // { id, file, preview }
+  const MAX_PHOTO_SIZE = 10 * 1024 * 1024; // 10MB
+
+  const triggerPhotoInput = () => photoInput.value?.click();
+
+  const onPhotoChange = (e) => {
+    const files = Array.from(e.target.files || []);
+    for (const file of files) {
+      if (!file.type.startsWith("image/")) continue;
+      if (file.size > MAX_PHOTO_SIZE) {
+        errorMessage.value = `'${file.name}' 파일이 10MB를 초과합니다.`;
+        continue;
+      }
+      photos.value.push({
+        id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
+        file,
+        preview: URL.createObjectURL(file),
+      });
+    }
+    e.target.value = ""; // 같은 파일 다시 선택 가능하게 초기화
+  };
+
+  const removePhoto = (index) => {
+    const [removed] = photos.value.splice(index, 1);
+    if (removed) URL.revokeObjectURL(removed.preview);
+  };
+
+  // 주요 은행 목록 (금융결제원 표준 코드)
+  const bankOptions = [
+    { code: "002", name: "산업은행" },
+    { code: "003", name: "기업은행" },
+    { code: "004", name: "국민은행" },
+    { code: "007", name: "수협은행" },
+    { code: "011", name: "농협은행" },
+    { code: "020", name: "우리은행" },
+    { code: "023", name: "SC제일은행" },
+    { code: "031", name: "대구은행" },
+    { code: "032", name: "부산은행" },
+    { code: "034", name: "광주은행" },
+    { code: "035", name: "제주은행" },
+    { code: "037", name: "전북은행" },
+    { code: "039", name: "경남은행" },
+    { code: "045", name: "새마을금고" },
+    { code: "071", name: "우체국" },
+    { code: "081", name: "하나은행" },
+    { code: "088", name: "신한은행" },
+    { code: "089", name: "케이뱅크" },
+    { code: "090", name: "카카오뱅크" },
+    { code: "092", name: "토스뱅크" },
+  ];
+
+  const formData = ref({
+    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: "N",
+    status_YN: "Y",
+  });
+
+  // 제휴 여부 — 비제휴면 계좌 입력 비활성화
+  const isPartner = computed(() => formData.value.partnership_YN === "Y");
+
+  // 비제휴로 전환 시 입력했던 계좌 정보 초기화
+  watch(
+    () => formData.value.partnership_YN,
+    (val) => {
+      if (val === "N") {
+        formData.value.bank_code = "";
+        formData.value.account_number = "";
+        formData.value.account_holder = "";
+      }
+    }
+  );
+
+  // 분야 / 지역 옵션 로드
+  const loadOptions = async () => {
+    const [fieldRes, areaRes] = await Promise.all([
+      get("/field/list", { params: { per_page: 1000 } }),
+      get("/area/list", { params: { per_page: 1000 } }),
+    ]);
+
+    // API는 id DESC(최신순)로 주므로 뒤집어서 먼저 등록한 순(수도권 등)이 위로 오게
+    if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
+    if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
+  };
+
+  // 외부 스크립트 동적 로드
+  const loadScript = (src) =>
+    new Promise((resolve, reject) => {
+      if (document.querySelector(`script[src="${src}"]`)) {
+        resolve();
+        return;
+      }
+      const s = document.createElement("script");
+      s.src = src;
+      s.onload = () => resolve();
+      s.onerror = () => reject(new Error(`스크립트 로드 실패: ${src}`));
+      document.head.appendChild(s);
+    });
+
+  // 주소 → 위도/경도 변환 (Google Geocoding API)
+  const searchCoords = async (address) => {
+    coordError.value = "";
+
+    const key = config.public.googleMapKey;
+    if (!key) {
+      coordError.value = "좌표를 자동으로 가져올 수 없습니다. 위도/경도를 직접 입력하세요.";
+      return;
     }
 
-    const { data, error } = await post('/showroom', submitData)
+    try {
+      const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
+      url.searchParams.set("address", address);
+      url.searchParams.set("key", key);
+      url.searchParams.set("language", "ko");
+      url.searchParams.set("region", "kr");
+
+      const res = await fetch(url);
+      const data = await res.json();
+
+      if (data.status === "OK" && data.results?.[0]) {
+        const loc = data.results[0].geometry.location;
+        formData.value.lat = String(loc.lat);
+        formData.value.lng = String(loc.lng);
+      } else {
+        formData.value.lat = "";
+        formData.value.lng = "";
+        coordError.value = "좌표를 찾지 못했습니다. 직접 입력해 주세요.";
+      }
+    } catch (e) {
+      console.error("Geocoding error:", e);
+      formData.value.lat = "";
+      formData.value.lng = "";
+      coordError.value = "좌표 조회 중 오류가 발생했습니다. 위도/경도를 직접 입력해주세요.";
+    }
+  };
+
+  // 우편번호 검색 (Daum Postcode)
+  const openPostcode = async () => {
+    coordError.value = "";
+    try {
+      await loadScript("https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js");
+    } catch (e) {
+      errorMessage.value = "우편번호 서비스를 불러오지 못했습니다.";
+      return;
+    }
 
-    if (error || !data?.success) {
-      errorMessage.value = error?.message || data?.message || '등록에 실패했습니다.'
-    } else {
-      successMessage.value = data.message || '전시장이 등록되었습니다.'
+    new window.daum.Postcode({
+      oncomplete: (data) => {
+        formData.value.zip_code = data.zonecode;
+        formData.value.address = data.roadAddress || data.jibunAddress;
+        // 선택한 주소로 좌표 자동 조회
+        searchCoords(formData.value.address);
+      },
+    }).open();
+  };
+
+  // 폼 제출
+  const handleSubmit = async () => {
+    successMessage.value = "";
+    errorMessage.value = "";
+
+    // 필수값 검증
+    if (!formData.value.field_id) return (errorMessage.value = "분야를 선택하세요.");
+    if (!formData.value.area_id) return (errorMessage.value = "지역을 선택하세요.");
+    if (!formData.value.name.trim()) return (errorMessage.value = "낚시터명을 입력하세요.");
+
+    isSaving.value = true;
+    try {
+      // 1) 낚시터 등록
+      const { data, error } = await post("/fishing", { ...formData.value });
+
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
+        return;
+      }
+
+      const newId = data.data?.id;
+
+      // 2) 사진이 있으면 업로드 (낚시터 id 받은 뒤)
+      if (newId && photos.value.length) {
+        const fd = new FormData();
+        photos.value.forEach((p) => fd.append("photos[]", p.file));
+        const { data: photoRes, error: photoErr } = await upload(`/fishing/${newId}/photos`, fd);
+
+        if (photoErr || !photoRes?.success) {
+          // 낚시터는 등록됐으나 사진 일부 실패 — 안내 후 목록 이동
+          errorMessage.value = "낚시터는 등록됐지만 사진 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
+          setTimeout(() => router.push("/site-manager/fishing/list"), 1500);
+          return;
+        }
+      }
+
+      successMessage.value = data.message || "낚시터이 등록되었습니다.";
       setTimeout(() => {
-        router.push('/site-manager/showroom/list')
-      }, 1000)
+        router.push("/site-manager/fishing/list");
+      }, 1000);
+    } catch (e) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Save error:", e);
+    } finally {
+      isSaving.value = false;
     }
-  } catch (error) {
-    errorMessage.value = '서버 오류가 발생했습니다.'
-    console.error('Save error:', error)
-  } finally {
-    isSaving.value = false
-  }
-}
-
-// 목록으로 이동
-const goToList = () => {
-  router.push('/site-manager/showroom/list')
-}
-
-onMounted(() => {
-  loadBranches()
-})
-</script>
-
-<style scoped>
-.admin--coordinate-group {
-  display: flex;
-  gap: 16px;
-}
-
-.admin--coordinate-item {
-  flex: 1;
-}
-
-.admin--coordinate-item label {
-  display: block;
-  margin-bottom: 8px;
-  font-size: 14px;
-  color: #666;
-}
-</style>
+  };
+
+  // 목록으로 이동
+  const goToList = () => router.push("/site-manager/fishing/list");
+
+  onMounted(() => {
+    loadOptions();
+  });
+</script>

+ 269 - 0
app/pages/site-manager/fishing/detail/[id].vue

@@ -0,0 +1,269 @@
+<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>{{ data.field_name || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>지역</div></th>
+            <td>{{ data.area_name || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>낚시터명</div></th>
+            <td class="admin--table-title">{{ data.name || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>운영시간</div></th>
+            <td>{{ data.operating_hours || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>주요 어종</div></th>
+            <td>{{ data.fish_species || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>우편번호</div></th>
+            <td>{{ data.zip_code || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>주소</div></th>
+            <td>{{ data.address || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>상세주소</div></th>
+            <td>{{ data.address_detail || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>참고항목</div></th>
+            <td>{{ data.address_refer || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>좌표</div></th>
+            <td>
+              <template v-if="data.lat && data.lng">{{ data.lat }}, {{ data.lng }}</template>
+              <template v-else>-</template>
+            </td>
+          </tr>
+          <tr>
+            <th><div>사진</div></th>
+            <td>
+              <div v-if="data.photos && data.photos.length" class="onboard--photo-grid">
+                <div v-for="p in data.photos" :key="p.id" class="onboard--photo-item">
+                  <img :src="getImageUrl(p.file_path)" :alt="p.original_name" />
+                </div>
+              </div>
+              <template v-else>-</template>
+            </td>
+          </tr>
+          <tr>
+            <th><div>제휴 여부</div></th>
+            <td>
+              <span :class="['admin--badge', data.partnership_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
+                {{ data.partnership_YN === "Y" ? "제휴" : "비제휴" }}
+              </span>
+            </td>
+          </tr>
+          <tr v-if="data.partnership_YN === 'Y'">
+            <th><div>계좌번호</div></th>
+            <td>
+              <template v-if="data.account_number">
+                {{ bankName(data.bank_code) }} {{ data.account_number }}<template v-if="data.account_holder"> ({{ data.account_holder }})</template>
+              </template>
+              <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 fishingId = route.params.id;
+
+  const data = ref({
+    field_name: "",
+    area_name: "",
+    name: "",
+    operating_hours: "",
+    fish_species: "",
+    zip_code: "",
+    address: "",
+    address_detail: "",
+    address_refer: "",
+    lat: "",
+    lng: "",
+    bank_code: "",
+    account_number: "",
+    account_holder: "",
+    partnership_YN: "N",
+    status_YN: "Y",
+    created_at: "",
+    updated_at: "",
+    photos: [],
+  });
+
+  // 은행 코드 → 은행명
+  const bankMap = {
+    "002": "산업은행", "003": "기업은행", "004": "국민은행", "007": "수협은행",
+    "011": "농협은행", "020": "우리은행", "023": "SC제일은행", "031": "대구은행",
+    "032": "부산은행", "034": "광주은행", "035": "제주은행", "037": "전북은행",
+    "039": "경남은행", "045": "새마을금고", "071": "우체국", "081": "하나은행",
+    "088": "신한은행", "089": "케이뱅크", "090": "카카오뱅크", "092": "토스뱅크",
+  };
+  const bankName = (code) => bankMap[code] || (code ? code : "-");
+
+  // 알림 모달
+  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(`/fishing/${fishingId}`);
+
+    if (error || !res?.success) {
+      showAlert(error?.message || res?.message || "조회에 실패했습니다.", "오류");
+      return;
+    }
+
+    const row = res.data || {};
+    data.value = {
+      field_name: row.field_name ?? "",
+      area_name: row.area_name ?? "",
+      name: row.name ?? "",
+      operating_hours: row.operating_hours ?? "",
+      fish_species: row.fish_species ?? "",
+      zip_code: row.zip_code ?? "",
+      address: row.address ?? "",
+      address_detail: row.address_detail ?? "",
+      address_refer: row.address_refer ?? "",
+      lat: row.lat ?? "",
+      lng: row.lng ?? "",
+      bank_code: row.bank_code ?? "",
+      account_number: row.account_number ?? "",
+      account_holder: row.account_holder ?? "",
+      partnership_YN: row.partnership_YN ?? "N",
+      status_YN: row.status_YN ?? "Y",
+      created_at: row.created_at ?? "",
+      updated_at: row.updated_at ?? "",
+      photos: row.photos ?? [],
+    };
+  };
+
+  // 삭제
+  const handleDelete = () => {
+    showConfirm(
+      `'${data.value.name}' 낚시터를 삭제하시겠습니까?`,
+      async () => {
+        const { data: res, error } = await del(`/fishing/${fishingId}`);
+        if (error || !res?.success) {
+          showAlert(error?.message || res?.message || "삭제에 실패했습니다.", "오류");
+        } else {
+          showAlert(res.message || "삭제되었습니다.", "성공");
+          setTimeout(() => router.push("/site-manager/fishing/list"), 800);
+        }
+      },
+      "낚시터 삭제"
+    );
+  };
+
+  // 이동
+  const goToList = () => router.push("/site-manager/fishing/list");
+  const goToEdit = () => router.push(`/site-manager/fishing/edit/${fishingId}`);
+
+  // 일시 포맷
+  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>

+ 432 - 276
app/pages/site-manager/fishing/edit/[id].vue

@@ -1,337 +1,493 @@
 <template>
-  <div class="admin--showroom-form">
+  <div class="admin--page-content">
     <div v-if="isLoading" class="admin--loading">데이터를 불러오는 중...</div>
-    <form v-else @submit.prevent="handleSubmit" class="admin--form">
-      <!-- 전시장명 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label"
-          >전시장명 <span class="admin--required">*</span></label
-        >
-        <input
-          v-model="formData.name"
-          type="text"
-          class="admin--form-input"
-          placeholder="전시장명을 입력하세요"
-          required
-        />
-      </div>
-
-      <!-- 소속명 선택 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label"
-          >소속명 <span class="admin--required">*</span></label
-        >
-        <select v-model="formData.branch_id" class="admin--form-select" required>
-          <option value="">소속 지점을 선택하세요</option>
-          <option v-for="branch in branches" :key="branch.id" :value="branch.id">
-            {{ branch.name }}
-          </option>
-        </select>
-      </div>
-
-      <!-- 대표번호 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label"
-          >대표번호 <span class="admin--required">*</span></label
-        >
-        <input
-          v-model="formData.phone"
-          type="tel"
-          class="admin--form-input"
-          placeholder="02-1234-5678"
-          required
-        />
-      </div>
-
-      <!-- 주소 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label"
-          >주소 <span class="admin--required">*</span></label
-        >
-        <input
-          v-model="formData.address"
-          type="text"
-          class="admin--form-input"
-          placeholder="주소를 입력하세요"
-          required
-        />
-      </div>
-
-      <!-- 상세주소 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">상세주소</label>
-        <input
-          v-model="formData.detail_address"
-          type="text"
-          class="admin--form-input"
-          placeholder="상세주소를 입력하세요"
-        />
-      </div>
-
-      <!-- 위도/경도 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">위치 좌표</label>
-        <div class="admin--coordinate-group">
-          <div class="admin--coordinate-item">
-            <label>위도</label>
-            <input
-              v-model.number="formData.latitude"
-              type="text"
-              step="any"
-              class="admin--form-input"
-              placeholder="37.5665"
-            />
-          </div>
-          <div class="admin--coordinate-item">
-            <label>경도</label>
-            <input
-              v-model.number="formData.longitude"
-              type="text"
-              step="any"
-              class="admin--form-input"
-              placeholder="126.9780"
-            />
-          </div>
-        </div>
-      </div>
-
-      <!-- 영업시간 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">영업시간</label>
-        <textarea
-          v-model="formData.business_hours"
-          class="admin--form-textarea"
-          rows="3"
-          placeholder="평일: 09:00 - 18:00&#10;주말: 10:00 - 17:00"
-        ></textarea>
-      </div>
-
-      <!-- 견적요청 링크 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">견적요청 링크</label>
-        <input
-          v-model="formData.quote_link"
-          type="url"
-          class="admin--form-input"
-          placeholder="https://example.com/quote"
-        />
-      </div>
-
-      <!-- 시승신청 링크 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">시승신청 링크</label>
-        <input
-          v-model="formData.test_drive_link"
-          type="url"
-          class="admin--form-input"
-          placeholder="https://example.com/test-drive"
-        />
-      </div>
-
-      <!-- 링크 관리 -->
-      <div class="admin--form-group">
-        <label class="admin--form-label">관련 링크</label>
-        <div class="admin--multi-input-wrapper">
-          <div
-            v-for="(link, index) in formData.links"
-            :key="index"
-            class="admin--multi-input-item"
-          >
-            <input
-              v-model="formData.links[index]"
-              type="url"
-              class="admin--form-input"
-              placeholder="https://example.com"
-            />
-            <button type="button" class="admin--btn-remove" @click="removeLink(index)">
-              삭제
-            </button>
-          </div>
-          <button type="button" class="admin--btn-add" @click="addLink">
-            + 링크 추가
+    <div v-else class="admin--form">
+      <form @submit.prevent="handleSubmit">
+        <table class="admin--form--table">
+          <colgroup>
+            <col style="width: 140px;">
+            <col>
+          </colgroup>
+          <tbody>
+            <tr>
+              <th><div>분야 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="formData.field_id" class="admin--form-select w--240" required>
+                    <option value="">선택하세요</option>
+                    <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
+                  </select>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>지역 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="formData.area_id" class="admin--form-select w--240" required>
+                    <option value="">선택하세요</option>
+                    <option v-for="a in areaOptions" :key="a.id" :value="a.id">{{ a.name }}</option>
+                  </select>
+                </div>
+              </td>
+            </tr>
+            <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 />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>운영시간</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.operating_hours" type="text" class="admin--form-input w--full" placeholder="예: 하절기 07:00~16:00 / 동절기 08:00~16:00" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>주요 어종</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.fish_species" type="text" class="admin--form-input w--full" placeholder="예: 붕어, 잉어 (쉼표로 구분)" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>우편번호</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.zip_code" type="text" class="admin--form-input w--160" placeholder="우편번호" readonly />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="openPostcode">
+                    우편번호 검색
+                  </button>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>주소</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.address" type="text" class="admin--form-input w--full" placeholder="우편번호 검색 시 자동 입력" readonly />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>상세주소</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.address_detail" type="text" class="admin--form-input w--full" placeholder="" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>참고항목</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.address_refer" type="text" class="admin--form-input w--full" placeholder="예: 선착장 입구 우측" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>좌표</div></th>
+              <td>
+                <div class="input--wrap admin--inline-group">
+                  <input v-model="formData.lat" type="text" class="admin--form-input w--200" placeholder="위도 (lat)" />
+                  <input v-model="formData.lng" type="text" class="admin--form-input w--200" placeholder="경도 (lng)" />
+                </div>
+                <p v-if="coordError" class="">{{ coordError }}</p>
+                <p v-else class="mt--10">주소를 검색하면 위도·경도가 자동 입력됩니다.</p>
+              </td>
+            </tr>
+            <tr>
+              <th><div>사진</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    ref="photoInput"
+                    type="file"
+                    accept="image/*"
+                    multiple
+                    class="admin--form-file-hidden"
+                    @change="onPhotoChange"
+                  />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerPhotoInput">
+                    사진 추가
+                  </button>
+                </div>
+                <p class="mt--10">JPG/PNG/WebP, 한 장당 10MB 이하. 첫 번째 사진이 대표 이미지로 사용됩니다.</p>
+                <div v-if="existingPhotos.length || photos.length" class="onboard--photo-grid mt--10">
+                  <!-- 기존 사진 -->
+                  <div v-for="p in existingPhotos" :key="'e' + p.id" class="onboard--photo-item">
+                    <img :src="getImageUrl(p.file_path)" :alt="p.original_name" />
+                    <button type="button" class="onboard--photo-remove" @click="removeExistingPhoto(p.id)">×</button>
+                  </div>
+                  <!-- 새로 추가한 사진 -->
+                  <div v-for="(p, i) in photos" :key="'n' + p.id" class="onboard--photo-item">
+                    <img :src="p.preview" alt="미리보기" />
+                    <button type="button" class="onboard--photo-remove" @click="removePhoto(i)">×</button>
+                    <span class="onboard--photo-new">NEW</span>
+                  </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.partnership_YN" value="Y" /> 제휴
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.partnership_YN" value="N" /> 비제휴
+                  </label>
+                </div>
+              </td>
+            </tr>
+            <tr v-if="isPartner">
+              <th><div>계좌번호</div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="formData.bank_code" class="admin--form-select w--120">
+                    <option value="">은행명</option>
+                    <option v-for="b in bankOptions" :key="b.code" :value="b.code">{{ b.name }}</option>
+                  </select>
+                  <input v-model="formData.account_number" type="text" class="admin--form-input w--300" placeholder="'-' 없이 숫자만 입력" />
+                  <input v-model="formData.account_holder" type="text" class="admin--form-input w--240" placeholder="예금주명" />
+                </div>
+                <p class="mt--10">정산 입금 계좌 (제휴 업체용)</p>
+              </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>
-
-      <!-- 버튼 영역 -->
-      <div class="admin--form-actions">
-        <button type="submit" class="admin--btn admin--btn-primary" :disabled="isSaving">
-          {{ isSaving ? "저장 중..." : "확인" }}
-        </button>
-        <button type="button" class="admin--btn admin--btn-secondary" @click="goToList">
-          목록
-        </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 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, onMounted } from "vue";
-  import { useRouter, useRoute } from "vue-router";
+  import { ref, computed, watch, onMounted } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
 
   definePageMeta({
     layout: "admin",
     middleware: ["auth"],
   });
 
-  const router = useRouter();
   const route = useRoute();
-  const { get, put } = useApi();
+  const router = useRouter();
+  const config = useRuntimeConfig();
+  const { get, put, upload, del } = useApi();
+  const { getImageUrl } = useImage();
+
+  const fishingId = route.params.id;
 
   const isLoading = ref(true);
   const isSaving = ref(false);
   const successMessage = ref("");
   const errorMessage = ref("");
-  const branches = ref([]);
+  const coordError = ref("");
+
+  const fieldOptions = ref([]);
+  const areaOptions = ref([]);
+
+  // 사진
+  const photoInput = ref(null);
+  const photos = ref([]);
+  const existingPhotos = ref([]);
+  const MAX_PHOTO_SIZE = 10 * 1024 * 1024;
+
+  const bankOptions = [
+    { code: "002", name: "산업은행" }, { code: "003", name: "기업은행" },
+    { code: "004", name: "국민은행" }, { code: "007", name: "수협은행" },
+    { code: "011", name: "농협은행" }, { code: "020", name: "우리은행" },
+    { code: "023", name: "SC제일은행" }, { code: "031", name: "대구은행" },
+    { code: "032", name: "부산은행" }, { code: "034", name: "광주은행" },
+    { code: "035", name: "제주은행" }, { code: "037", name: "전북은행" },
+    { code: "039", name: "경남은행" }, { code: "045", name: "새마을금고" },
+    { code: "071", name: "우체국" }, { code: "081", name: "하나은행" },
+    { code: "088", name: "신한은행" }, { code: "089", name: "케이뱅크" },
+    { code: "090", name: "카카오뱅크" }, { code: "092", name: "토스뱅크" },
+  ];
 
   const formData = ref({
+    field_id: "",
+    area_id: "",
     name: "",
-    branch_id: "",
-    phone: "",
+    operating_hours: "",
+    fish_species: "",
+    zip_code: "",
     address: "",
-    detail_address: "",
-    latitude: null,
-    longitude: null,
-    business_hours: "",
-    quote_link: "",
-    test_drive_link: "",
-    links: [],
+    address_detail: "",
+    address_refer: "",
+    lat: "",
+    lng: "",
+    bank_code: "",
+    account_number: "",
+    account_holder: "",
+    partnership_YN: "N",
+    status_YN: "Y",
   });
 
-  // 지점 목록 로드
-  const loadBranches = async () => {
-    const { data, error } = await get("/branch/list", { params: { per_page: 1000 } });
-
-    if (data?.success && data?.data?.items) {
-      branches.value = data.data.items.filter((branch) => branch.is_active == 1);
-    }
-  };
-
-  // 전시장 데이터 로드
-  const loadShowroom = async () => {
-    const id = route.params.id;
-
-    const { data, error } = await get(`/showroom/${id}`);
+  const isPartner = computed(() => formData.value.partnership_YN === "Y");
 
-    if (data?.success && data?.data) {
-      const showroom = data.data;
-      formData.value = {
-        name: showroom.name || "",
-        branch_id: showroom.branch_id || "",
-        phone: showroom.main_phone || "",
-        address: showroom.address || "",
-        detail_address: showroom.detail_address || "",
-        latitude: showroom.latitude || null,
-        longitude: showroom.longitude || null,
-        business_hours: showroom.business_hours || "",
-        quote_link: showroom.quote_link || "",
-        test_drive_link: showroom.test_drive_link || "",
-        links: showroom.links && showroom.links.length > 0 ? showroom.links : [""],
-      };
+  watch(
+    () => formData.value.partnership_YN,
+    (val) => {
+      if (val === "N") {
+        formData.value.bank_code = "";
+        formData.value.account_number = "";
+        formData.value.account_holder = "";
+      }
     }
+  );
 
-    isLoading.value = false;
+  // 알림 모달
+  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 addLink = () => {
-    formData.value.links.push("");
+  const showConfirm = (message, onConfirm, title = "확인") => {
+    alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
   };
-
-  // 링크 삭제
-  const removeLink = (index) => {
-    formData.value.links.splice(index, 1);
+  const closeAlertModal = () => { alertModal.value.show = false; };
+  const handleAlertConfirm = () => {
+    if (alertModal.value.onConfirm) alertModal.value.onConfirm();
+    closeAlertModal();
+  };
+  const handleAlertCancel = () => closeAlertModal();
+
+  // 분야 / 지역 옵션 로드
+  const loadOptions = async () => {
+    const [fieldRes, areaRes] = await Promise.all([
+      get("/field/list", { params: { per_page: 1000 } }),
+      get("/area/list", { params: { per_page: 1000 } }),
+    ]);
+    if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
+    if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
   };
 
-  // 폼 제출
-  const handleSubmit = async () => {
-    successMessage.value = "";
-    errorMessage.value = "";
-
-    // 유효성 검사
-    if (!formData.value.name) {
-      errorMessage.value = "전시장명을 입력하세요.";
+  // 기존 데이터 로드
+  const loadDetail = async () => {
+    const { data, error } = await get(`/fishing/${fishingId}`);
+    if (error || !data?.success) {
+      errorMessage.value = error?.message || data?.message || "조회에 실패했습니다.";
+      isLoading.value = false;
       return;
     }
+    const row = data.data || {};
+    formData.value = {
+      field_id: row.field_id ?? "",
+      area_id: row.area_id ?? "",
+      name: row.name ?? "",
+      operating_hours: row.operating_hours ?? "",
+      fish_species: row.fish_species ?? "",
+      zip_code: row.zip_code ?? "",
+      address: row.address ?? "",
+      address_detail: row.address_detail ?? "",
+      address_refer: row.address_refer ?? "",
+      lat: row.lat ?? "",
+      lng: row.lng ?? "",
+      bank_code: row.bank_code ?? "",
+      account_number: row.account_number ?? "", // 복호화된 평문
+      account_holder: row.account_holder ?? "",
+      partnership_YN: row.partnership_YN ?? "N",
+      status_YN: row.status_YN ?? "Y",
+    };
+    existingPhotos.value = row.photos ?? [];
+    isLoading.value = false;
+  };
 
-    if (!formData.value.branch_id) {
-      errorMessage.value = "소속 지점을 선택하세요.";
+  // 외부 스크립트 로드
+  const loadScript = (src) =>
+    new Promise((resolve, reject) => {
+      if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
+      const s = document.createElement("script");
+      s.src = src;
+      s.onload = () => resolve();
+      s.onerror = () => reject(new Error(`스크립트 로드 실패: ${src}`));
+      document.head.appendChild(s);
+    });
+
+  // 주소 → 좌표
+  const searchCoords = async (address) => {
+    coordError.value = "";
+    const key = config.public.googleMapKey;
+    if (!key) {
+      coordError.value = "좌표를 자동으로 가져올 수 없습니다. 위도/경도를 직접 입력하세요.";
       return;
     }
-
-    if (!formData.value.phone) {
-      errorMessage.value = "대표번호를 입력하세요.";
-      return;
+    try {
+      const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
+      url.searchParams.set("address", address);
+      url.searchParams.set("key", key);
+      url.searchParams.set("language", "ko");
+      url.searchParams.set("region", "kr");
+      const res = await fetch(url);
+      const data = await res.json();
+      if (data.status === "OK" && data.results?.[0]) {
+        const loc = data.results[0].geometry.location;
+        formData.value.lat = String(loc.lat);
+        formData.value.lng = String(loc.lng);
+      } else {
+        formData.value.lat = "";
+        formData.value.lng = "";
+        coordError.value = "좌표를 찾지 못했습니다. 직접 입력해 주세요.";
+      }
+    } catch (e) {
+      console.error("Geocoding error:", e);
+      coordError.value = "좌표 조회 중 오류가 발생했습니다. 위도/경도를 직접 입력해주세요.";
     }
+  };
 
-    if (!formData.value.address) {
-      errorMessage.value = "주소를 입력하세요.";
+  // 우편번호 검색
+  const openPostcode = async () => {
+    coordError.value = "";
+    try {
+      await loadScript("https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js");
+    } catch (e) {
+      errorMessage.value = "우편번호 서비스를 불러오지 못했습니다.";
       return;
     }
+    new window.daum.Postcode({
+      oncomplete: (data) => {
+        formData.value.zip_code = data.zonecode;
+        formData.value.address = data.roadAddress || data.jibunAddress;
+        searchCoords(formData.value.address);
+      },
+    }).open();
+  };
 
-    isSaving.value = true;
+  // 사진 추가/삭제
+  const triggerPhotoInput = () => photoInput.value?.click();
+  const onPhotoChange = (e) => {
+    const files = Array.from(e.target.files || []);
+    for (const file of files) {
+      if (!file.type.startsWith("image/")) continue;
+      if (file.size > MAX_PHOTO_SIZE) {
+        errorMessage.value = `'${file.name}' 파일이 10MB를 초과합니다.`;
+        continue;
+      }
+      photos.value.push({
+        id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
+        file,
+        preview: URL.createObjectURL(file),
+      });
+    }
+    e.target.value = "";
+  };
+  const removePhoto = (index) => {
+    const [removed] = photos.value.splice(index, 1);
+    if (removed) URL.revokeObjectURL(removed.preview);
+  };
 
-    try {
-      const id = route.params.id;
+  // 기존 사진 삭제 (즉시 서버 반영)
+  const removeExistingPhoto = (photoId) => {
+    showConfirm(
+      "이 사진을 삭제하시겠습니까?",
+      async () => {
+        const { data, error } = await del(`/fishing/photo/${photoId}`);
+        if (error || !data?.success) {
+          showAlert(error?.message || data?.message || "사진 삭제에 실패했습니다.", "오류");
+        } else {
+          existingPhotos.value = existingPhotos.value.filter((p) => p.id !== photoId);
+        }
+      },
+      "사진 삭제"
+    );
+  };
 
-      // 빈 링크 제거
-      const submitData = {
-        ...formData.value,
-        links: formData.value.links.filter((link) => link.trim() !== ""),
-      };
+  // 폼 제출
+  const handleSubmit = async () => {
+    successMessage.value = "";
+    errorMessage.value = "";
 
-      const { data, error } = await put(`/showroom/${id}`, submitData);
+    if (!formData.value.field_id) return (errorMessage.value = "분야를 선택하세요.");
+    if (!formData.value.area_id) return (errorMessage.value = "지역을 선택하세요.");
+    if (!formData.value.name.trim()) return (errorMessage.value = "낚시터명을 입력하세요.");
 
+    isSaving.value = true;
+    try {
+      // 1) 낚시터 수정
+      const { data, error } = await put(`/fishing/${fishingId}`, { ...formData.value });
       if (error || !data?.success) {
         errorMessage.value = error?.message || data?.message || "수정에 실패했습니다.";
-      } else {
-        successMessage.value = data.message || "전시장이 수정되었습니다.";
-        setTimeout(() => {
-          router.push("/site-manager/showroom/list");
-        }, 1000);
+        return;
       }
-    } catch (error) {
+
+      // 2) 새 사진이 있으면 업로드
+      if (photos.value.length) {
+        const fd = new FormData();
+        photos.value.forEach((p) => fd.append("photos[]", p.file));
+        const { data: photoRes, error: photoErr } = await upload(`/fishing/${fishingId}/photos`, fd);
+        if (photoErr || !photoRes?.success) {
+          errorMessage.value = "낚시터는 수정됐지만 사진 업로드에 실패했습니다.";
+          setTimeout(() => router.push(`/site-manager/fishing/detail/${fishingId}`), 1500);
+          return;
+        }
+      }
+
+      successMessage.value = data.message || "수정되었습니다.";
+      setTimeout(() => {
+        router.push(`/site-manager/fishing/detail/${fishingId}`);
+      }, 1000);
+    } catch (e) {
       errorMessage.value = "서버 오류가 발생했습니다.";
-      console.error("Save error:", error);
+      console.error("Update error:", e);
     } finally {
       isSaving.value = false;
     }
   };
 
-  // 목록으로 이동
-  const goToList = () => {
-    router.push("/site-manager/showroom/list");
-  };
+  const goToDetail = () => router.push(`/site-manager/fishing/detail/${fishingId}`);
 
   onMounted(async () => {
-    await loadBranches();
-    await loadShowroom();
+    await loadOptions();
+    await loadDetail();
   });
 </script>
-
-<style scoped>
-  .admin--coordinate-group {
-    display: flex;
-    gap: 16px;
-  }
-
-  .admin--coordinate-item {
-    flex: 1;
-  }
-
-  .admin--coordinate-item label {
-    display: block;
-    margin-bottom: 8px;
-    font-size: 14px;
-    color: #666;
-  }
-</style>

+ 168 - 55
app/pages/site-manager/fishing/list.vue

@@ -1,25 +1,54 @@
 <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--search-box type2">
+      <div class="admin--search--inner--box">
+        <div class="admin--search-form">
+          <select v-model="searchField" class="admin--form-select admin--search-select">
+            <option value="">전체</option>
+            <option value="field">분야</option>
+            <option value="area">지역명</option>
+            <option value="name">낚시터명</option>
+            <option value="fish_species">주요어종</option>
+          </select>
+          <input
+            v-model="searchQuery"
+            type="text"
+            placeholder="검색어 입력"
+            @keyup.enter="onSearch"
+            class="admin--form-input admin--search-input"
+          />
+          <select v-model="filterPartnership" @change="onSearch" class="admin--form-select admin--search-select">
+            <option value="">전체</option>
+            <option value="Y">제휴</option>
+            <option value="N">비제휴</option>
+          </select>
+          <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>
+          <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--search-actions">
-        <button class="admin--btn-add" @click="goToCreate">+ 새 선상 추가</button>
+      <div class="admin--search--inner--box">
+        <div class="admin--search-form">
+          <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
+          <span class="admin--date-separator">-</span>
+          <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
+          <div class="admin--quick-range">
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('today')">오늘</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('7d')">7일</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('15d')">15일</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1m')">1개월</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('3m')">3개월</button>
+            <button type="button" class="admin--btn-small admin--btn-small-secondary range--btn" @click="setRange('1y')">1년</button>
+          </div>
+        </div>
       </div>
     </div>
 
@@ -28,42 +57,54 @@
       <table class="admin--table">
         <thead>
           <tr>
-            <th style="width: 80px;">번호</th>
-            <th style="width: 40%;">분야</th>
-            <th>지역명</th>
-            <th>선상명</th>
-            <th>제휴업체</th>
-            <th>상태</th>
-            <th>등록일</th>
+            <th style="width: 40px;">번호</th>
+            <th style="width: 120px;">분야</th>
+            <th style="width: 120px;">지역명</th>
+            <th>낚시터명</th>
+            <th style="">주소</th>
+            <th style="">주요어종</th>
+            <th style="width: 140px;">운영시간</th>
+            <th style="width: 100px;">제휴업체</th>
+            <th style="width: 100px;">상태</th>
+            <th style="width: 120px;">등록일</th>
             <th style="width: 120px;">관리</th>
           </tr>
         </thead>
         <tbody>
           <tr v-if="isLoading">
-            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
+            <td colspan="11" class="admin--table-loading">데이터를 불러오는 중...</td>
           </tr>
-          <tr v-else-if="!fields || fields.length === 0">
-            <td colspan="6" class="admin--table-empty">등록된 선상이 없습니다.</td>
+          <tr v-else-if="!spots || spots.length === 0">
+            <td colspan="11" class="admin--table-empty">등록된 낚시터가 없습니다.</td>
           </tr>
           <tr
             v-else
-            v-for="(field, index) in fields"
-            :key="field.id"
+            v-for="(item, index) in spots"
+            :key="item.id"
             class="admin--table-row-clickable"
-            @click="goToDetail(field.id)"
+            @click="goToDetail(item.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>{{ item.field_name || "-" }}</td>
+            <td>{{ item.area_name || "-" }}</td>
+            <td class="admin--table-title">{{ item.name }}</td>
+            <td>{{ item.address || "-" }}</td>
+            <td>{{ item.fish_species || "-" }}</td>
+            <td>{{ item.operating_hours || "-" }}</td>
             <td>
-              <span :class="['admin--badge', getStatusBadgeClass(field.status_YN)]">
-                {{ getStatusLabel(field.status_YN) }}
+              <span :class="['admin--badge', item.partnership_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
+                {{ item.partnership_YN === "Y" ? "제휴" : "비제휴" }}
               </span>
             </td>
+            <td>
+              <span :class="['admin--badge', getStatusBadgeClass(item.status_YN)]">
+                {{ getStatusLabel(item.status_YN) }}
+              </span>
+            </td>
+            <td class="date">{{ formatDate(item.created_at) }}</td>
             <td>
               <div class="admin--table-actions">
-                <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(field.id)">
+                <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(item.id)">
                   수정
                 </button>
               </div>
@@ -75,12 +116,22 @@
 
     <!-- 페이지네이션 -->
     <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"
@@ -95,8 +146,18 @@
         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>
@@ -105,6 +166,7 @@
 <script setup>
   import { ref, computed, onMounted } from "vue";
   import { useRouter } from "vue-router";
+  import DatePicker from "~/components/admin/DatePicker.vue";
 
   definePageMeta({
     layout: "admin",
@@ -115,14 +177,55 @@
   const { get } = useApi();
 
   const isLoading = ref(false);
-  const fields = ref([]);
+  const spots = ref([]);
   const currentPage = ref(1);
   const perPage = ref(10);
   const totalCount = ref(0);
   const totalPages = ref(0);
 
+  const searchField = ref("");      // '', field, area, name
   const searchQuery = ref("");
-  const filterStatus = ref("");
+  const filterPartnership = ref(""); // '', Y, N
+  const filterStatus = ref("");      // '', Y, N
+  const startDate = ref("");         // YYYY-MM-DD
+  const endDate = ref("");           // YYYY-MM-DD
+
+  // YYYY-MM-DD 포맷터
+  const toYMD = (d) => {
+    const y = d.getFullYear();
+    const m = String(d.getMonth() + 1).padStart(2, "0");
+    const day = String(d.getDate()).padStart(2, "0");
+    return `${y}-${m}-${day}`;
+  };
+
+  // 빠른 기간 선택 (오늘 기준)
+  const setRange = (kind) => {
+    const today = new Date();
+    const end = toYMD(today);
+    const startDt = new Date();
+    switch (kind) {
+      case "today":
+        break;
+      case "7d":
+        startDt.setDate(startDt.getDate() - 7);
+        break;
+      case "15d":
+        startDt.setDate(startDt.getDate() - 15);
+        break;
+      case "1m":
+        startDt.setMonth(startDt.getMonth() - 1);
+        break;
+      case "3m":
+        startDt.setMonth(startDt.getMonth() - 3);
+        break;
+      case "1y":
+        startDt.setFullYear(startDt.getFullYear() - 1);
+        break;
+    }
+    startDate.value = toYMD(startDt);
+    endDate.value = end;
+    onSearch();
+  };
 
   // 보이는 페이지 번호 계산
   const visiblePages = computed(() => {
@@ -141,25 +244,31 @@
   });
 
   // 데이터 로드
-  const loadFields = async () => {
+  const loadSpots = async () => {
     isLoading.value = true;
 
     const params = {
       page: currentPage.value,
       per_page: perPage.value,
     };
-    if (searchQuery.value) params.search = searchQuery.value;
+    if (searchQuery.value) {
+      params.search = searchQuery.value;
+      if (searchField.value) params.search_field = searchField.value;
+    }
+    if (filterPartnership.value) params.partnership = filterPartnership.value;
     if (filterStatus.value) params.status = filterStatus.value;
+    if (startDate.value) params.start_date = startDate.value;
+    if (endDate.value) params.end_date = endDate.value;
 
-    const { data, error } = await get("/field/list", { params });
+    const { data, error } = await get("/fishing/list", { params });
 
     if (error) {
-      console.error("[FieldList] 목록 로드 실패:", error);
-      fields.value = [];
+      console.error("[FishingList] 목록 로드 실패:", error);
+      spots.value = [];
       totalCount.value = 0;
       totalPages.value = 0;
     } else if (data?.success && data?.data) {
-      fields.value = data.data.items || [];
+      spots.value = data.data.items || [];
       totalCount.value = data.data.total || 0;
       totalPages.value = data.data.total_pages || 0;
     }
@@ -170,29 +279,33 @@
   // 검색
   const onSearch = () => {
     currentPage.value = 1;
-    loadFields();
+    loadSpots();
   };
 
   // 검색 초기화
   const resetSearch = () => {
+    searchField.value = "";
     searchQuery.value = "";
+    filterPartnership.value = "";
     filterStatus.value = "";
+    startDate.value = "";
+    endDate.value = "";
     currentPage.value = 1;
-    loadFields();
+    loadSpots();
   };
 
   // 페이지 변경
   const changePage = (page) => {
     if (page < 1 || page > totalPages.value) return;
     currentPage.value = page;
-    loadFields();
+    loadSpots();
     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 goToCreate = () => router.push("/site-manager/fishing/create");
+  const goToDetail = (id) => router.push(`/site-manager/fishing/detail/${id}`);
+  const goToEdit = (id) => router.push(`/site-manager/fishing/edit/${id}`);
 
   // 상태 라벨 / 뱃지 클래스
   const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
@@ -212,6 +325,6 @@
   };
 
   onMounted(() => {
-    loadFields();
+    loadSpots();
   });
-</script>
+</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');
 
+// Fishing (낚시터)
+$routes->get('api/fishing/list', 'Api\FishingController::index');
+$routes->get('api/fishing/(:num)', 'Api\FishingController::show/$1');
+$routes->post('api/fishing', 'Api\FishingController::create');
+$routes->put('api/fishing/(:num)', 'Api\FishingController::update/$1');
+$routes->post('api/fishing/(:num)/photos', 'Api\FishingController::uploadPhotos/$1');
+$routes->delete('api/fishing/photo/(:num)', 'Api\FishingController::deletePhoto/$1');
+$routes->delete('api/fishing/(:num)', 'Api\FishingController::delete/$1');
+
 // Onboard (선상)
 $routes->get('api/onboard/list', 'Api\OnboardController::index');
 $routes->get('api/onboard/(:num)', 'Api\OnboardController::show/$1');

+ 512 - 0
backend/app/Controllers/Api/FishingController.php

@@ -0,0 +1,512 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class FishingController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'fishing';
+
+    /**
+     * 낚시터 목록
+     * GET /api/fishing/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;
+
+            $searchField = trim((string) $this->request->getGet('search_field')); // '', field, area, name, fish_species
+            $search      = trim((string) $this->request->getGet('search'));
+            $partnership = trim((string) $this->request->getGet('partnership'));
+            $status      = trim((string) $this->request->getGet('status'));
+            $startDate   = trim((string) $this->request->getGet('start_date'));
+            $endDate     = trim((string) $this->request->getGet('end_date'));
+
+            $db = $this->getDB();
+            $builder = $db->table($this->table . ' fs');
+            $builder->join('fishing_field f', 'f.id = fs.field_id', 'left');
+            $builder->join('fishing_area a', 'a.id = fs.area_id', 'left');
+            $builder->where('fs.deleted_YN', 'N');
+
+            if ($search !== '') {
+                if ($searchField === 'field') {
+                    $builder->like('f.name', $search);
+                } elseif ($searchField === 'area') {
+                    $builder->like('a.name', $search);
+                } elseif ($searchField === 'name') {
+                    $builder->like('fs.name', $search);
+                } elseif ($searchField === 'fish_species') {
+                    $builder->like('fs.fish_species', $search);
+                } else {
+                    // 전체: 분야 / 지역명 / 낚시터명 / 어종
+                    $builder->groupStart()
+                        ->like('f.name', $search)
+                        ->orLike('a.name', $search)
+                        ->orLike('fs.name', $search)
+                        ->orLike('fs.fish_species', $search)
+                        ->groupEnd();
+                }
+            }
+            if ($partnership === 'Y' || $partnership === 'N') {
+                $builder->where('fs.partnership_YN', $partnership);
+            }
+            if ($status === 'Y' || $status === 'N') {
+                $builder->where('fs.status_YN', $status);
+            }
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
+                $builder->where('fs.created_at >=', $startDate . ' 00:00:00');
+            }
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+                $builder->where('fs.created_at <=', $endDate . ' 23:59:59');
+            }
+
+            $total = $builder->countAllResults(false);
+
+            // 계좌번호는 목록에서 제외 (민감정보)
+            $items = $builder
+                ->select('fs.id, fs.name, fs.field_id, fs.area_id, fs.operating_hours, fs.fish_species, fs.address, fs.partnership_YN, fs.status_YN, fs.created_at, f.name as field_name, a.name as area_name')
+                ->orderBy('fs.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', 'FishingController index error: ' . $e->getMessage());
+            return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시터 등록
+     * POST /api/fishing
+     */
+    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() ?? [];
+            }
+
+            $fieldId = (int) ($payload['field_id'] ?? 0);
+            $areaId  = (int) ($payload['area_id'] ?? 0);
+            $name    = trim((string) ($payload['name'] ?? ''));
+
+            // 필수값 검증
+            if ($fieldId <= 0) {
+                return $this->respondError('분야를 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if ($areaId <= 0) {
+                return $this->respondError('지역을 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if ($name === '') {
+                return $this->respondError('낚시터명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 100) {
+                return $this->respondError('낚시터명은 100자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $db = $this->getDB();
+
+            // 분야 / 지역 존재 확인
+            $fieldExists = $db->table('fishing_field')
+                ->where('id', $fieldId)->where('deleted_YN', 'N')->countAllResults();
+            if ($fieldExists === 0) {
+                return $this->respondError('존재하지 않는 분야입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $areaExists = $db->table('fishing_area')
+                ->where('id', $areaId)->where('deleted_YN', 'N')->countAllResults();
+            if ($areaExists === 0) {
+                return $this->respondError('존재하지 않는 지역입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $partnership = (($payload['partnership_YN'] ?? 'N') === 'Y') ? 'Y' : 'N';
+            $status      = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y';
+
+            $insertData = [
+                'field_id'        => $fieldId,
+                'area_id'         => $areaId,
+                'name'            => $name,
+                'operating_hours' => trim((string) ($payload['operating_hours'] ?? '')),
+                'fish_species'    => trim((string) ($payload['fish_species'] ?? '')),
+                'zip_code'        => trim((string) ($payload['zip_code'] ?? '')),
+                'address'         => trim((string) ($payload['address'] ?? '')),
+                'address_detail'  => trim((string) ($payload['address_detail'] ?? '')),
+                'address_refer'   => trim((string) ($payload['address_refer'] ?? '')),
+                'lat'             => trim((string) ($payload['lat'] ?? '')),
+                'lng'             => trim((string) ($payload['lng'] ?? '')),
+                'partnership_YN'  => $partnership,
+                'status_YN'       => $status,
+                'created_at'      => date('Y-m-d H:i:s'),
+            ];
+
+            // 제휴인 경우에만 계좌 정보 저장 (계좌번호 양방향 암호화)
+            if ($partnership === 'Y') {
+                $insertData['bank_code']      = trim((string) ($payload['bank_code'] ?? ''));
+                $insertData['account_number'] = $this->encryptValue(trim((string) ($payload['account_number'] ?? '')));
+                $insertData['account_holder'] = trim((string) ($payload['account_holder'] ?? ''));
+            } else {
+                $insertData['bank_code']      = '';
+                $insertData['account_number'] = '';
+                $insertData['account_holder'] = '';
+            }
+
+            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();
+            if ($row) {
+                $row->account_number = $this->decryptValue($row->account_number);
+            }
+
+            return $this->respondSuccess($row, '낚시터가 등록되었습니다.', ResponseInterface::HTTP_CREATED);
+        } catch (\Exception $e) {
+            log_message('error', 'FishingController create error: ' . $e->getMessage());
+            return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시터 상세 조회
+     * GET /api/fishing/: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 . ' fs')
+                ->select('fs.*, f.name as field_name, a.name as area_name')
+                ->join('fishing_field f', 'f.id = fs.field_id', 'left')
+                ->join('fishing_area a', 'a.id = fs.area_id', 'left')
+                ->where('fs.id', (int) $id)
+                ->where('fs.deleted_YN', 'N')
+                ->get()
+                ->getRow();
+
+            if (!$row) {
+                return $this->respondError('해당 낚시터를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            $row->account_number = $this->decryptValue($row->account_number);
+
+            $row->photos = $this->getDB()->table('fishing_photos')
+                ->where('fishing_id', (int) $id)
+                ->orderBy('sort_order', 'ASC')
+                ->orderBy('id', 'ASC')
+                ->get()
+                ->getResult();
+
+            return $this->respondSuccess($row);
+        } catch (\Exception $e) {
+            log_message('error', 'FishingController show error: ' . $e->getMessage());
+            return $this->respondError('조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시터 수정
+     * PUT /api/fishing/: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() ?? [];
+            }
+
+            $fieldId = (int) ($payload['field_id'] ?? 0);
+            $areaId  = (int) ($payload['area_id'] ?? 0);
+            $name    = trim((string) ($payload['name'] ?? ''));
+
+            if ($fieldId <= 0) return $this->respondError('분야를 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            if ($areaId <= 0)  return $this->respondError('지역을 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            if ($name === '')  return $this->respondError('낚시터명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            if (mb_strlen($name) > 100) return $this->respondError('낚시터명은 100자 이내로 입력하세요.', 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);
+            }
+
+            $fieldExists = $db->table('fishing_field')
+                ->where('id', $fieldId)->where('deleted_YN', 'N')->countAllResults();
+            if ($fieldExists === 0) return $this->respondError('존재하지 않는 분야입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+
+            $areaExists = $db->table('fishing_area')
+                ->where('id', $areaId)->where('deleted_YN', 'N')->countAllResults();
+            if ($areaExists === 0) return $this->respondError('존재하지 않는 지역입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+
+            $partnership = (($payload['partnership_YN'] ?? 'N') === 'Y') ? 'Y' : 'N';
+            $status      = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y';
+
+            $updateData = [
+                'field_id'        => $fieldId,
+                'area_id'         => $areaId,
+                'name'            => $name,
+                'operating_hours' => trim((string) ($payload['operating_hours'] ?? '')),
+                'fish_species'    => trim((string) ($payload['fish_species'] ?? '')),
+                'zip_code'        => trim((string) ($payload['zip_code'] ?? '')),
+                'address'         => trim((string) ($payload['address'] ?? '')),
+                'address_detail'  => trim((string) ($payload['address_detail'] ?? '')),
+                'address_refer'   => trim((string) ($payload['address_refer'] ?? '')),
+                'lat'             => trim((string) ($payload['lat'] ?? '')),
+                'lng'             => trim((string) ($payload['lng'] ?? '')),
+                'partnership_YN'  => $partnership,
+                'status_YN'       => $status,
+                'updated_at'      => date('Y-m-d H:i:s'),
+            ];
+
+            if ($partnership === 'Y') {
+                $updateData['bank_code']      = trim((string) ($payload['bank_code'] ?? ''));
+                $updateData['account_number'] = $this->encryptValue(trim((string) ($payload['account_number'] ?? '')));
+                $updateData['account_holder'] = trim((string) ($payload['account_holder'] ?? ''));
+            } else {
+                $updateData['bank_code']      = '';
+                $updateData['account_number'] = '';
+                $updateData['account_holder'] = '';
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update($updateData);
+
+            $row = $db->table($this->table)->where('id', (int) $id)->get()->getRow();
+            if ($row) $row->account_number = $this->decryptValue($row->account_number);
+
+            return $this->respondSuccess($row, '낚시터가 수정되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'FishingController update error: ' . $e->getMessage());
+            return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시터 삭제 (soft delete)
+     * DELETE /api/fishing/: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', 'FishingController delete error: ' . $e->getMessage());
+            return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시터 사진 삭제 (파일 + DB hard delete)
+     * DELETE /api/fishing/photo/:photoId
+     */
+    public function deletePhoto($photoId = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($photoId)) {
+            return $this->respondError('사진 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $db = $this->getDB();
+            $photo = $db->table('fishing_photos')->where('id', (int) $photoId)->get()->getRow();
+            if (!$photo) {
+                return $this->respondError('해당 사진을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            $fullPath = FCPATH . ltrim($photo->file_path, '/');
+            if (is_file($fullPath)) {
+                @unlink($fullPath);
+            }
+
+            $db->table('fishing_photos')->where('id', (int) $photoId)->delete();
+
+            return $this->respondSuccess(null, '사진이 삭제되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'FishingController deletePhoto error: ' . $e->getMessage());
+            return $this->respondError('사진 삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 낚시터 사진 업로드 (다중)
+     * POST /api/fishing/:id/photos   (multipart, photos[])
+     */
+    public function uploadPhotos($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);
+            }
+
+            $files = $this->request->getFileMultiple('photos');
+            if (empty($files)) {
+                return $this->respondError('업로드할 사진이 없습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+            $uploadPath = FCPATH . 'uploads/fishing/';
+            if (!is_dir($uploadPath)) {
+                mkdir($uploadPath, 0755, true);
+            }
+
+            $maxRow = $db->table('fishing_photos')
+                ->selectMax('sort_order')
+                ->where('fishing_id', (int) $id)
+                ->get()->getRow();
+            $order = $maxRow && $maxRow->sort_order !== null ? (int) $maxRow->sort_order + 1 : 0;
+
+            $saved = [];
+            foreach ($files as $file) {
+                if (!$file->isValid()) continue;
+                $mime = $file->getMimeType();
+                if (!in_array($mime, $allowed, true)) continue;
+
+                $originalName = $file->getClientName();
+                $size = $file->getSize();
+                $newName = $file->getRandomName();
+                $file->move($uploadPath, $newName);
+                $fullPath = $uploadPath . $newName;
+
+                $width = null;
+                $height = null;
+                $info = @getimagesize($fullPath);
+                if ($info) {
+                    $width = $info[0];
+                    $height = $info[1];
+                }
+
+                $photoData = [
+                    'fishing_id'    => (int) $id,
+                    'original_name' => $originalName,
+                    'stored_name'   => $newName,
+                    'file_path'     => '/uploads/fishing/' . $newName,
+                    'file_size'     => $size,
+                    'mime_type'     => $mime,
+                    'width'         => $width,
+                    'height'        => $height,
+                    'sort_order'    => $order,
+                    'created_at'    => date('Y-m-d H:i:s'),
+                ];
+                $db->table('fishing_photos')->insert($photoData);
+                $photoData['id'] = $db->insertID();
+                $saved[] = $photoData;
+                $order++;
+            }
+
+            if (empty($saved)) {
+                return $this->respondError('유효한 이미지 파일이 없습니다. (JPG/PNG/GIF/WebP만 허용)', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            return $this->respondSuccess($saved, count($saved) . '장의 사진이 업로드되었습니다.', ResponseInterface::HTTP_CREATED);
+        } catch (\Exception $e) {
+            log_message('error', 'FishingController uploadPhotos error: ' . $e->getMessage());
+            return $this->respondError('사진 업로드 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /** 값 암호화 (빈 값은 그대로 빈 문자열) */
+    private function encryptValue(string $plain): string
+    {
+        if ($plain === '') return '';
+        $encrypter = \Config\Services::encrypter();
+        return base64_encode($encrypter->encrypt($plain));
+    }
+
+    /** 값 복호화 (실패/빈 값이면 빈 문자열) */
+    private function decryptValue(?string $cipher): string
+    {
+        if (empty($cipher)) return '';
+        try {
+            $encrypter = \Config\Services::encrypter();
+            return $encrypter->decrypt(base64_decode($cipher));
+        } catch (\Exception $e) {
+            log_message('error', 'Account decrypt error: ' . $e->getMessage());
+            return '';
+        }
+    }
+}

+ 904 - 147
db.vuerd.json

@@ -4,8 +4,8 @@
   "settings": {
     "width": 2000,
     "height": 2000,
-    "scrollTop": -100,
-    "scrollLeft": -430.3514,
+    "scrollTop": -482,
+    "scrollLeft": -454.3501,
     "zoomLevel": 0.97,
     "show": 431,
     "database": 4,
@@ -34,12 +34,17 @@
       "FISm81kEr0UVuk9sKYz4j",
       "U1M3DNhPb8li5y4zccrUR",
       "zUvkqgDCrDrdAi4llCexp",
-      "cH4_5N71LdebT2IHo_Dd9"
+      "cH4_5N71LdebT2IHo_Dd9",
+      "f0HLDJeJHSxkXnVkJNkFN",
+      "Rm_FvXbyIhbNwaAzppHJH"
     ],
     "relationshipIds": [
       "02Rf0D1riQbaw0LqkaD6r",
       "lo9HDcBWr2MulQ_cz9_mS",
-      "UyIJVtBW8x18iGTJ7LZuv"
+      "UyIJVtBW8x18iGTJ7LZuv",
+      "z3AF_pXmB6YPyWNFzaQmH",
+      "6dWnTNRq3psvKvtiYAk_j",
+      "dPC1hrjZp8SIR9PkoUaLL"
     ],
     "indexIds": [],
     "memoIds": []
@@ -79,7 +84,7 @@
           "color": ""
         },
         "meta": {
-          "updateAt": 1779935483367,
+          "updateAt": 1780291586351,
           "createAt": 1779771222669
         }
       },
@@ -110,7 +115,7 @@
           "color": ""
         },
         "meta": {
-          "updateAt": 1779935571357,
+          "updateAt": 1780291586351,
           "createAt": 1779771453475
         }
       },
@@ -146,15 +151,15 @@
           "xvJg59PiQl2fgtFQxgLY9"
         ],
         "ui": {
-          "x": 1002.8465,
-          "y": 30.6351,
+          "x": 1229.6505,
+          "y": 17.233,
           "zIndex": 20,
           "widthName": 89,
           "widthComment": 65,
           "color": ""
         },
         "meta": {
-          "updateAt": 1779935567355,
+          "updateAt": 1780291597655,
           "createAt": 1779933385317
         }
       },
@@ -207,20 +212,125 @@
           "FBWfJa0SYgeGwp7F7XhxG",
           "AUGMP-wRSRQJjtTsWuTN7",
           "QSEyEfejN_SOlC_FRSi74",
-          "rRY6HzigSqa-1-KNKehqG"
+          "rRY6HzigSqa-1-KNKehqG",
+          "roUjhYNQNgvstKDoY9AA5"
         ],
         "ui": {
-          "x": 544.9502,
-          "y": 20.9896,
+          "x": 687.2182,
+          "y": 15.835,
           "zIndex": 142,
           "widthName": 60,
           "widthComment": 60,
           "color": ""
         },
         "meta": {
-          "updateAt": 1779942361366,
+          "updateAt": 1780293773976,
           "createAt": 1779934492094
         }
+      },
+      "f0HLDJeJHSxkXnVkJNkFN": {
+        "id": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "fishing",
+        "comment": "낚시터",
+        "columnIds": [
+          "V6flU3IcG8jhBv4HetFn7",
+          "RA54uihL-Tg51rhi61MhV",
+          "U84ia2uANWjZ6-tF7KhhS",
+          "jFzfT-C1s-IibgfLv0A1V",
+          "yJXA4rEaUAviLym5-sYyQ",
+          "-RajDwQrGNUhD-VOx16c6",
+          "X9GfhJHYWRINnR3p-GMkN",
+          "5DgAuVzYXj2uLg3UoGqVP",
+          "vprtd2uQxKTGdBZw9wKfC",
+          "QMRmKEJDnrOYxIqlG3_1t",
+          "mMLb46wNZSS4ZeX-0ideF",
+          "ueMQqZyl9zjAJkn3i96bW",
+          "eU4jhpeI-x-Qg_8K-iE6r",
+          "nUzLO7vi86fu_9Gg0AvFs",
+          "dTvQg90ta33bfkOapBz99",
+          "hi403V2CZ_WqB8IlIS3T7",
+          "ah56UcOTkHHWMFDOv-9gT",
+          "zWf5fmlhczDF0uIHcbgzy",
+          "tHBZi3zUlI0cSx4ZaMYbf",
+          "h_PV6rCFXbJSKVePfzVgN"
+        ],
+        "seqColumnIds": [
+          "V6flU3IcG8jhBv4HetFn7",
+          "RA54uihL-Tg51rhi61MhV",
+          "U84ia2uANWjZ6-tF7KhhS",
+          "jFzfT-C1s-IibgfLv0A1V",
+          "yJXA4rEaUAviLym5-sYyQ",
+          "-RajDwQrGNUhD-VOx16c6",
+          "X9GfhJHYWRINnR3p-GMkN",
+          "5DgAuVzYXj2uLg3UoGqVP",
+          "vprtd2uQxKTGdBZw9wKfC",
+          "QMRmKEJDnrOYxIqlG3_1t",
+          "mMLb46wNZSS4ZeX-0ideF",
+          "ueMQqZyl9zjAJkn3i96bW",
+          "eU4jhpeI-x-Qg_8K-iE6r",
+          "nUzLO7vi86fu_9Gg0AvFs",
+          "dTvQg90ta33bfkOapBz99",
+          "hi403V2CZ_WqB8IlIS3T7",
+          "ah56UcOTkHHWMFDOv-9gT",
+          "zWf5fmlhczDF0uIHcbgzy",
+          "tHBZi3zUlI0cSx4ZaMYbf",
+          "h_PV6rCFXbJSKVePfzVgN"
+        ],
+        "ui": {
+          "x": 685.5666,
+          "y": 673.1958,
+          "zIndex": 150,
+          "widthName": 60,
+          "widthComment": 60,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1780293590881,
+          "createAt": 1780291013586
+        }
+      },
+      "Rm_FvXbyIhbNwaAzppHJH": {
+        "id": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "fishing_photos",
+        "comment": "낚시터 이미지",
+        "columnIds": [
+          "FGMqSJFVq4QlHNUcb15id",
+          "5i3irVRE6-tHCSnLf6_y4",
+          "PHL6u0--ZO1ZcyYflWxOu",
+          "Go8_Qwm0Lw_KGAZWDGsyp",
+          "ELALBAiur0Gq95HFIUNuz",
+          "0TL0WyKeSPpu7sKXS7cxX",
+          "7qXqhAioo16sA-YdK1ms4",
+          "RjmFTv58QRp6O3aYb0Mgv",
+          "jVoVUuEshEveD4HQTYL2r",
+          "lOxjLhJ0-hMaefkPCeSpu",
+          "zO1UMeqAKkf-A511wUToG"
+        ],
+        "seqColumnIds": [
+          "FGMqSJFVq4QlHNUcb15id",
+          "5i3irVRE6-tHCSnLf6_y4",
+          "PHL6u0--ZO1ZcyYflWxOu",
+          "Go8_Qwm0Lw_KGAZWDGsyp",
+          "ELALBAiur0Gq95HFIUNuz",
+          "0TL0WyKeSPpu7sKXS7cxX",
+          "7qXqhAioo16sA-YdK1ms4",
+          "RjmFTv58QRp6O3aYb0Mgv",
+          "jVoVUuEshEveD4HQTYL2r",
+          "lOxjLhJ0-hMaefkPCeSpu",
+          "zO1UMeqAKkf-A511wUToG"
+        ],
+        "ui": {
+          "x": 1227.8358,
+          "y": 669.0721,
+          "zIndex": 339,
+          "widthName": 79,
+          "widthComment": 77,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1780293768819,
+          "createAt": 1780293637693
+        }
       }
     },
     "tableColumnEntities": {
@@ -1163,172 +1273,819 @@
           "updateAt": 1779942347514,
           "createAt": 1779942328026
         }
-      }
-    },
-    "relationshipEntities": {
-      "PE78A013FGrCpojrvbnsz": {
-        "id": "PE78A013FGrCpojrvbnsz",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "cH4_5N71LdebT2IHo_Dd9",
-          "columnIds": [
-            "T0k9O_Ol_BAHTkKFWaaVb"
-          ],
-          "x": 215.07909999999998,
-          "y": 388,
-          "direction": 4
+      },
+      "V6flU3IcG8jhBv4HetFn7": {
+        "id": "V6flU3IcG8jhBv4HetFn7",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
-        "end": {
-          "tableId": "FISm81kEr0UVuk9sKYz4j",
-          "columnIds": [
-            "EcLhQU28k3MJy4eQUZuni"
-          ],
-          "x": 240.6134,
-          "y": 280.1134,
-          "direction": 8
+        "meta": {
+          "updateAt": 1780293670204,
+          "createAt": 1780291054491
+        }
+      },
+      "roUjhYNQNgvstKDoY9AA5": {
+        "id": "roUjhYNQNgvstKDoY9AA5",
+        "tableId": "cH4_5N71LdebT2IHo_Dd9",
+        "name": "",
+        "comment": "",
+        "dataType": "",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1779935435996,
-          "createAt": 1779935435996
+          "updateAt": 1780291068335,
+          "createAt": 1780291068335
         }
       },
-      "02Rf0D1riQbaw0LqkaD6r": {
-        "id": "02Rf0D1riQbaw0LqkaD6r",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "FISm81kEr0UVuk9sKYz4j",
-          "columnIds": [
-            "tVtTK-A1GhU8WRdiZT89G"
-          ],
-          "x": 427.1134,
-          "y": 155.0825,
-          "direction": 2
+      "RA54uihL-Tg51rhi61MhV": {
+        "id": "RA54uihL-Tg51rhi61MhV",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "field_id",
+        "comment": "분야",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 2,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
-        "end": {
-          "tableId": "cH4_5N71LdebT2IHo_Dd9",
-          "columnIds": [
-            "zH8U7DiFE0_b0k7y88KoF"
-          ],
-          "x": 544.9502,
-          "y": 160.9896,
-          "direction": 1
+        "meta": {
+          "updateAt": 1780291616232,
+          "createAt": 1780291548815
+        }
+      },
+      "U84ia2uANWjZ6-tF7KhhS": {
+        "id": "U84ia2uANWjZ6-tF7KhhS",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "area_id",
+        "comment": "지역",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 2,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1779935483922,
-          "createAt": 1779935483922
+          "updateAt": 1780291640304,
+          "createAt": 1780291627101
         }
       },
-      "lo9HDcBWr2MulQ_cz9_mS": {
-        "id": "lo9HDcBWr2MulQ_cz9_mS",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "cH4_5N71LdebT2IHo_Dd9",
-          "columnIds": [
-            "T0k9O_Ol_BAHTkKFWaaVb"
-          ],
-          "x": 944.9502,
-          "y": 300.9896,
-          "direction": 2
+      "jFzfT-C1s-IibgfLv0A1V": {
+        "id": "jFzfT-C1s-IibgfLv0A1V",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "name",
+        "comment": "낚시터명",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
-        "end": {
-          "tableId": "zUvkqgDCrDrdAi4llCexp",
-          "columnIds": [
-            "9zEjG5L24xZPSGCUtmP49"
-          ],
-          "x": 1002.8465,
-          "y": 190.6351,
-          "direction": 1
+        "meta": {
+          "updateAt": 1780293504696,
+          "createAt": 1780291649445
+        }
+      },
+      "yJXA4rEaUAviLym5-sYyQ": {
+        "id": "yJXA4rEaUAviLym5-sYyQ",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "zip_code",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1779935535175,
-          "createAt": 1779935535175
+          "updateAt": 1780293319036,
+          "createAt": 1780291669104
         }
       },
-      "UyIJVtBW8x18iGTJ7LZuv": {
-        "id": "UyIJVtBW8x18iGTJ7LZuv",
-        "identification": false,
-        "relationshipType": 8,
-        "startRelationshipType": 2,
-        "start": {
-          "tableId": "U1M3DNhPb8li5y4zccrUR",
-          "columnIds": [
-            "9O46Rt9JdNS_X3fayZRJu"
-          ],
-          "x": 429.1237,
-          "y": 410.8041,
-          "direction": 2
+      "-RajDwQrGNUhD-VOx16c6": {
+        "id": "-RajDwQrGNUhD-VOx16c6",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "address",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
-        "end": {
-          "tableId": "cH4_5N71LdebT2IHo_Dd9",
-          "columnIds": [
-            "jOU5il8WO13kfZaWytFCs"
-          ],
-          "x": 544.9502,
-          "y": 440.9896,
-          "direction": 1
+        "meta": {
+          "updateAt": 1780293320723,
+          "createAt": 1780291676086
+        }
+      },
+      "X9GfhJHYWRINnR3p-GMkN": {
+        "id": "X9GfhJHYWRINnR3p-GMkN",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "address_detail",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 77,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1779935590724,
-          "createAt": 1779935590724
+          "updateAt": 1780293322044,
+          "createAt": 1780292099826
         }
-      }
-    },
-    "indexEntities": {
-      "-MCTIFN9XEQ-y6MQi7vTE": {
-        "id": "-MCTIFN9XEQ-y6MQi7vTE",
-        "name": "",
-        "tableId": "cH4_5N71LdebT2IHo_Dd9",
-        "indexColumnIds": [],
-        "seqIndexColumnIds": [],
-        "unique": false,
+      },
+      "5DgAuVzYXj2uLg3UoGqVP": {
+        "id": "5DgAuVzYXj2uLg3UoGqVP",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "address_refer",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 73,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
         "meta": {
-          "updateAt": 1779935189255,
-          "createAt": 1779935189255
+          "updateAt": 1780293323356,
+          "createAt": 1780292123263
         }
       },
-      "8Qk67gKfrATzMVhIHZ5ew": {
-        "id": "8Qk67gKfrATzMVhIHZ5ew",
-        "name": "",
-        "tableId": "cH4_5N71LdebT2IHo_Dd9",
-        "indexColumnIds": [],
-        "seqIndexColumnIds": [],
-        "unique": false,
+      "vprtd2uQxKTGdBZw9wKfC": {
+        "id": "vprtd2uQxKTGdBZw9wKfC",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "lat",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
         "meta": {
-          "updateAt": 1779935190160,
-          "createAt": 1779935190160
+          "updateAt": 1780293327294,
+          "createAt": 1780292351597
         }
       },
-      "Od9vkpiwxuNNbPvVAVfNF": {
-        "id": "Od9vkpiwxuNNbPvVAVfNF",
-        "name": "",
-        "tableId": "cH4_5N71LdebT2IHo_Dd9",
-        "indexColumnIds": [],
-        "seqIndexColumnIds": [],
-        "unique": false,
+      "QMRmKEJDnrOYxIqlG3_1t": {
+        "id": "QMRmKEJDnrOYxIqlG3_1t",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "lng",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
         "meta": {
-          "updateAt": 1779935203751,
-          "createAt": 1779935203751
+          "updateAt": 1780293336601,
+          "createAt": 1780292357296
         }
       },
-      "GiW8Tmldcc4cpFJ1vHGJk": {
-        "id": "GiW8Tmldcc4cpFJ1vHGJk",
-        "name": "",
-        "tableId": "cH4_5N71LdebT2IHo_Dd9",
-        "indexColumnIds": [],
-        "seqIndexColumnIds": [],
-        "unique": false,
+      "eU4jhpeI-x-Qg_8K-iE6r": {
+        "id": "eU4jhpeI-x-Qg_8K-iE6r",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "partnership_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "'N'",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 83,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293570757,
+          "createAt": 1780292361354
+        }
+      },
+      "nUzLO7vi86fu_9Gg0AvFs": {
+        "id": "nUzLO7vi86fu_9Gg0AvFs",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "bank_code",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293341524,
+          "createAt": 1780292371786
+        }
+      },
+      "dTvQg90ta33bfkOapBz99": {
+        "id": "dTvQg90ta33bfkOapBz99",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "account_number",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 90,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293343224,
+          "createAt": 1780292378574
+        }
+      },
+      "hi403V2CZ_WqB8IlIS3T7": {
+        "id": "hi403V2CZ_WqB8IlIS3T7",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "account_holder",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 83,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293345204,
+          "createAt": 1780292382744
+        }
+      },
+      "ueMQqZyl9zjAJkn3i96bW": {
+        "id": "ueMQqZyl9zjAJkn3i96bW",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "fish_species",
+        "comment": "주요어종",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 64,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293529510,
+          "createAt": 1780292447678
+        }
+      },
+      "mMLb46wNZSS4ZeX-0ideF": {
+        "id": "mMLb46wNZSS4ZeX-0ideF",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "operating_hours",
+        "comment": "운영시간",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 88,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293525440,
+          "createAt": 1780292498378
+        }
+      },
+      "ah56UcOTkHHWMFDOv-9gT": {
+        "id": "ah56UcOTkHHWMFDOv-9gT",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "status_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "'Y'",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293303526,
+          "createAt": 1780293277127
+        }
+      },
+      "zWf5fmlhczDF0uIHcbgzy": {
+        "id": "zWf5fmlhczDF0uIHcbgzy",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "'N'",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293306431,
+          "createAt": 1780293290507
+        }
+      },
+      "tHBZi3zUlI0cSx4ZaMYbf": {
+        "id": "tHBZi3zUlI0cSx4ZaMYbf",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293480536,
+          "createAt": 1780293468469
+        }
+      },
+      "h_PV6rCFXbJSKVePfzVgN": {
+        "id": "h_PV6rCFXbJSKVePfzVgN",
+        "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+        "name": "updated_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 62,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293490751,
+          "createAt": 1780293482389
+        }
+      },
+      "FGMqSJFVq4QlHNUcb15id": {
+        "id": "FGMqSJFVq4QlHNUcb15id",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293676564,
+          "createAt": 1780293659913
+        }
+      },
+      "5i3irVRE6-tHCSnLf6_y4": {
+        "id": "5i3irVRE6-tHCSnLf6_y4",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "fishing_id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 2,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293692636,
+          "createAt": 1780293685859
+        }
+      },
+      "PHL6u0--ZO1ZcyYflWxOu": {
+        "id": "PHL6u0--ZO1ZcyYflWxOu",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "original_name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 76,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293759131,
+          "createAt": 1780293696491
+        }
+      },
+      "Go8_Qwm0Lw_KGAZWDGsyp": {
+        "id": "Go8_Qwm0Lw_KGAZWDGsyp",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "stored_name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 70,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293758463,
+          "createAt": 1780293697930
+        }
+      },
+      "ELALBAiur0Gq95HFIUNuz": {
+        "id": "ELALBAiur0Gq95HFIUNuz",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "file_path",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293757592,
+          "createAt": 1780293698478
+        }
+      },
+      "0TL0WyKeSPpu7sKXS7cxX": {
+        "id": "0TL0WyKeSPpu7sKXS7cxX",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "file_size",
+        "comment": "",
+        "dataType": "BIGINT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293756363,
+          "createAt": 1780293698989
+        }
+      },
+      "7qXqhAioo16sA-YdK1ms4": {
+        "id": "7qXqhAioo16sA-YdK1ms4",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "mime_type",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293755260,
+          "createAt": 1780293699506
+        }
+      },
+      "RjmFTv58QRp6O3aYb0Mgv": {
+        "id": "RjmFTv58QRp6O3aYb0Mgv",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "width",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293746596,
+          "createAt": 1780293700106
+        }
+      },
+      "jVoVUuEshEveD4HQTYL2r": {
+        "id": "jVoVUuEshEveD4HQTYL2r",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "height",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293748045,
+          "createAt": 1780293700758
+        }
+      },
+      "lOxjLhJ0-hMaefkPCeSpu": {
+        "id": "lOxjLhJ0-hMaefkPCeSpu",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "sort_order",
+        "comment": "",
+        "dataType": "INT",
+        "default": "'0'",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293763120,
+          "createAt": 1780293701313
+        }
+      },
+      "zO1UMeqAKkf-A511wUToG": {
+        "id": "zO1UMeqAKkf-A511wUToG",
+        "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1780293752321,
+          "createAt": 1780293701853
+        }
+      }
+    },
+    "relationshipEntities": {
+      "02Rf0D1riQbaw0LqkaD6r": {
+        "id": "02Rf0D1riQbaw0LqkaD6r",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "FISm81kEr0UVuk9sKYz4j",
+          "columnIds": [
+            "tVtTK-A1GhU8WRdiZT89G"
+          ],
+          "x": 427.1134,
+          "y": 93.0825,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "cH4_5N71LdebT2IHo_Dd9",
+          "columnIds": [
+            "zH8U7DiFE0_b0k7y88KoF"
+          ],
+          "x": 687.2182,
+          "y": 155.835,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1779935483922,
+          "createAt": 1779935483922
+        }
+      },
+      "lo9HDcBWr2MulQ_cz9_mS": {
+        "id": "lo9HDcBWr2MulQ_cz9_mS",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "cH4_5N71LdebT2IHo_Dd9",
+          "columnIds": [
+            "T0k9O_Ol_BAHTkKFWaaVb"
+          ],
+          "x": 1087.2182,
+          "y": 295.835,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "zUvkqgDCrDrdAi4llCexp",
+          "columnIds": [
+            "9zEjG5L24xZPSGCUtmP49"
+          ],
+          "x": 1229.6505,
+          "y": 177.233,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1779935535175,
+          "createAt": 1779935535175
+        }
+      },
+      "UyIJVtBW8x18iGTJ7LZuv": {
+        "id": "UyIJVtBW8x18iGTJ7LZuv",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "U1M3DNhPb8li5y4zccrUR",
+          "columnIds": [
+            "9O46Rt9JdNS_X3fayZRJu"
+          ],
+          "x": 429.1237,
+          "y": 366.8041,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "cH4_5N71LdebT2IHo_Dd9",
+          "columnIds": [
+            "jOU5il8WO13kfZaWytFCs"
+          ],
+          "x": 687.2182,
+          "y": 435.83500000000004,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1779935590724,
+          "createAt": 1779935590724
+        }
+      },
+      "z3AF_pXmB6YPyWNFzaQmH": {
+        "id": "z3AF_pXmB6YPyWNFzaQmH",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "FISm81kEr0UVuk9sKYz4j",
+          "columnIds": [
+            "tVtTK-A1GhU8WRdiZT89G"
+          ],
+          "x": 427.1134,
+          "y": 217.08249999999998,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+          "columnIds": [
+            "RA54uihL-Tg51rhi61MhV"
+          ],
+          "x": 785.5666,
+          "y": 673.1958,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780291548815,
+          "createAt": 1780291548815
+        }
+      },
+      "6dWnTNRq3psvKvtiYAk_j": {
+        "id": "6dWnTNRq3psvKvtiYAk_j",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "U1M3DNhPb8li5y4zccrUR",
+          "columnIds": [
+            "9O46Rt9JdNS_X3fayZRJu"
+          ],
+          "x": 429.1237,
+          "y": 454.8041,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+          "columnIds": [
+            "U84ia2uANWjZ6-tF7KhhS"
+          ],
+          "x": 985.5666,
+          "y": 673.1958,
+          "direction": 4
+        },
+        "meta": {
+          "updateAt": 1780291627101,
+          "createAt": 1780291627101
+        }
+      },
+      "dPC1hrjZp8SIR9PkoUaLL": {
+        "id": "dPC1hrjZp8SIR9PkoUaLL",
+        "identification": false,
+        "relationshipType": 8,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "f0HLDJeJHSxkXnVkJNkFN",
+          "columnIds": [
+            "V6flU3IcG8jhBv4HetFn7"
+          ],
+          "x": 1085.5666,
+          "y": 941.1958,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "Rm_FvXbyIhbNwaAzppHJH",
+          "columnIds": [
+            "5i3irVRE6-tHCSnLf6_y4"
+          ],
+          "x": 1227.8358,
+          "y": 829.0721,
+          "direction": 1
+        },
         "meta": {
-          "updateAt": 1779935204830,
-          "createAt": 1779935204830
+          "updateAt": 1780293685859,
+          "createAt": 1780293685859
         }
       }
     },
+    "indexEntities": {},
     "indexColumnEntities": {},
     "memoEntities": {}
   }