فهرست منبع

[챌린지] 리스트, 등록 진행중

DESKTOP-T61HUSC\user 6 روز پیش
والد
کامیت
6d018ca919
4فایلهای تغییر یافته به همراه2397 افزوده شده و 198 حذف شده
  1. 370 1
      app/assets/scss/admin.scss
  2. 582 0
      app/pages/site-manager/challenge/create.vue
  3. 316 0
      app/pages/site-manager/challenge/list.vue
  4. 1129 197
      db.vuerd.json

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

@@ -1887,7 +1887,6 @@ footer {
   border-radius: 8px;
   padding: 24px;
   border: 1px solid var(--admin-border-color);
-
   .admin--sub--table--title{
     display: flex;
     justify-content: space-between;
@@ -1915,6 +1914,7 @@ footer {
   }
 }
 
+
 // Admin Dashboard
 .admin--dashboard {
   .admin--dashboard-welcome {
@@ -2021,6 +2021,365 @@ footer {
     }
   }
 
+  .admin--table--middle--title{
+    font-size: 13px;
+    color: #1a2b4a;
+    font-weight: 700;
+    margin-top: 20px;
+  }
+  .admin--table--middle--desc{
+    margin-top: 8px;
+    color: #666b75;
+    font-size: 13px;
+    margin-bottom: 20px;
+  }
+  
+  .admin--round--box--wrap{
+    padding: 16px 20px;
+    border-radius: 8px;
+    border: 1px solid #eaecf0;
+    .admin--round--title{
+      display: flex;
+      align-items: center;
+      gap: 40px;
+      color: #1a2b4a;
+      font-size: 13px;
+      font-weight: 700;
+      span{
+        border-radius: 6px;
+        background-color: #f1f3f5;
+        padding: 5px 20px;
+        color: #666b75;
+        font-size: 12px;
+        font-weight: 400;
+      }
+      button{
+        padding: 8px 16px;
+        cursor: pointer;
+        font-weight: 600;
+        font-size: 13px;
+        color: #fff;
+        margin-left: auto;
+        background-color: var(--admin-red);
+        border-radius: 4px;
+        &.place--remove--btn{
+          background-color: transparent;
+          color: #9aa0aa;
+          font-size: 14px;
+          font-weight: 400;
+          padding: 6px;
+        }
+      }
+    }
+    .admin--round--box{
+      margin-top: 16px;
+      display: flex;
+      flex-direction: column;
+      .input--wrap{
+        display: flex;
+        gap: 12px;
+        .admin--round--radio{
+          cursor: pointer;
+          display: flex;
+          align-items: center;
+          width: 50%;
+          gap: 5px;
+          max-width: 360px;
+          border-radius: 6px;
+          border: 1px solid #c8d5e6;
+          padding: 10px 16px;
+          color: #666b75;
+          font-size: 13px;
+          font-weight: 400;
+          &:has(input:checked){
+            background-color: #ebf7fa;
+            border: 2px solid #17a2b8;
+            color: #17a2b8;
+            font-weight: 600;
+          }
+          input[type=radio]{
+            appearance: none;
+            width: 8px;
+            height: 8px;
+            border: 1px solid #666b75;
+            border-radius: 50%;
+            &:checked{
+              background-color: #17a2b8;
+              border-color: #17a2b8;
+            }
+          }
+        }
+      }
+      p{
+        color: #666b75;
+        font-size: 12px;
+        font-weight: 400;
+      }
+      .qual--wrap{
+        .input--wrap{
+          align-items: center;
+          input{
+            height: 40px;
+          }
+          .w--120{
+            width: 120px;
+          }
+          span{
+            color: #666b75;
+            font-size: 12px;
+            font-weight: 400;
+          }
+        }
+      }
+      .round--place--wrap{
+        border-radius: 6px;
+        border: 1px solid #eaecf0;
+        display: flex;
+        flex-direction: column;
+        background-color: #f7f8fa;
+        margin-top: 16px;
+        padding: 16px;
+      }
+      .place--select--wrap{
+        .input--wrap{
+          flex-wrap: wrap;
+          .admin--form-select{
+            height: 40px;
+            min-width: 120px;
+            width: fit-content;
+            line-height: 1;
+            text-align: left;
+          }
+        }
+        input[type="checkbox"] {
+          appearance: none;
+          -webkit-appearance: none;
+          -moz-appearance: none;
+          width: 18px;
+          height: 18px;
+          margin: 0;
+          border: 1.5px solid #cbd5e0;
+          border-radius: 4px;
+          background-color: #fff;
+          background-position: center;
+          background-repeat: no-repeat;
+          background-size: 12px 12px;
+          cursor: pointer;
+          display: inline-block;
+          vertical-align: middle;
+          transition: background-color 0.15s ease, border-color 0.15s ease;
+          flex-shrink: 0;
+
+          &:hover {
+            border-color: var(--admin-accent-primary);
+          }
+
+          &:focus-visible {
+            outline: 2px solid rgba(26, 35, 50, 0.25);
+            outline-offset: 1px;
+          }
+
+          &:checked {
+            background-color: #fff;
+            border-color: var(--admin-accent-primary);
+            background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M3.5 8L6.75 11.25L12.5 5.25' stroke='%231A2332' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+          }
+        }
+        .place--select--btn--wrap{
+          .all--place--wrap{
+            margin-top: 12px;
+            width: 460px;
+            background-color: #fff;
+            border: 1px solid #c8d5e6;
+            border-radius: 8px;
+            .place--top{
+              padding: 8px 12px;
+              .search--wrap{
+                margin-bottom: 6px;
+                input{
+                  border-radius: 6px;
+                  border: 1px solid #eaecf0;
+                  background-color: #f7f8fa;
+                  width: 100%;
+                  padding: 8px 12px;
+                  color: #9aa0aa;
+                  font-size: 13px;
+                  font-weight: 400;
+                }
+              }
+              .check--wrap{
+                margin-bottom: 10px;
+                label{
+                  cursor: pointer;
+                  border-radius: 6px;
+                  background-color: #edf5fc;
+                  padding: 8px 12px;
+                  color: #125ea6;
+                  font-weight: 600;
+                  font-size: 13px;
+                  display: flex;
+                  gap: 10px;
+                  span{
+                    font-weight: 400;
+                  }
+                }
+              }
+              .all--place{
+                .all--place--list{
+                  height: 100px;
+                  overflow-y: auto;
+                  li{
+                    label{
+                      padding: 6px 0;
+                      cursor: pointer;
+                      display: flex;
+                      align-items: center;
+                      gap: 10px;
+                      color: #1a2b4a;
+                      font-size: 13px;
+                      font-weight: 600;
+                      span{
+                        &:nth-of-type(1){
+                          flex: 1;
+                        }
+                      }
+                      .off{
+                        width: 50px;
+                        min-width: 50px;
+                        border-radius: 4px;
+                        background-color: #f0f2f5;
+                        padding: 4px;
+                        text-align: center;
+                        color: #666b75;
+                        font-size: 12px;
+                        font-weight: 600;
+                      }
+                      .on{
+                        width: 50px;
+                        min-width: 50px;
+                        border-radius: 4px;
+                        padding: 4px;
+                        text-align: center;
+                        font-size: 12px;
+                        font-weight: 600;
+                        background-color: #EDF5FC;
+                        color: #125ea6;
+                      }
+                      p {
+                        width: 200px;
+                        min-width: 200px;
+                      }
+                    }
+                  }
+                }
+              }
+            }
+            .place--bot{
+              display: flex;
+              justify-content: space-between;
+              padding: 8px 12px;
+              align-items: center;
+              border-top: 1px solid #eaecf0;
+              button{
+                font-weight: 600;
+                cursor: pointer;
+                color: #fff;
+                background-color: #17a2b8;
+                border-radius: 4px;
+                padding: 4px 20px;
+                font-size: 12px;
+              }
+            }
+          }
+          div{
+            &.admin--form-select{
+              display: flex;
+              gap: 8px;
+              width: 100%;
+              max-width: 460px;
+              .place--selected{
+                border-radius: 5px;
+                background-color: #edf5fc;
+                padding: 6px 12px;
+                color: #125ea6;
+                font-size: 12px;
+                font-weight: 600;
+                button{
+                  color: #7394c2;
+                  font-weight: 400;
+                  cursor: pointer;
+                  padding-left: 10px;
+                }
+              }
+            }
+          }
+        }
+      }
+      .item--select--wrap{
+        .item--select--btn--wrap{
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-end;
+          button{
+            padding: 8px 16px;
+            cursor: pointer;
+            font-weight: 600;
+            font-size: 13px;
+            color: #fff;
+            background-color: #17a2b8;
+            border-radius: 4px;
+          }
+        }
+        .item--selected--wrap{
+          display: flex;
+          gap: 8px;
+          flex-wrap: wrap;
+          .item--selected{
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            min-width: 140px;
+            border-radius: 6px;
+            padding: 8px 12px;
+            font-size: 12px;
+            font-weight: 400;
+            color: #374151;
+            border: 1px solid #eaecf0;
+            background-color: #f3f4f6;
+            button{
+              color: #9aa0aa;
+              cursor: pointer;
+              font-size: 10px;
+            }
+          }
+        }
+      }
+      .place--add--btn{
+        margin-top: 16px;
+        text-align: center;
+        border-radius: 6px;
+        border: 1px dashed #c8d5e6;
+        background-color: #fff;
+        padding: 8px;
+        color: #666b75;
+        font-size: 14px;
+        cursor: pointer;
+        font-weight: 400;
+      }
+    }
+  }
+  .round--add--btn{
+    text-align: center;
+    margin-top: 16px;
+    border-radius: 6px;
+    border: 1px dashed #17a2b8;
+    padding: 12px;
+    color: #17a2b8;
+    font-weight: 600;
+    font-size: 14px;
+    cursor: pointer;
+  }
+
   .admin--form-input,
   .admin--form-textarea,
   .admin--form-select {
@@ -2432,6 +2791,13 @@ footer {
   overflow-x: auto;
 }
 
+.admin--table--count{
+  color: #666b75;
+  font-size: 12px;
+  font-weight: 400;
+  margin-bottom: 20px;
+}
+
 .admin--table {
   &.fish--table{
     // 챌린지&퀘스트 어종관리 테이블
@@ -7869,6 +8235,9 @@ footer {
         &.w--full{
           width: 100%;
         }
+        &.w--60{
+          width: 60px;
+        }
         &.w--120{
           width: 120px;
         }

+ 582 - 0
app/pages/site-manager/challenge/create.vue

@@ -0,0 +1,582 @@
+<template>
+  <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">
+                  <input v-model="formData.name" type="text" class="w--full admin--form-input" placeholder="예: 동해안 왕대구 선상낚시대회" required />
+                </div>
+                <div class="input--wrap">
+                </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="예: 10,000" required />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>기간 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <DatePicker v-model="startDate" placeholder="📅 YYYY-MM-DD" />
+                  <span class="admin--date-separator">-</span>
+                  <DatePicker v-model="endDate" placeholder="📅 YYYY-MM-DD" />
+                </div>
+              </td>
+            </tr>
+            <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--60" required>
+                    <option value="">0</option>
+                    <option value="1">1</option>
+                  </select>
+                  <select v-model="formData.field_id" class="admin--form-select w--60" required>
+                    <option value="">0</option>
+                    <option value="1">1</option>
+                  </select>
+                  <select v-model="formData.field_id" class="admin--form-select w--60" required>
+                    <option value="">0</option>
+                    <option value="1">1</option>
+                  </select>
+                  <select v-model="formData.field_id" class="admin--form-select w--60" required>
+                    <option value="">0</option>
+                    <option value="1">1</option>
+                  </select>
+                  <select v-model="formData.field_id" class="admin--form-select w--60" required>
+                    <option value="">0</option>
+                    <option value="1">1</option>
+                  </select>
+                  <select v-model="formData.field_id" class="admin--form-select w--60" required>
+                    <option value="">0</option>
+                    <option value="1">1</option>
+                  </select>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>타이틀 이미지</div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    ref="imageInput"
+                    type="file"
+                    accept="image/*"
+                    class="admin--form-file-hidden"
+                    @change="onImageChange"
+                  />
+                  <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
+                    이미지 선택
+                  </button>
+                  <span v-if="image" class="ml--16">{{ image.file.name }}</span>
+                </div>
+                <p class="mt--10">권장 1200x800, JPG/PNG/GIF/WebP, 5MB 이하 (선택 사항)</p>
+                <div v-if="image" class="onboard--photo-grid mt--10">
+                  <div class="onboard--photo-item">
+                    <img :src="image.preview" alt="미리보기" />
+                    <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
+                  </div>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>상태 <span class="admin--required">*</span></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>
+            <tr>
+              <th><div>상세내용</div></th>
+              <td>
+                <div class="input--wrap">
+                  <textarea v-model="formData.address_refer" type="text" class="admin--form-input w--full" placeholder="챌린지 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)" />
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <h3 class="admin--table--middle--title">라운드 설정</h3>
+        <p class="admin--table--middle--desc">
+          라운드별로 장소 범위(전체/개별)를 선택하고, 장소마다 아이템을 배정합니다. 최대 5라운드까지 등록할 수 있습니다.
+        </p>
+
+        <div class="admin--round--box--wrap">
+          <div class="admin--round--title">라운드 1 <span>전체 장소ㆍ아이템 2</span>
+          <!-- 최소 2라운드 이상. 3라운드부터 라운드 삭제 버튼 노출 -->
+          <!-- <button>
+            라운드 삭제
+          </button> -->
+          </div>
+          <div class="admin--round--box">
+            <div class="input--wrap">
+              <label class="admin--round--radio">
+                <!-- 디폴트 선택값 -->
+                <input type="radio" value="all">
+                전체 장소에 동일 적용
+              </label>
+              <label class="admin--round--radio">
+                <input type="radio" value="specific">
+                장소별 개별 설정
+              </label>
+            </div>
+            <div class="qual--wrap">
+              <p class="mt--16 mb--4">진출자 확률</p>
+              <div class="input--wrap">
+                <input v-model="formData.name" type="text" class="admin--form-input w--120" placeholder="예: 30" required />
+                <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
+                <span>명</span>
+              </div>
+            </div>
+            <div class="item--select--wrap">
+              <div class="item--select--btn--wrap mt--16 mb--4">
+                <p class="">배정 아이템ㆍ수량 2</p>
+                <button>+ 아이템 선택</button>
+              </div>
+              <div class="item--selected--wrap">
+                <div class="item--selected">아이템 이름<button>✕</button></div>
+                <div class="item--selected">아이템 이름<button>✕</button></div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="admin--round--box--wrap mt--16">
+          <div class="admin--round--title">라운드 2 <span>전체 장소ㆍ아이템 2</span>
+        </div>
+          <div class="admin--round--box">
+            <div class="input--wrap">
+              <label class="admin--round--radio">
+                <!-- 디폴트 선택값 -->
+                <input type="radio" value="all">
+                전체 장소에 동일 적용
+              </label>
+              <label class="admin--round--radio">
+                <input type="radio" value="specific">
+                장소별 개별 설정
+              </label>
+            </div>
+            <div class="qual--wrap">
+              <p class="mt--16 mb--4">진출자 확률</p>
+              <div class="input--wrap">
+                <input v-model="formData.name" type="text" class="admin--form-input w--120" placeholder="예: 30" required />
+                <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
+                <span>명</span>
+              </div>
+            </div>
+            <div class="item--select--wrap">
+              <div class="item--select--btn--wrap mt--16 mb--4">
+                <p class="">배정 아이템ㆍ수량 2</p>
+                <button>+ 아이템 선택</button>
+              </div>
+              <div class="item--selected--wrap">
+                <div class="item--selected">아이템 이름<button>✕</button></div>
+                <div class="item--selected">아이템 이름<button>✕</button></div>
+              </div>
+            </div>
+            <div class="round--place--wrap">
+              <div class="admin--round--title">
+                장소 1
+                <button class="place--remove--btn">✕</button>
+              </div>
+              <div class="place--select--wrap">
+                <p class="mb--4">장소 정의</p>
+                <div class="input--wrap">
+                  <select class="admin--form-select" required>
+                    <option value="">전체 분야</option>
+                  </select>
+                  <select class="admin--form-select" required>
+                    <option value="">전체 지역</option>
+                  </select>
+                  <select class="admin--form-select" required>
+                    <option value="">제휴</option>
+                  </select>
+                  <!-- 선상 선택 전 노출 -->
+                  <div class="place--select--btn--wrap">
+                    <button class="admin--form-select" required>
+                      <option value="">선상 선택</option>
+                    </button>
+                    <div class="all--place--wrap">
+                      <div class="place--top">
+                        <div class="search--wrap">
+                          <input type="text" placeholder="선상명 검색">
+                        </div>
+                        <div class="check--wrap">
+                          <label>
+                            <input type="checkbox">
+                            전체
+                            <span>모든 등록 선상에 적용</span>
+                          </label>
+                        </div>
+                        <div class="all--place">
+                          <p>등록된 선상ㆍ지역명</p>
+                          <ul class="all--place--list mt--6">
+                            <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
+                            <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
+                            <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
+                            <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
+                          </ul>
+                        </div>
+                      </div>
+                      <div class="place--bot">
+                        <p>3개 선택</p>
+                        <button>적용</button>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+                <!-- 선상 선택 후 노출 -->
+                <p class="mt--16 mb--4">선상ㆍ복수 선택</p>
+                <div class="place--select--btn--wrap">
+                  <div class="admin--form-select">
+                    <div class="place--selected">
+                      동산피싱
+                      <button>✕</button>
+                    </div>
+                    <div class="place--selected">
+                      행복마린호
+                      <button>✕</button>
+                    </div>
+                    <div class="place--selected">
+                      + 8
+                    </div>
+                  </div>
+                  <div class="all--place--wrap">
+                    <div class="place--top">
+                      <div class="search--wrap">
+                        <input type="text" placeholder="선상명 검색">
+                      </div>
+                      <div class="check--wrap">
+                        <label>
+                          <input type="checkbox">
+                          전체
+                          <span>모든 등록 선상에 적용</span>
+                        </label>
+                      </div>
+                      <div class="all--place">
+                        <p>등록된 선상ㆍ지역명</p>
+                        <ul class="all--place--list mt--6">
+                          <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
+                          <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
+                          <li><label><input type="checkbox"><span>동산피싱</span><span class="off">비제휴</span> <p>지역명ㆍ분야</p></label></li>
+                          <li><label><input type="checkbox"><span>동산피싱</span><span class="on">제휴</span> <p>지역명ㆍ분야</p></label></li>
+                        </ul>
+                      </div>
+                    </div>
+                    <div class="place--bot">
+                      <p>3개 선택</p>
+                      <button>적용</button>
+                    </div>
+                  </div>
+                </div>
+              </div>
+              <div class="item--select--wrap">
+                <div class="item--select--btn--wrap mb--4 mt--16">
+                  <p>배정 아이템ㆍ수량 2</p>
+                  <button>+ 아이템 선택</button>
+                </div>
+                <div class="item--selected--wrap">
+                  <div class="item--selected">아이템 이름<button>✕</button></div>
+                  <div class="item--selected">아이템 이름<button>✕</button></div>
+                </div>
+              </div>
+            </div>
+            <div class="place--add--btn">
+              + 장소 추가
+            </div>
+          </div>
+        </div>
+        <div class="round--add--btn">
+          + 라운드 추가 (최대 5라운드)
+        </div>
+
+        <!-- 버튼 영역 -->
+        <div class="admin--form-actions">
+          <button type="button" class="admin--btn" @click="goToList">
+            ← 목록으로
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+            {{ isSaving ? "저장 중..." : "저장" }}
+          </button>
+        </div>
+
+        <!-- 성공/에러 메시지 -->
+        <div v-if="successMessage" class="admin--alert admin--alert-success">
+          {{ successMessage }}
+        </div>
+        <div v-if="errorMessage" class="admin--alert admin--alert-error">
+          {{ errorMessage }}
+        </div>
+      </form>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, watch, onMounted } from "vue";
+  import { useRouter } from "vue-router";
+  import DatePicker from "~/components/admin/DatePicker.vue";
+
+  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 imageInput = ref(null);
+  const image = ref(null);
+  const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
+
+  const triggerImageInput = () => imageInput.value?.click();
+
+  const onImageChange = (e) => {
+    const file = (e.target.files || [])[0];
+    e.target.value = "";
+    if (!file) return;
+    if (!file.type.startsWith("image/")) {
+      errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
+      return;
+    }
+    if (file.size > MAX_IMAGE_SIZE) {
+      errorMessage.value = "이미지가 10MB를 초과합니다.";
+      return;
+    }
+    if (image.value) URL.revokeObjectURL(image.value.preview);
+    image.value = { file, preview: URL.createObjectURL(file) };
+  };
+
+  const removeImage = () => {
+    if (image.value) {
+      URL.revokeObjectURL(image.value.preview);
+      image.value = null;
+    }
+  };
+
+  // 주요 은행 목록 (금융결제원 표준 코드)
+  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;
+    }
+
+    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;
+    }
+
+    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/fishing/list");
+      }, 1000);
+    } catch (e) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Save error:", e);
+    } finally {
+      isSaving.value = false;
+    }
+  };
+
+  // 목록으로 이동
+  const goToList = () => router.push("/site-manager/fishing/list");
+
+  onMounted(() => {
+    loadOptions();
+  });
+</script>

+ 316 - 0
app/pages/site-manager/challenge/list.vue

@@ -0,0 +1,316 @@
+<template>
+  <div class="admin--field-list">
+    <!-- 상단 검색/액션 영역 -->
+    <div class="admin--search-box type2">
+      <div class="admin--search--inner--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>
+            <option value="">종료</option>
+          </select>
+          <input
+            v-model="searchQuery"
+            type="text"
+            placeholder="챌린지명으로 검색"
+            @keyup.enter="onSearch"
+            class="admin--form-input admin--search-input"
+          />
+          <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
+          <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
+        </div>
+        <div class="admin--search-actions">
+          <button class="admin--btn-add" @click="goToCreate">+ 새 챌린지 등록</button>
+        </div>
+      </div>
+      <div class="admin--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>
+
+    <!-- 테이블 -->
+    <p class="admin--table--count">전체 42ㆍ모집중 12ㆍ진행중 19ㆍ종료 12</p>
+    <div class="admin--table-wrapper">
+      <table class="admin--table fishing--table">
+        <thead>
+          <tr>
+            <th style="width: 40px;">번호</th>
+            <th>챌린지명</th>
+            <th style="width: 100px;">상태</th>
+            <th style="width: 100px;">라운드</th>
+            <th style="width: 100px;">모집/전체</th>
+            <th style="">기간</th>
+            <th style="width: 120px;">액션</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="7" class="admin--table-loading">데이터를 불러오는 중...</td>
+          </tr>
+          <tr v-else-if="!spots || spots.length === 0">
+            <td colspan="7" class="admin--table-empty">등록된 챌린지가 없습니다.</td>
+          </tr>
+          <tr
+            v-else
+            v-for="(item, index) in spots"
+            :key="item.id"
+            class="admin--table-row-clickable"
+            @click="goToDetail(item.id)"
+          >
+            <td class="date">{{ totalCount - ((currentPage - 1) * perPage + index) }}</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', 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(item.id)">
+                  수정
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        v-if="totalPages > 2"
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(1)"
+        title="처음"
+      >
+        ◀◀
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+        title="이전"
+      >
+        ◀
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+        title="다음"
+      >
+        ▶
+      </button>
+      <button
+        v-if="totalPages > 2"
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(totalPages)"
+        title="끝"
+      >
+        ▶▶
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted } from "vue";
+  import { useRouter } from "vue-router";
+  import DatePicker from "~/components/admin/DatePicker.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { get } = useApi();
+
+  const isLoading = ref(false);
+  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 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(() => {
+    const pages = [];
+    const maxVisible = 5;
+    let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
+    let end = Math.min(totalPages.value, start + maxVisible - 1);
+
+    if (end - start < maxVisible - 1) {
+      start = Math.max(1, end - maxVisible + 1);
+    }
+    for (let i = start; i <= end; i++) {
+      pages.push(i);
+    }
+    return pages;
+  });
+
+  // 데이터 로드
+  const loadSpots = async () => {
+    isLoading.value = true;
+
+    const params = {
+      page: currentPage.value,
+      per_page: perPage.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("/challenge/list", { params });
+
+    if (error) {
+      console.error("[ChallengeList] 목록 로드 실패:", error);
+      spots.value = [];
+      totalCount.value = 0;
+      totalPages.value = 0;
+    } else if (data?.success && data?.data) {
+      spots.value = data.data.items || [];
+      totalCount.value = data.data.total || 0;
+      totalPages.value = data.data.total_pages || 0;
+    }
+
+    isLoading.value = false;
+  };
+
+  // 검색
+  const onSearch = () => {
+    currentPage.value = 1;
+    loadSpots();
+  };
+
+  // 검색 초기화
+  const resetSearch = () => {
+    searchField.value = "";
+    searchQuery.value = "";
+    filterPartnership.value = "";
+    filterStatus.value = "";
+    startDate.value = "";
+    endDate.value = "";
+    currentPage.value = 1;
+    loadSpots();
+  };
+
+  // 페이지 변경
+  const changePage = (page) => {
+    if (page < 1 || page > totalPages.value) return;
+    currentPage.value = page;
+    loadSpots();
+    window.scrollTo({ top: 0, behavior: "smooth" });
+  };
+
+  // 이동
+  const goToCreate = () => router.push("/site-manager/challenge/create");
+  const goToDetail = (id) => router.push(`/site-manager/challenge/detail/${id}`);
+  const goToEdit = (id) => router.push(`/site-manager/challenge/edit/${id}`);
+
+  // 상태 라벨 / 뱃지 클래스
+  const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
+  const getStatusBadgeClass = (status) =>
+    status === "Y" ? "admin--badge-active" : "admin--badge-ended";
+
+  // 날짜 포맷
+  const formatDate = (dateString) => {
+    if (!dateString) return "-";
+    const date = new Date(dateString.replace(" ", "T"));
+    if (isNaN(date.getTime())) return dateString;
+    return date.toLocaleDateString("ko-KR", {
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+    });
+  };
+
+  onMounted(() => {
+    loadSpots();
+  });
+</script>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1129 - 197
db.vuerd.json


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است