Browse Source

[선상관리] 완료, 기간검색 추가 필요

DESKTOP-T61HUSC\user 1 month ago
parent
commit
a63af66d22

+ 59 - 0
app/assets/scss/admin.scss

@@ -7754,4 +7754,63 @@ footer {
   h3{
     color: #666B75;
   }
+}
+
+// 선상 & 낚시터 사진 추가
+.onboard--photo-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+.onboard--photo-item {
+  position: relative;
+  width: 120px;
+  height: 120px;
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid #e8eaef;
+}
+
+.onboard--photo-item img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.onboard--photo-remove {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  width: 22px;
+  height: 22px;
+  border: none;
+  border-radius: 50%;
+  background: rgba(0, 0, 0, 0.6);
+  cursor: pointer;
+  &::before{
+    content: '×';
+    top: -1px;
+    right: 6.5px;
+    position: absolute;
+    font-size: 15px;
+    color: #fff;
+  }
+}
+
+.onboard--photo-remove:hover {
+  background: rgba(0, 0, 0, 0.85);
+}
+
+.onboard--photo-badge {
+  position: absolute;
+  bottom: 4px;
+  left: 4px;
+  padding: 2px 8px;
+  border-radius: 4px;
+  background: var(--admin-red);
+  color: #fff;
+  font-size: 11px;
+  font-weight: 600;
 }

+ 80 - 7
app/pages/site-manager/onboard/create.vue

@@ -108,6 +108,32 @@
                 <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>
@@ -184,7 +210,7 @@
 
   const router = useRouter();
   const config = useRuntimeConfig();
-  const { get, post } = useApi();
+  const { get, post, upload } = useApi();
 
   const isSaving = ref(false);
   const successMessage = ref("");
@@ -195,6 +221,35 @@
   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: "산업은행" },
@@ -349,16 +404,34 @@
 
     isSaving.value = true;
     try {
+      // 1) 선상 등록
       const { data, error } = await post("/onboard", { ...formData.value });
 
       if (error || !data?.success) {
         errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
-      } else {
-        successMessage.value = data.message || "선상이 등록되었습니다.";
-        setTimeout(() => {
-          router.push("/site-manager/onboard/list");
-        }, 1000);
+        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(`/onboard/${newId}/photos`, fd);
+
+        if (photoErr || !photoRes?.success) {
+          // 선상은 등록됐으나 사진 일부 실패 — 안내 후 목록 이동
+          errorMessage.value = "선상은 등록됐지만 사진 업로드에 실패했습니다. 수정에서 다시 시도해주세요.";
+          setTimeout(() => router.push("/site-manager/onboard/list"), 1500);
+          return;
+        }
+      }
+
+      successMessage.value = data.message || "선상이 등록되었습니다.";
+      setTimeout(() => {
+        router.push("/site-manager/onboard/list");
+      }, 1000);
     } catch (e) {
       errorMessage.value = "서버 오류가 발생했습니다.";
       console.error("Save error:", e);
@@ -373,4 +446,4 @@
   onMounted(() => {
     loadOptions();
   });
-</script>
+</script>

+ 15 - 1
app/pages/site-manager/onboard/detail/[id].vue

@@ -54,6 +54,17 @@
               <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>
@@ -130,6 +141,7 @@
   const route = useRoute();
   const router = useRouter();
   const { get, del } = useApi();
+  const { getImageUrl } = useImage();
 
   const onboardId = route.params.id;
 
@@ -153,6 +165,7 @@
     status_YN: "Y",
     created_at: "",
     updated_at: "",
+    photos: [],
   });
 
   // 은행 코드 → 은행명
@@ -217,6 +230,7 @@
       status_YN: row.status_YN ?? "Y",
       created_at: row.created_at ?? "",
       updated_at: row.updated_at ?? "",
+      photos: row.photos ?? [],
     };
   };
 
@@ -258,4 +272,4 @@
   onMounted(() => {
     loadDetail();
   });
-</script>
+</script>

+ 447 - 277
app/pages/site-manager/onboard/edit/[id].vue

@@ -1,337 +1,507 @@
 <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.area_detail" type="text" class="admin--form-input" placeholder="예: 모슬포항" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>중량(톤수)</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.tonnage" type="text" class="admin--form-input w--240" placeholder="예: 9.77톤" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>탑승인원</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.capacity" type="text" class="admin--form-input w--240" placeholder="예: 20인승, 낚시 전용선" />
+                </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 onboardId = route.params.id;
 
   const isLoading = ref(true);
   const isSaving = ref(false);
   const successMessage = ref("");
   const errorMessage = ref("");
-  const branches = ref([]);
+  const coordError = ref("");
+
+  // 분야 / 지역 select 옵션
+  const fieldOptions = ref([]);
+  const areaOptions = ref([]);
+
+  // 사진
+  const photoInput = ref(null);
+  const photos = ref([]); // 새로 추가: { id, file, preview }
+  const existingPhotos = ref([]); // 기존: { id, file_path, original_name, ... }
+  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: "",
+    area_detail: "",
+    tonnage: "",
+    capacity: "",
+    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(`/onboard/${onboardId}`);
+    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 ?? "",
+      area_detail: row.area_detail ?? "",
+      tonnage: row.tonnage ?? "",
+      capacity: row.capacity ?? "",
+      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);
+    });
+
+  // 주소 → 좌표 (Google Geocoding)
+  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(`/onboard/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(`/onboard/${onboardId}`, { ...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(`/onboard/${onboardId}/photos`, fd);
+        if (photoErr || !photoRes?.success) {
+          errorMessage.value = "선상은 수정됐지만 사진 업로드에 실패했습니다.";
+          setTimeout(() => router.push(`/site-manager/onboard/detail/${onboardId}`), 1500);
+          return;
+        }
+      }
+
+      successMessage.value = data.message || "수정되었습니다.";
+      setTimeout(() => {
+        router.push(`/site-manager/onboard/detail/${onboardId}`);
+      }, 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/onboard/detail/${onboardId}`);
 
   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>
+</script>

+ 25 - 6
app/pages/site-manager/onboard/list.vue

@@ -3,18 +3,29 @@
     <!-- 상단 검색/액션 영역 -->
     <div class="admin--search-box">
       <div class="admin--search-form">
-        <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
+        <select v-model="searchField" class="admin--form-select admin--search-select">
           <option value="">전체</option>
-          <option value="Y">사용중</option>
-          <option value="N">미사용</option>
+          <option value="field">분야</option>
+          <option value="area">지역명</option>
+          <option value="name">선상명</option>
         </select>
         <input
           v-model="searchQuery"
           type="text"
-          placeholder="선상명, 지역명으로 검색"
+          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>
@@ -147,8 +158,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 visiblePages = computed(() => {
@@ -174,7 +187,11 @@
       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;
 
     const { data, error } = await get("/onboard/list", { params });
@@ -201,7 +218,9 @@
 
   // 검색 초기화
   const resetSearch = () => {
+    searchField.value = "";
     searchQuery.value = "";
+    filterPartnership.value = "";
     filterStatus.value = "";
     currentPage.value = 1;
     loadOnboards();

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

@@ -47,6 +47,9 @@ $routes->delete('api/area/(:num)', 'Api\FishingAreaController::delete/$1');
 $routes->get('api/onboard/list', 'Api\OnboardController::index');
 $routes->get('api/onboard/(:num)', 'Api\OnboardController::show/$1');
 $routes->post('api/onboard', 'Api\OnboardController::create');
+$routes->put('api/onboard/(:num)', 'Api\OnboardController::update/$1');
+$routes->post('api/onboard/(:num)/photos', 'Api\OnboardController::uploadPhotos/$1');
+$routes->delete('api/onboard/photo/(:num)', 'Api\OnboardController::deletePhoto/$1');
 $routes->delete('api/onboard/(:num)', 'Api\OnboardController::delete/$1');
 
 // File Upload

+ 270 - 6
backend/app/Controllers/Api/OnboardController.php

@@ -27,8 +27,10 @@ class OnboardController extends BaseApiController
             if ($perPage < 1) $perPage = 10;
             $offset = ($page - 1) * $perPage;
 
-            $search = trim((string) $this->request->getGet('search'));
-            $status = trim((string) $this->request->getGet('status'));
+            $searchField = trim((string) $this->request->getGet('search_field')); // '', field, area, name
+            $search      = trim((string) $this->request->getGet('search'));
+            $partnership = trim((string) $this->request->getGet('partnership'));
+            $status      = trim((string) $this->request->getGet('status'));
 
             $db = $this->getDB();
             $builder = $db->table($this->table . ' o');
@@ -37,10 +39,23 @@ class OnboardController extends BaseApiController
             $builder->where('o.deleted_YN', 'N');
 
             if ($search !== '') {
-                $builder->groupStart()
-                    ->like('o.name', $search)
-                    ->orLike('a.name', $search)
-                    ->groupEnd();
+                if ($searchField === 'field') {
+                    $builder->like('f.name', $search);
+                } elseif ($searchField === 'area') {
+                    $builder->like('a.name', $search);
+                } elseif ($searchField === 'name') {
+                    $builder->like('o.name', $search);
+                } else {
+                    // 전체: 분야 / 지역명 / 선상명
+                    $builder->groupStart()
+                        ->like('f.name', $search)
+                        ->orLike('a.name', $search)
+                        ->orLike('o.name', $search)
+                        ->groupEnd();
+                }
+            }
+            if ($partnership === 'Y' || $partnership === 'N') {
+                $builder->where('o.partnership_YN', $partnership);
             }
             if ($status === 'Y' || $status === 'N') {
                 $builder->where('o.status_YN', $status);
@@ -203,6 +218,14 @@ class OnboardController extends BaseApiController
             // 계좌번호 복호화
             $row->account_number = $this->decryptValue($row->account_number);
 
+            // 사진 목록 (정렬순)
+            $row->photos = $this->getDB()->table('onboard_photos')
+                ->where('onboard_id', (int) $id)
+                ->orderBy('sort_order', 'ASC')
+                ->orderBy('id', 'ASC')
+                ->get()
+                ->getResult();
+
             return $this->respondSuccess($row);
         } catch (\Exception $e) {
             log_message('error', 'OnboardController show error: ' . $e->getMessage());
@@ -210,6 +233,247 @@ class OnboardController extends BaseApiController
         }
     }
 
+    /**
+     * 선상 수정
+     * PUT /api/onboard/: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,
+                'area_detail'    => trim((string) ($payload['area_detail'] ?? '')),
+                'tonnage'        => trim((string) ($payload['tonnage'] ?? '')),
+                'capacity'       => trim((string) ($payload['capacity'] ?? '')),
+                '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', 'OnboardController update error: ' . $e->getMessage());
+            return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 선상 사진 삭제 (파일 + DB hard delete)
+     * DELETE /api/onboard/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('onboard_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('onboard_photos')->where('id', (int) $photoId)->delete();
+
+            return $this->respondSuccess(null, '사진이 삭제되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'OnboardController deletePhoto error: ' . $e->getMessage());
+            return $this->respondError('사진 삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 선상 사진 업로드 (다중)
+     * POST /api/onboard/: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/onboard/';
+            if (!is_dir($uploadPath)) {
+                mkdir($uploadPath, 0755, true);
+            }
+
+            // 기존 사진의 최대 sort_order 다음부터 부여
+            $maxRow = $db->table('onboard_photos')
+                ->selectMax('sort_order')
+                ->where('onboard_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 검증
+                $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 = [
+                    'onboard_id'    => (int) $id,
+                    'original_name' => $originalName,
+                    'stored_name'   => $newName,
+                    'file_path'     => '/uploads/onboard/' . $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('onboard_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', 'OnboardController uploadPhotos error: ' . $e->getMessage());
+            return $this->respondError('사진 업로드 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
     /**
      * 선상 삭제 (soft delete)
      * DELETE /api/onboard/:id

+ 1 - 1
db.vuerd.json

@@ -4,7 +4,7 @@
   "settings": {
     "width": 2000,
     "height": 2000,
-    "scrollTop": 0,
+    "scrollTop": -100,
     "scrollLeft": -430.3514,
     "zoomLevel": 0.97,
     "show": 431,