Преглед изворни кода

[챌린지관리] 1차 기능 완료, 추후 신청자/참가자관리 개발 필요

DESKTOP-T61HUSC\user пре 5 дана
родитељ
комит
7d7f225fd8

+ 345 - 4
app/assets/scss/admin.scss

@@ -2045,6 +2045,12 @@ footer {
       color: #1a2b4a;
       font-size: 13px;
       font-weight: 700;
+      flex-wrap: wrap;
+      @media(max-width: 768px){
+        // flex-direction: column;
+        // align-items: flex-start;
+        gap: 12px;
+      }
       span{
         border-radius: 6px;
         background-color: #f1f3f5;
@@ -2052,6 +2058,11 @@ footer {
         color: #666b75;
         font-size: 12px;
         font-weight: 400;
+        &.closed--txt{
+          margin-left: auto;
+          background-color: var(--admin-red);
+          color: #fff;
+        }
       }
       button{
         padding: 8px 16px;
@@ -2072,7 +2083,7 @@ footer {
       }
     }
     .admin--round--box{
-      margin-top: 16px;
+      // margin-top: 16px;
       display: flex;
       flex-direction: column;
       .input--wrap{
@@ -2115,6 +2126,18 @@ footer {
         font-size: 12px;
         font-weight: 400;
       }
+      .group--header{
+        color: #374151;
+        font-weight: 600;
+        display: flex;
+        justify-content: space-between;
+        padding: 16px 0 6px;
+        button{
+          cursor: pointer;
+          color: #125ea6;
+          font-weight: 400;
+        }
+      }
       .qual--wrap{
         .input--wrap{
           align-items: center;
@@ -2226,7 +2249,8 @@ footer {
               }
               .all--place{
                 .all--place--list{
-                  height: 100px;
+                  min-height: 100px;
+                  max-height: 480px;
                   overflow-y: auto;
                   li{
                     label{
@@ -2314,6 +2338,23 @@ footer {
             }
           }
         }
+        .item--selected--wrap{
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+          .item--selected{
+            border-radius: 5px;
+            padding: 6px 12px;
+            background: rgba(59, 130, 246, 0.1);
+            color: #3b82f6;
+            font-size: 12px;
+            font-weight: 600;
+            &.onboard{
+              background: rgba(16, 185, 129, 0.1);
+              color: var(--admin-success);
+            }
+          }
+        }
       }
       .item--select--wrap{
         .item--select--btn--wrap{
@@ -2372,6 +2413,7 @@ footer {
     text-align: center;
     margin-top: 16px;
     border-radius: 6px;
+    width: 100%;
     border: 1px dashed #17a2b8;
     padding: 12px;
     color: #17a2b8;
@@ -3062,6 +3104,21 @@ footer {
     color: #6b7280;
   }
 
+  &.admin--badge-hidden {
+    color: #fff;
+    background-color: #6b7280;
+  }
+
+  &.admin--badge-recruiting{
+    background-color: #FFF0EC;
+    color: #e85c3f;
+  }
+
+  &.admin--badge-running{
+    background: rgba(16, 185, 129, 0.1);
+    color: var(--admin-success);
+  }
+
   // 슈퍼 관리자 전용 — 진한 살구/골드 톤 + 진한 글씨
   &.admin--badge-super {
     color: #fff;
@@ -3602,8 +3659,8 @@ footer {
     appearance: none;
     -webkit-appearance: none;
     -moz-appearance: none;
-    width: 18px;
-    height: 18px;
+    width: 18px!important;
+    height: 18px!important;
     margin: 0;
     border: 1.5px solid #cbd5e0;
     border-radius: 4px;
@@ -8597,6 +8654,290 @@ footer {
   display: none;
 }
 
+// ============================================
+// 챌린지 디테일 - 메인 탭 (챌린지/신청자/참가자 관리)
+// ============================================
+.admin--main-tabs {
+  display: flex;
+  gap: 0;
+  border-bottom: 3px solid var(--admin-accent-primary);
+  margin-bottom: 24px;
+
+  button {
+    padding: 14px 32px;
+    border-bottom: none;
+    cursor: pointer;
+    font-size: 14px;
+    font-weight: 600;
+    color: #6b7280;
+    margin-right: -1px;
+    border-radius: 8px 8px 0 0;
+    transition: all 0.15s ease;
+
+    &:hover {
+      background: #fff;
+      color: #1f2937;
+    }
+
+    &.is-active {
+      background: var(--admin-accent-primary, #bb0a30);
+      color: #fff;
+      border-color: var(--admin-accent-primary, #bb0a30);
+      position: relative;
+      z-index: 1;
+    }
+  }
+}
+
+.admin--placeholder {
+  padding: 80px 24px;
+  text-align: center;
+  font-size: 16px;
+  color: #4b5563;
+  background: #fff;
+  border: 1px dashed #d1d5db;
+  border-radius: 12px;
+
+  p:first-child {
+    font-size: 18px;
+    font-weight: 600;
+    color: #1f2937;
+    margin: 0;
+  }
+}
+
+// ============================================
+// 챌린지 디테일 - 라운드 탭
+// ============================================
+.admin--round--tabs {
+  display: flex;
+  border-bottom: 2px solid #eaecf0;
+  margin-bottom: 12px;
+}
+
+.admin--round--tab {
+  position: relative;
+  padding: 12px 22px;
+  background: transparent;
+  border: none;
+  border-bottom: 3px solid transparent;
+  margin-bottom: -3px;
+  gap: 6px;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: 400;
+  color: #808791;
+  display: inline-flex;
+  align-items: center;
+  transition: all 0.15s ease;
+
+  &:hover {
+    color: #000;
+    font-weight: 600;
+  }
+
+  &.is-active {
+    color: #000;
+    font-weight: 600;
+    border-bottom-color: #17A2B8;
+  }
+
+  .admin--round--tab__badge {
+    display: inline-block;
+    padding: 2px 8px;
+    border-radius: 10px;
+    background: #9ca3af;
+    color: #fff;
+    font-size: 11px;
+    font-weight: 600;
+  }
+}
+
+// ============================================
+// 챌린지 등록 - 아이템 선택 모달
+// ============================================
+.admin--item-modal {
+  min-width: 640px !important;
+  max-width: 820px !important;
+  width: 90vw;
+
+  .admin--modal-body {
+    padding: 20px 22px;
+    max-height: 65vh;
+    overflow-y: auto;
+  }
+
+  &__search {
+    input {
+      padding: 10px 14px;
+      border-radius: 8px;
+      border: 1px solid #d1d5db;
+      font-size: 14px;
+
+      &:focus {
+        outline: none;
+        border-color: var(--admin-accent-primary, #bb0a30);
+        box-shadow: 0 0 0 3px rgba(187, 10, 48, 0.1);
+      }
+    }
+  }
+
+  &__grid {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+    gap: 12px;
+  }
+
+  &__card {
+    background: #fff;
+    border: 2px solid #e5e7eb;
+    border-radius: 10px;
+    overflow: hidden;
+    transition: all 0.15s ease;
+    cursor: pointer;
+    position: relative;
+
+    &:hover {
+      border-color: #9ca3af;
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+    }
+
+    &.is-selected {
+      border-color: var(--admin-accent-primary, #bb0a30);
+      background: rgba(187, 10, 48, 0.04);
+      box-shadow: 0 4px 12px rgba(187, 10, 48, 0.15);
+
+      .admin--item-modal__name {
+        color: var(--admin-accent-primary, #bb0a30);
+      }
+    }
+
+    label {
+      display: block;
+      padding: 12px 12px 14px;
+      cursor: pointer;
+      margin: 0;
+    }
+
+    input[type="checkbox"] {
+      position: absolute;
+      top: 8px;
+      right: 8px;
+      width: 18px;
+      height: 18px;
+      cursor: pointer;
+      accent-color: var(--admin-accent-primary, #bb0a30);
+      z-index: 2;
+    }
+  }
+
+  &__thumb {
+    aspect-ratio: 1;
+    border-radius: 8px;
+    overflow: hidden;
+    background: #f3f4f6;
+    margin-bottom: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
+  }
+
+  &__no-img {
+    font-size: 36px;
+    opacity: 0.35;
+    line-height: 1;
+  }
+
+  &__name {
+    font-size: 14px;
+    font-weight: 600;
+    color: #1f2937;
+    text-align: center;
+    margin-bottom: 6px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__meta {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    flex-wrap: wrap;
+  }
+
+  &__type {
+    display: inline-block;
+    padding: 2px 8px;
+    border-radius: 10px;
+    background: #e5e7eb;
+    color: #6b7280;
+    font-size: 11px;
+    font-weight: 500;
+  }
+
+  &__point {
+    display: inline-block;
+    padding: 2px 8px;
+    border-radius: 10px;
+    background: rgba(187, 10, 48, 0.1);
+    color: var(--admin-accent-primary, #bb0a30);
+    font-size: 12px;
+    font-weight: 700;
+  }
+
+  &__empty {
+    padding: 60px 20px;
+    text-align: center;
+    color: #9ca3af;
+    font-size: 14px;
+  }
+
+  &__count {
+    font-size: 13px;
+    color: #4b5563;
+    font-weight: 600;
+
+    &::before {
+      content: '✓ ';
+      color: var(--admin-accent-primary, #bb0a30);
+    }
+  }
+}
+
+// 모바일 대응
+@media (max-width: 768px) {
+  .admin--item-modal {
+    min-width: 0 !important;
+    width: 95vw;
+
+    &__grid {
+      grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
+      gap: 8px;
+    }
+
+    &__card label {
+      padding: 8px 8px 10px;
+    }
+
+    &__name {
+      font-size: 13px;
+    }
+  }
+}
+
 // 1600px 이하 — 가로 스크롤 (모바일 영역 제외)
 @media (max-width: 1600px) and (min-width: 769px) {
   body {

+ 8 - 1
app/components/admin/DatePicker.vue

@@ -5,8 +5,9 @@
       type="text"
       :value="modelValue"
       :placeholder="placeholder"
-      :class="['admin--form-input', { 'is-required': required }]"
+      :class="['admin--form-input', { 'is-required': required, 'is-disabled': disabled }]"
       :required="required"
+      :disabled="disabled"
       readonly
     >
   </div>
@@ -43,6 +44,10 @@ const props = defineProps({
   maxDate: {
     type: String,
     default: null
+  },
+  disabled: {
+    type: Boolean,
+    default: false
   }
 })
 
@@ -59,6 +64,8 @@ onMounted(() => {
     minDate: props.minDate,
     maxDate: props.maxDate,
     defaultDate: props.modelValue || null,
+    // disabled일 때 캘린더 열림 방지
+    clickOpens: !props.disabled,
     // 모바일에서 native date input으로 전환 차단 (placeholder가 "연도-월-일" 으로 깨지는 원인)
     disableMobile: true,
     onChange: (_selectedDates, dateStr) => {

Разлика између датотеке није приказан због своје велике величине
+ 686 - 328
app/pages/site-manager/challenge/create.vue


+ 458 - 0
app/pages/site-manager/challenge/detail/[id].vue

@@ -0,0 +1,458 @@
+<template>
+  <div class="admin--page-content">
+    <div v-if="isLoading" class="admin--table-loading" style="padding:60px;text-align:center;">
+      데이터를 불러오는 중...
+    </div>
+
+    <div v-else-if="!challenge" class="admin--table-empty" style="padding:60px;text-align:center;">
+      해당 챌린지를 찾을 수 없습니다.
+      <div class="mt--16">
+        <button class="admin--btn" @click="goToList">← 목록으로</button>
+      </div>
+    </div>
+
+    <template v-else>
+      <!-- ============================
+           메인 탭
+      ============================ -->
+      <div class="admin--main-tabs">
+        <button
+          type="button"
+          :class="{ 'is-active': activeMainTab === 'challenge' }"
+          @click="activeMainTab = 'challenge'"
+        >
+          챌린지관리
+        </button>
+        <button
+          type="button"
+          :class="{ 'is-active': activeMainTab === 'applicants' }"
+          @click="activeMainTab = 'applicants'"
+        >
+          신청자관리
+        </button>
+        <button
+          type="button"
+          :class="{ 'is-active': activeMainTab === 'participants' }"
+          @click="activeMainTab = 'participants'"
+        >
+          참가자관리
+        </button>
+      </div>
+
+      <!-- ============================
+           신청자관리 (준비중)
+      ============================ -->
+      <div v-if="activeMainTab === 'applicants'" class="admin--placeholder">
+        <p>📝 신청자관리는 준비중입니다.</p>
+      </div>
+
+      <!-- ============================
+           참가자관리 (준비중)
+      ============================ -->
+      <div v-else-if="activeMainTab === 'participants'" class="admin--placeholder">
+        <p>👥 참가자관리는 준비중입니다.</p>
+      </div>
+
+      <!-- ============================
+           챌린지관리 (기본 활성)
+      ============================ -->
+      <div v-show="activeMainTab === 'challenge'" class="admin--form">
+        <table class="admin--form--table">
+          <colgroup>
+            <col style="width: 140px;">
+            <col>
+          </colgroup>
+          <tbody>
+            <tr>
+              <th><div>챌린지명</div></th>
+              <td>{{ challenge.name }}</td>
+            </tr>
+            <tr>
+              <th><div>참가비</div></th>
+              <td>{{ formatFee(challenge.fee) }}</td>
+            </tr>
+            <tr>
+              <th><div>기간</div></th>
+              <td>{{ formatDate(challenge.start_date) }} ~ {{ formatDate(challenge.end_date) }}</td>
+            </tr>
+            <tr>
+              <th><div>최대 참가자</div></th>
+              <td>{{ challenge.max_participants }}명</td>
+            </tr>
+            <tr>
+              <th><div>총 라운드</div></th>
+              <td>{{ challenge.total_rounds }}R</td>
+            </tr>
+            <tr>
+              <th><div>타이틀 이미지</div></th>
+              <td>
+                <div v-if="challenge.file_path" class="onboard--photo-grid">
+                  <div class="onboard--photo-item">
+                    <img :src="getImageUrl(challenge.file_path)" :alt="challenge.file_name || challenge.name" />
+                  </div>
+                </div>
+                <template v-else>-</template>
+              </td>
+            </tr>
+            <tr>
+              <th><div>현재 상태</div></th>
+              <td>
+                <span :class="['admin--badge', statusBadgeClass(challenge.derived_status)]">
+                  {{ statusLabel(challenge.derived_status) }}
+                </span>
+                <span v-if="challenge.closed_at" class="txt--muted ml--16">
+                  ({{ formatDateTime(challenge.closed_at) }} 마감)
+                </span>
+              </td>
+            </tr>
+            <tr>
+              <th><div>노출 여부</div></th>
+              <td>
+                <span :class="['admin--badge', challenge.status_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
+                  {{ challenge.status_YN === 'Y' ? '사용중' : '미사용' }}
+                </span>
+              </td>
+            </tr>
+            <tr>
+              <th><div>등록일</div></th>
+              <td>{{ formatDateTime(challenge.created_at) }}</td>
+            </tr>
+            <tr v-if="challenge.description">
+              <th><div>상세내용</div></th>
+              <td>
+                <div class="admin--detail--content" v-html="challenge.description"></div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <!-- ============================
+             라운드 정보 (탭)
+        ============================ -->
+        <h3 class="admin--table--middle--title mb--8">라운드 정보</h3>
+
+        <div class="admin--round--tabs">
+          <button
+            v-for="(round, rIdx) in challenge.rounds"
+            :key="round.id"
+            type="button"
+            class="admin--round--tab"
+            :class="{ 'is-active': activeRoundIdx === rIdx }"
+            @click="activeRoundIdx = rIdx"
+          >
+            라운드 {{ round.round_no }}
+            <span v-if="round.closed_at" class="admin--round--tab__badge">종료</span>
+          </button>
+        </div>
+
+        <div
+          v-for="(round, rIdx) in challenge.rounds"
+          v-show="activeRoundIdx === rIdx"
+          :key="round.id"
+          class="admin--round--box--wrap"
+        >
+          <div class="admin--round--title">
+            라운드 {{ round.round_no }}
+            <span>
+              {{ round.place_mode === 'all' ? '전체 장소에 동일 적용' : '장소별 개별 설정' }}
+              ㆍ 진출자 {{ round.qualified }}{{ rIdx === 0 ? '명' : '%' }}
+            </span>
+            <span v-if="round.closed_at" class="closed--txt">
+              마감 ({{ formatDateTime(round.closed_at) }})
+            </span>
+            <button
+              v-else-if="currentRoundIdx === rIdx && !challenge.closed_at"
+              type="button"
+              class="admin--btn-small admin--btn-red"
+              @click="confirmCloseRound(round)"
+            >
+              라운드 마감
+            </button>
+          </div>
+
+          <div class="admin--round--box">
+            <!-- all 모드 -->
+            <div v-if="round.place_mode === 'all'" class="mt--16">
+              <p class="mb--8">배정 아이템 ({{ round.items.length }})</p>
+              <ul v-if="round.items.length > 0" class="admin--item-modal__grid">
+                <li v-for="it in round.items" :key="it.id" class="admin--item-modal__card">
+                  <div style="padding:12px 12px 14px;">
+                    <div class="admin--item-modal__thumb">
+                      <img
+                        v-if="it.file_path"
+                        :src="getImageUrl(it.file_path)"
+                        :alt="it.name"
+                      />
+                      <div v-else class="admin--item-modal__no-img">🎁</div>
+                    </div>
+                    <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
+                    <div class="admin--item-modal__meta">
+                      <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
+                      <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
+                    </div>
+                  </div>
+                </li>
+              </ul>
+              <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
+            </div>
+
+            <!-- specific 모드 -->
+            <template v-else>
+              <!-- <p class="mb--8">장소 묶음 ({{ round.places.length }})</p> -->
+
+              <div
+                v-for="(place, pIdx) in round.places"
+                :key="place.group_no"
+                class="round--place--wrap"
+                :class="{ 'mt--16': pIdx > 0 }"
+              >
+                <div class="admin--round--title">
+                  장소 {{ pIdx + 1 }}
+                  <span class="txt--muted">{{ placeCountText(place.onboards) }}</span>
+                </div>
+
+                <div class="place--select--wrap">
+                  <p class="mt--8 mb--4">장소 목록</p>
+                  <div class="item--selected--wrap">
+                    <div
+                      v-for="o in place.onboards"
+                      :key="o.id"
+                      :class="[o.place_type === 'onboard' ? 'item--selected onboard' : 'item--selected']"
+                    >
+                      {{ o.place_type === 'onboard' ? '🚤' : '🎣' }} {{ o.place_name || '(삭제됨)' }}
+                    </div>
+                  </div>
+
+                  <p class="mt--16 mb--4">배정 아이템 ({{ place.items.length }})</p>
+                  <ul v-if="place.items.length > 0" class="admin--item-modal__grid">
+                    <li v-for="it in place.items" :key="it.id" class="admin--item-modal__card">
+                      <div style="padding:12px 12px 14px;">
+                        <div class="admin--item-modal__thumb">
+                          <img
+                            v-if="it.file_path"
+                            :src="getImageUrl(it.file_path)"
+                            :alt="it.name"
+                          />
+                          <div v-else class="admin--item-modal__no-img">🎁</div>
+                        </div>
+                        <div class="admin--item-modal__name">{{ it.name || '?' }}</div>
+                        <div class="admin--item-modal__meta">
+                          <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
+                          <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
+                        </div>
+                      </div>
+                    </li>
+                  </ul>
+                  <p v-else class="txt--muted">배정된 아이템이 없습니다.</p>
+                </div>
+              </div>
+            </template>
+          </div>
+        </div>
+
+        <!-- ============================
+             액션 버튼
+        ============================ -->
+        <div class="admin--form-actions">
+          <button type="button" class="admin--btn" @click="goToList">
+            ← 목록으로
+          </button>
+          <button type="button" class="admin--btn admin--btn-blue ml--auto" @click="goToEdit">
+            수정
+          </button>
+          <button type="button" class="admin--btn admin--btn-red" @click="confirmDelete">
+            삭제
+          </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>
+      </div>
+    </template>
+
+    <!-- 삭제 확인 모달 -->
+    <AdminAlertModal
+      v-if="showDeleteModal"
+      title="챌린지 삭제"
+      :message="`'${challenge?.name}' 챌린지를 삭제하시겠습니까?\n삭제된 챌린지는 복원할 수 있습니다.`"
+      type="confirm"
+      @confirm="handleDelete"
+      @cancel="showDeleteModal = false"
+      @close="showDeleteModal = false"
+    />
+
+    <!-- 라운드 마감 확인 모달 -->
+    <AdminAlertModal
+      v-if="showCloseRoundModal"
+      title="라운드 마감"
+      :message="`라운드 ${closingRound?.round_no}을(를) 마감하시겠습니까?\n마지막 라운드일 경우 챌린지도 함께 자동 종료됩니다.`"
+      type="confirm"
+      @confirm="handleCloseRound"
+      @cancel="() => { showCloseRoundModal = false; closingRound = null }"
+      @close="() => { showCloseRoundModal = false; closingRound = null }"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+  const { get, post, del } = useApi();
+  const { getImageUrl } = useImage();
+
+  const challengeId = Number(route.params.id);
+
+  const isLoading = ref(false);
+  const challenge = ref(null);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+  const showDeleteModal = ref(false);
+  const activeRoundIdx = ref(0); // 현재 선택된 라운드 탭
+  const activeMainTab = ref("challenge"); // 'challenge' | 'applicants' | 'participants'
+  const showCloseRoundModal = ref(false);
+  const closingRound = ref(null);
+
+  // 현재 라운드 인덱스 (closed_at NULL인 가장 작은 round_no)
+  const currentRoundIdx = computed(() => {
+    if (!challenge.value?.rounds?.length) return -1;
+    const idx = challenge.value.rounds.findIndex((r) => !r.closed_at);
+    return idx; // -1 이면 모든 라운드 마감 상태
+  });
+
+  const statusLabel = (s) =>
+    s === "hidden" ? "비노출"
+    : s === "recruiting" ? "모집중"
+    : s === "running" ? "진행중"
+    : s === "ended" ? "종료"
+    : "-";
+  const statusBadgeClass = (s) =>
+    s === "hidden" ? "admin--badge-hidden"
+    : s === "recruiting" ? "admin--badge-recruiting"
+    : s === "running" ? "admin--badge-running"
+    : s === "ended" ? "admin--badge-ended"
+    : "";
+
+  const formatDate = (s) => {
+    if (!s) return "-";
+    const d = new Date(s.replace(" ", "T"));
+    if (isNaN(d.getTime())) return s;
+    return d.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" });
+  };
+
+  const formatDateTime = (s) => {
+    if (!s) return "-";
+    const d = new Date(s.replace(" ", "T"));
+    if (isNaN(d.getTime())) return s;
+    return d.toLocaleString("ko-KR", {
+      year: "numeric", month: "2-digit", day: "2-digit",
+      hour: "2-digit", minute: "2-digit",
+    });
+  };
+
+  // 장소 묶음의 type별 카운트 텍스트 ("선상 3개ㆍ낚시터 2개" / 한쪽만 있으면 그쪽만)
+  const placeCountText = (onboards) => {
+    const list = onboards || [];
+    const onb = list.filter((o) => o.place_type === "onboard").length;
+    const fis = list.filter((o) => o.place_type === "fishing").length;
+    const parts = [];
+    if (onb > 0) parts.push(`선상 ${onb}개`);
+    if (fis > 0) parts.push(`낚시터 ${fis}개`);
+    return parts.join("ㆍ") || "장소 없음";
+  };
+
+  const formatFee = (fee) => {
+    if (fee === null || fee === undefined || fee === "") return "-";
+    const num = Number(String(fee).replace(/[^\d]/g, ""));
+    if (isNaN(num) || num === 0) return fee + "";
+    return num.toLocaleString() + "원";
+  };
+
+  const loadChallenge = async () => {
+    isLoading.value = true;
+    try {
+      const { data, error } = await get(`/challenge/${challengeId}`);
+      if (error || !data?.success) {
+        challenge.value = null;
+        return;
+      }
+      challenge.value = data.data;
+
+      // 페이지 진입 시 활성 탭 = 현재 라운드 (모두 마감이면 마지막 라운드)
+      const rounds = challenge.value?.rounds || [];
+      if (rounds.length) {
+        const idx = rounds.findIndex((r) => !r.closed_at);
+        activeRoundIdx.value = idx === -1 ? rounds.length - 1 : idx;
+      }
+    } catch (e) {
+      console.error("[ChallengeDetail] 로드 실패:", e);
+      challenge.value = null;
+    } finally {
+      isLoading.value = false;
+    }
+  };
+
+  // 라운드 마감 — 확인 모달 → API
+  const confirmCloseRound = (round) => {
+    closingRound.value = round;
+    showCloseRoundModal.value = true;
+  };
+
+  const handleCloseRound = async () => {
+    showCloseRoundModal.value = false;
+    const round = closingRound.value;
+    closingRound.value = null;
+    if (!round) return;
+
+    errorMessage.value = "";
+    try {
+      const { data, error } = await post(`/challenge/round/${round.id}/close`, {});
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "마감에 실패했습니다.";
+        return;
+      }
+      successMessage.value = data.message || "라운드가 마감되었습니다.";
+      await loadChallenge();
+    } catch (e) {
+      console.error("[ChallengeDetail] 마감 실패:", e);
+      errorMessage.value = "서버 오류가 발생했습니다.";
+    }
+  };
+
+  const confirmDelete = () => {
+    showDeleteModal.value = true;
+  };
+
+  const handleDelete = async () => {
+    showDeleteModal.value = false;
+    errorMessage.value = "";
+    try {
+      const { data, error } = await del(`/challenge/${challengeId}`);
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "삭제에 실패했습니다.";
+        return;
+      }
+      successMessage.value = data.message || "챌린지가 삭제되었습니다.";
+      setTimeout(() => router.push("/site-manager/challenge/list"), 800);
+    } catch (e) {
+      console.error("[ChallengeDetail] 삭제 실패:", e);
+      errorMessage.value = "서버 오류가 발생했습니다.";
+    }
+  };
+
+  const goToList = () => router.push("/site-manager/challenge/list");
+  const goToEdit = () => router.push(`/site-manager/challenge/edit/${challengeId}`);
+
+  onMounted(() => {
+    loadChallenge();
+  });
+</script>

+ 1062 - 0
app/pages/site-manager/challenge/edit/[id].vue

@@ -0,0 +1,1062 @@
+<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>
+              </td>
+            </tr>
+            <tr>
+              <th><div>참가비 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model="formData.fee"
+                    type="number"
+                    min="0"
+                    class="admin--form-input w--200"
+                    :placeholder="isFree ? '0 (무료)' : '예: 10000'"
+                    :disabled="isFree"
+                    required
+                  />
+                  <span>원</span>
+                  <label class="admin--radio-label ml--16">
+                    <input type="checkbox" v-model="isFree" @change="onFreeChange" /> 무료
+                  </label>
+                </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">
+                  <input v-model="formData.max_participants" type="number" min="100" max="999999" class="admin--form-input w--120" placeholder="100 ~ 999999" required />
+                  <span>명</span>
+                </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">
+                    {{ existingImagePath || image ? '이미지 변경' : '이미지 선택' }}
+                  </button>
+                  <span v-if="image" class="ml--16">{{ image.file.name }} (신규)</span>
+                  <span v-else-if="existingImagePath" class="ml--16">{{ existingImageName || '기존 이미지' }}</span>
+                  <button
+                    v-if="existingImagePath && !image"
+                    type="button"
+                    class="admin--btn-small admin--btn-red ml--8"
+                    @click="removeExistingImage"
+                  >
+                    이미지 제거
+                  </button>
+                </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>
+                <!-- 신규 없으면 기존 이미지 -->
+                <div v-else-if="existingImagePath" class="onboard--photo-grid mt--10">
+                  <div class="onboard--photo-item">
+                    <img :src="getImageUrl(existingImagePath)" alt="기존 이미지" />
+                  </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>
+                <ClientOnly>
+                  <SunEditor
+                    v-model="formData.description"
+                    height="400px"
+                    placeholder="챌린지 상세 설명을 입력하세요. (참가 방법ㆍ규칙ㆍ유의사항 등)"
+                  />
+                </ClientOnly>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <!-- ============================
+             라운드 설정
+        ============================ -->
+        <h3 class="admin--table--middle--title">라운드 설정</h3>
+        <p class="admin--table--middle--desc">
+          라운드별로 장소 범위(전체/개별)를 선택하고, 장소마다 아이템을 배정합니다. 최대 5라운드까지 등록할 수 있습니다.
+        </p>
+
+        <div
+          v-for="(round, rIdx) in rounds"
+          :key="round._key"
+          class="admin--round--box--wrap"
+          :class="{ 'mt--16': rIdx > 0 }"
+        >
+          <div class="admin--round--title">
+            라운드 {{ round.round_no }}
+            <span v-if="round.place_mode === 'all'">전체 장소ㆍ아이템 {{ round.items.length }}</span>
+            <span v-else>개별 장소 {{ round.places.length }}</span>
+            <!-- 최소 2라운드 이상. 3라운드부터 라운드 삭제 버튼 노출 -->
+            <button
+              v-if="rounds.length > 2"
+              type="button"
+              class="round--remove--btn"
+              @click="removeRound(rIdx)"
+            >
+              라운드 삭제
+            </button>
+          </div>
+
+          <div class="admin--round--box">
+            <div class="input--wrap mt--16">
+              <label class="admin--round--radio">
+                <input
+                  type="radio"
+                  :name="'place_mode_' + round._key"
+                  value="all"
+                  :checked="round.place_mode === 'all'"
+                  @change="changePlaceMode(round, 'all')"
+                />
+                전체 장소에 동일 적용
+              </label>
+              <label class="admin--round--radio">
+                <input
+                  type="radio"
+                  :name="'place_mode_' + round._key"
+                  value="specific"
+                  :checked="round.place_mode === 'specific'"
+                  @change="changePlaceMode(round, 'specific')"
+                />
+                장소별 개별 설정
+              </label>
+            </div>
+
+            <div class="qual--wrap">
+              <p class="mt--16 mb--4">진출자 {{ rIdx === 0 ? '인원' : '확률' }}</p>
+              <div class="input--wrap">
+                <input
+                  v-model="round.qualified"
+                  type="number"
+                  min="1"
+                  class="admin--form-input w--120"
+                  :placeholder="rIdx === 0 ? '예: 30' : '예: 50'"
+                  required
+                />
+                <!-- 1라운드 단위 : 명, 그 외 라운드 단위 : % -->
+                <span>{{ rIdx === 0 ? '명' : '%' }}</span>
+              </div>
+            </div>
+
+            <!-- 전체 적용 모드 — 라운드 단위 아이템 -->
+            <div v-if="round.place_mode === 'all'" class="item--select--wrap">
+              <div class="item--select--btn--wrap mt--16 mb--4">
+                <p>배정 아이템ㆍ수량 {{ round.items.length }}</p>
+                <button type="button" @click="openItemModal(round)">+ 아이템 선택</button>
+              </div>
+              <div class="item--selected--wrap">
+                <div v-for="(it, iIdx) in round.items" :key="it.item_id" class="item--selected">
+                  {{ it.name }}<button type="button" @click="round.items.splice(iIdx, 1)">✕</button>
+                </div>
+              </div>
+            </div>
+
+            <!-- 장소별 개별 설정 모드 -->
+            <template v-else>
+              <div
+                v-for="(place, pIdx) in round.places"
+                :key="place._key"
+                class="round--place--wrap"
+              >
+                <div class="admin--round--title">
+                  장소 {{ pIdx + 1 }}
+                  <button
+                    type="button"
+                    class="place--remove--btn"
+                    @click="removePlace(round, pIdx)"
+                  >✕</button>
+                </div>
+
+                <div class="place--select--wrap">
+                  <p class="mb--4">장소 정의</p>
+
+                  <!-- 분야/지역/제휴 셀렉트 — 항상 노출 -->
+                  <div class="input--wrap">
+                    <select v-model="place.field_id" class="admin--form-select">
+                      <option value="">전체 분야</option>
+                      <option v-for="f in fieldOptions" :key="f.id" :value="f.id">{{ f.name }}</option>
+                    </select>
+                    <select v-model="place.area_id" class="admin--form-select">
+                      <option value="">전체 지역</option>
+                      <option v-for="a in areaOptions" :key="a.id" :value="a.id">{{ a.name }}</option>
+                    </select>
+                    <select v-model="place.partnership_YN" class="admin--form-select">
+                      <option value="">제휴 여부</option>
+                      <option value="Y">제휴</option>
+                      <option value="N">비제휴</option>
+                    </select>
+
+                    <!-- 장소 미선택 시: "장소 선택" 버튼 + 드롭다운 -->
+                    <div v-if="place.onboards.length === 0" class="place--select--btn--wrap">
+                      <button
+                        type="button"
+                        class="admin--form-select"
+                        @click.stop="openDropdown(place)"
+                      >
+                        장소 선택
+                      </button>
+                      <div
+                        v-if="place.dropdownOpen"
+                        class="all--place--wrap"
+                        @click.stop
+                      >
+                        <div class="place--top">
+                          <div class="search--wrap">
+                            <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
+                          </div>
+                          <div class="check--wrap">
+                            <label>
+                              <input
+                                type="checkbox"
+                                :checked="isAllFilteredSelected(place)"
+                                @change="toggleAll(place)"
+                              >
+                              전체
+                              <span>조건의 모든 장소에 적용</span>
+                            </label>
+                          </div>
+                          <div class="all--place">
+                            <p>등록된 선상ㆍ낚시터</p>
+                            <ul class="all--place--list mt--6">
+                              <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
+                                <p class="group--header">
+                                  {{ group.area }}
+                                  <button
+                                    type="button"
+                                    @click="toggleAllInGroup(place, group.items)"
+                                  >
+                                    {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
+                                  </button>
+                                </p>
+                                <li v-for="p in group.items" :key="placeKey(p)">
+                                  <label>
+                                    <input
+                                      type="checkbox"
+                                      :checked="place.tempSelected.includes(placeKey(p))"
+                                      @change="togglePlaceInTemp(place, placeKey(p))"
+                                    >
+                                    <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
+                                    <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
+                                      {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
+                                    </span>
+                                    <p>{{ p.field_name || '-' }}</p>
+                                  </label>
+                                </li>
+                              </template>
+                              <li v-if="filteredPlaces(place).length === 0" class="empty">
+                                조건에 맞는 장소가 없습니다.
+                              </li>
+                            </ul>
+                          </div>
+                        </div>
+                        <div class="place--bot">
+                          <p>{{ place.tempSelected.length }}개 선택</p>
+                          <button type="button" @click="applyDropdown(place)">적용</button>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+
+                  <!-- 장소 선택 후 — 별도 영역에 "선상ㆍ낚시터 복수 선택" + 칩 -->
+                  <template v-if="place.onboards.length > 0">
+                    <p class="mt--16 mb--4">선상ㆍ낚시터 복수 선택</p>
+                    <div class="place--select--btn--wrap">
+                      <div
+                        class="admin--form-select"
+                        @click.stop="openDropdown(place)"
+                      >
+                        <div
+                          v-for="key in place.onboards.slice(0, 2)"
+                          :key="key"
+                          class="place--selected"
+                        >
+                          {{ placeTypeByKey(key) === 'onboard' ? '🚤' : '🎣' }} {{ placeNameByKey(key) }}
+                          <button
+                            type="button"
+                            @click.stop="removePlaceChip(place, key)"
+                          >✕</button>
+                        </div>
+                        <div v-if="place.onboards.length > 2" class="place--selected">
+                          + {{ place.onboards.length - 2 }}
+                        </div>
+                      </div>
+
+                      <div
+                        v-if="place.dropdownOpen"
+                        class="all--place--wrap"
+                        @click.stop
+                      >
+                        <div class="place--top">
+                          <div class="search--wrap">
+                            <input v-model="place.searchKeyword" type="text" placeholder="선상ㆍ낚시터명 검색">
+                          </div>
+                          <div class="check--wrap">
+                            <label>
+                              <input
+                                type="checkbox"
+                                :checked="isAllFilteredSelected(place)"
+                                @change="toggleAll(place)"
+                              >
+                              전체
+                              <span>조건의 모든 장소에 적용</span>
+                            </label>
+                          </div>
+                          <div class="all--place">
+                            <p>등록된 선상ㆍ낚시터</p>
+                            <ul class="all--place--list mt--6">
+                              <template v-for="group in groupedFilteredPlaces(place)" :key="group.area">
+                                <p class="group--header">
+                                  {{ group.area }}
+                                  <button
+                                    type="button"
+                                    @click="toggleAllInGroup(place, group.items)"
+                                  >
+                                    {{ isAllInGroupSelected(place, group.items) ? '그룹 해제' : '그룹 전체 선택' }}
+                                  </button>
+                                </p>
+                                <li v-for="p in group.items" :key="placeKey(p)">
+                                  <label>
+                                    <input
+                                      type="checkbox"
+                                      :checked="place.tempSelected.includes(placeKey(p))"
+                                      @change="togglePlaceInTemp(place, placeKey(p))"
+                                    >
+                                    <span>{{ p._placeType === 'onboard' ? '🚤' : '🎣' }} {{ p.name }}</span>
+                                    <span :class="p.partnership_YN === 'Y' ? 'on' : 'off'">
+                                      {{ p.partnership_YN === 'Y' ? '제휴' : '비제휴' }}
+                                    </span>
+                                    <p>{{ p.field_name || '-' }}</p>
+                                  </label>
+                                </li>
+                              </template>
+                              <li v-if="filteredPlaces(place).length === 0" class="empty">
+                                조건에 맞는 장소가 없습니다.
+                              </li>
+                            </ul>
+                          </div>
+                        </div>
+                        <div class="place--bot">
+                          <p>{{ place.tempSelected.length }}개 선택</p>
+                          <button type="button" @click="applyDropdown(place)">적용</button>
+                        </div>
+                      </div>
+                    </div>
+                  </template>
+                </div>
+
+                <!-- 장소별 아이템 -->
+                <div class="item--select--wrap">
+                  <div class="item--select--btn--wrap mb--4 mt--16">
+                    <p>배정 아이템ㆍ수량 {{ place.items.length }}</p>
+                    <button type="button" @click="openItemModal(place)">+ 아이템 선택</button>
+                  </div>
+                  <div class="item--selected--wrap">
+                    <div v-for="(it, iIdx) in place.items" :key="it.item_id" class="item--selected">
+                      {{ it.name }}<button type="button" @click="place.items.splice(iIdx, 1)">✕</button>
+                    </div>
+                  </div>
+                </div>
+              </div>
+
+              <button type="button" class="place--add--btn" @click="addPlace(round)">
+                + 장소 추가
+              </button>
+            </template>
+          </div>
+        </div>
+
+        <button
+          v-if="rounds.length < 5"
+          type="button"
+          class="round--add--btn"
+          @click="addRound"
+        >
+          + 라운드 추가 (최대 5라운드)
+        </button>
+
+        <!-- 버튼 영역 -->
+        <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 || isLoading">
+            {{ 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>
+
+    <!-- ============================
+         아이템 선택 모달
+    ============================ -->
+    <ClientOnly>
+      <Teleport to="body">
+        <div
+          v-if="itemModal.isOpen"
+          class="admin--modal-overlay admin--alert-overlay"
+          @click.self="closeItemModal"
+        >
+          <div class="admin--modal admin--form-modal admin--item-modal" @click.stop>
+            <div class="admin--modal-header">
+              <h4>아이템 선택</h4>
+              <button type="button" class="admin--modal-close" @click="closeItemModal">✕</button>
+            </div>
+            <div class="admin--modal-body">
+              <div class="admin--item-modal__search mb--16">
+                <input
+                  v-model="itemModal.searchKeyword"
+                  type="text"
+                  class="admin--form-input w--full"
+                  placeholder="🔍 아이템명 검색"
+                />
+              </div>
+
+              <ul v-if="filteredItems().length > 0" class="admin--item-modal__grid">
+                <li
+                  v-for="it in filteredItems()"
+                  :key="it.id"
+                  class="admin--item-modal__card"
+                  :class="{ 'is-selected': itemModal.tempSelected.includes(it.id) }"
+                >
+                  <label>
+                    <input
+                      type="checkbox"
+                      :checked="itemModal.tempSelected.includes(it.id)"
+                      @change="toggleItemInModal(it.id)"
+                    />
+                    <div class="admin--item-modal__thumb">
+                      <img
+                        v-if="it.file_path"
+                        :src="getImageUrl(it.file_path)"
+                        :alt="it.name"
+                      />
+                      <div v-else class="admin--item-modal__no-img">🎁</div>
+                    </div>
+                    <div class="admin--item-modal__name">{{ it.name }}</div>
+                    <div class="admin--item-modal__meta">
+                      <span v-if="it.type" class="admin--item-modal__type">{{ it.type == 'B' ? '뱃지' : it.type == 'P' ? '포인트' : '진출권' }}</span>
+                      <span v-if="it.point !== null && it.point !== undefined" class="admin--item-modal__point">{{ it.point }}P</span>
+                    </div>
+                  </label>
+                </li>
+              </ul>
+
+              <div v-else class="admin--item-modal__empty">
+                {{ itemModal.searchKeyword ? '검색 결과가 없습니다.' : '등록된 아이템이 없습니다.' }}
+              </div>
+            </div>
+            <div class="admin--modal-footer">
+              <span class="admin--item-modal__count mr--auto">{{ itemModal.tempSelected.length }}개 선택</span>
+              <button type="button" class="admin--btn" @click="closeItemModal">취소</button>
+              <button type="button" class="admin--btn admin--btn-primary-fill ml--8" @click="applyItemModal">적용</button>
+            </div>
+          </div>
+        </div>
+      </Teleport>
+    </ClientOnly>
+  </div>
+</template>
+
+<script setup>
+  import { ref, onMounted, onBeforeUnmount } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+  import DatePicker from "~/components/admin/DatePicker.vue";
+  import SunEditor from "~/components/admin/SunEditor.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+  const { get, put, del, upload } = useApi();
+  const { getImageUrl } = useImage();
+
+  const challengeId = Number(route.params.id);
+
+  const isLoading = ref(false);
+  const isSaving = ref(false);
+  const successMessage = ref("");
+  const errorMessage = ref("");
+
+  // ============================
+  // 옵션 데이터
+  // ============================
+  const fieldOptions = ref([]);
+  const areaOptions = ref([]);
+  const placesAll = ref([]);   // 검색용 전체 장소 (선상 + 낚시터, _placeType 필드로 구분)
+  const itemsAll = ref([]);    // 아이템 모달용 전체 아이템
+
+  // ============================
+  // 챌린지 기본 정보
+  // ============================
+  const formData = ref({
+    name: "",
+    fee: "",
+    max_participants: "",
+    status_YN: "Y",
+    description: "",
+  });
+  const startDate = ref("");
+  const endDate = ref("");
+  const isFree = ref(false);
+
+  // 무료 체크박스 토글
+  const onFreeChange = () => {
+    if (isFree.value) {
+      formData.value.fee = "0";
+    } else {
+      formData.value.fee = "";
+    }
+  };
+
+  // ============================
+  // 이미지 업로드 (기존 + 신규)
+  // ============================
+  const imageInput = ref(null);
+  const image = ref(null);                  // 신규 업로드 (file + preview)
+  const existingImagePath = ref(null);      // 기존 file_path
+  const existingImageName = ref(null);      // 기존 file_name
+  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 = "이미지가 5MB를 초과합니다.";
+      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;
+    }
+  };
+
+  // ============================
+  // 라운드/장소 동적 배열
+  // ============================
+  let _keySeq = 0;
+  const nextKey = () => ++_keySeq;
+
+  function createPlace() {
+    return {
+      _key: nextKey(),
+      field_id: "",
+      area_id: "",
+      partnership_YN: "",
+      onboards: [],       // 적용된 장소 키 배열 (예: 'onboard-1', 'fishing-3')
+      items: [],          // [{ item_id, name, qty }] — Phase 2
+      // UI 상태
+      dropdownOpen: false,
+      searchKeyword: "",
+      tempSelected: [],   // 드롭다운 내 임시 체크 (장소 키 배열)
+    };
+  }
+  function createRound(no) {
+    return {
+      _key: nextKey(),
+      round_no: no,
+      place_mode: "all",
+      qualified: "",
+      items: [],          // [{ item_id, name, qty }] — Phase 2
+      places: [],
+    };
+  }
+
+  const rounds = ref([createRound(1), createRound(2)]);
+
+  function renumberRounds() {
+    rounds.value.forEach((r, i) => { r.round_no = i + 1; });
+  }
+
+  function addRound() {
+    if (rounds.value.length >= 5) return;
+    rounds.value.push(createRound(rounds.value.length + 1));
+  }
+  function removeRound(idx) {
+    if (rounds.value.length <= 2) return;
+    rounds.value.splice(idx, 1);
+    renumberRounds();
+  }
+  function changePlaceMode(round, mode) {
+    round.place_mode = mode;
+    // specific 으로 전환했는데 장소가 없으면 자동으로 장소 1 생성
+    if (mode === "specific" && round.places.length === 0) {
+      round.places.push(createPlace());
+    }
+  }
+  function addPlace(round) {
+    round.places.push(createPlace());
+  }
+  function removePlace(round, idx) {
+    round.places.splice(idx, 1);
+    // specific 모드인데 장소가 0개면 자동으로 1개 다시 추가 (또는 all 모드로 되돌리기 — 여기선 하나 자동 추가)
+    if (round.places.length === 0) {
+      round.places.push(createPlace());
+    }
+  }
+
+  // ============================
+  // 장소(선상+낚시터) 검색 드롭다운
+  // ============================
+  // 장소 키 헬퍼: 'onboard-1', 'fishing-2' 형태로 고유 식별
+  const placeKey = (p) => `${p._placeType}-${p.id}`;
+  const placeByKey = (k) => placesAll.value.find((p) => placeKey(p) === k);
+  const placeNameByKey = (k) => placeByKey(k)?.name || "?";
+  const placeTypeByKey = (k) => (k && k.startsWith("fishing-")) ? "fishing" : "onboard";
+
+  function closeAllDropdowns() {
+    rounds.value.forEach((r) =>
+      r.places.forEach((p) => { p.dropdownOpen = false; })
+    );
+  }
+
+  function openDropdown(place) {
+    closeAllDropdowns();
+    place.tempSelected = [...place.onboards];
+    place.dropdownOpen = true;
+  }
+
+  function filteredPlaces(place) {
+    return placesAll.value.filter((p) => {
+      if (place.field_id && String(p.field_id) !== String(place.field_id)) return false;
+      if (place.area_id && String(p.area_id) !== String(place.area_id)) return false;
+      if (place.partnership_YN && p.partnership_YN !== place.partnership_YN) return false;
+      if (place.searchKeyword) {
+        const kw = place.searchKeyword.toLowerCase();
+        if (!String(p.name || "").toLowerCase().includes(kw)) return false;
+      }
+      return true;
+    });
+  }
+
+  function togglePlaceInTemp(place, key) {
+    const idx = place.tempSelected.indexOf(key);
+    if (idx === -1) place.tempSelected.push(key);
+    else place.tempSelected.splice(idx, 1);
+  }
+
+  function isAllFilteredSelected(place) {
+    const filtered = filteredPlaces(place);
+    if (filtered.length === 0) return false;
+    return filtered.every((p) => place.tempSelected.includes(placeKey(p)));
+  }
+
+  function toggleAll(place) {
+    const filtered = filteredPlaces(place);
+    const filteredKeys = filtered.map(placeKey);
+    if (isAllFilteredSelected(place)) {
+      const set = new Set(filteredKeys);
+      place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
+    } else {
+      const merged = new Set([...place.tempSelected, ...filteredKeys]);
+      place.tempSelected = [...merged];
+    }
+  }
+
+  // 지역별 그룹화: [{area, items: [...]}, ...]
+  function groupedFilteredPlaces(place) {
+    const filtered = filteredPlaces(place);
+    const map = new Map();
+    filtered.forEach((p) => {
+      const area = p.area_name || "미분류";
+      if (!map.has(area)) map.set(area, []);
+      map.get(area).push(p);
+    });
+    return Array.from(map.entries()).map(([area, items]) => ({ area, items }));
+  }
+
+  function isAllInGroupSelected(place, items) {
+    if (!items || items.length === 0) return false;
+    return items.every((p) => place.tempSelected.includes(placeKey(p)));
+  }
+
+  function toggleAllInGroup(place, items) {
+    const keys = items.map(placeKey);
+    if (isAllInGroupSelected(place, items)) {
+      const set = new Set(keys);
+      place.tempSelected = place.tempSelected.filter((k) => !set.has(k));
+    } else {
+      const merged = new Set([...place.tempSelected, ...keys]);
+      place.tempSelected = [...merged];
+    }
+  }
+
+  function applyDropdown(place) {
+    place.onboards = [...place.tempSelected];
+    place.dropdownOpen = false;
+  }
+
+  function removePlaceChip(place, key) {
+    place.onboards = place.onboards.filter((k) => k !== key);
+  }
+
+  // 외부 클릭 시 모든 드롭다운 닫기
+  function handleDocumentClick() {
+    closeAllDropdowns();
+  }
+
+  // ============================
+  // 아이템 선택 모달
+  // ============================
+  const itemModal = ref({
+    isOpen: false,
+    target: null,        // round 또는 place 객체 (둘 다 .items 배열 가짐)
+    tempSelected: [],    // 임시 선택된 item id 배열
+    searchKeyword: "",
+  });
+
+  function openItemModal(target) {
+    itemModal.value.target = target;
+    itemModal.value.tempSelected = target.items.map((i) => i.item_id);
+    itemModal.value.searchKeyword = "";
+    itemModal.value.isOpen = true;
+  }
+
+  function closeItemModal() {
+    itemModal.value.isOpen = false;
+    itemModal.value.target = null;
+    itemModal.value.tempSelected = [];
+    itemModal.value.searchKeyword = "";
+  }
+
+  function toggleItemInModal(itemId) {
+    const idx = itemModal.value.tempSelected.indexOf(itemId);
+    if (idx === -1) itemModal.value.tempSelected.push(itemId);
+    else itemModal.value.tempSelected.splice(idx, 1);
+  }
+
+  function filteredItems() {
+    if (!itemModal.value.searchKeyword) return itemsAll.value;
+    const kw = itemModal.value.searchKeyword.toLowerCase();
+    return itemsAll.value.filter((i) =>
+      String(i.name || "").toLowerCase().includes(kw)
+    );
+  }
+
+  function applyItemModal() {
+    const target = itemModal.value.target;
+    if (!target) return;
+    target.items = itemModal.value.tempSelected.map((id) => {
+      const it = itemsAll.value.find((x) => x.id === id);
+      return {
+        item_id: id,
+        name: it?.name || "?",
+        type: it?.type || "",
+        point: it?.point ?? null,
+      };
+    });
+    closeItemModal();
+  }
+
+  // ============================
+  // 데이터 로드
+  // ============================
+  async function loadOptions() {
+    try {
+      const [fieldRes, areaRes, onboardRes, fishingRes, itemRes] = await Promise.all([
+        get("/field/list", { params: { per_page: 1000 } }),
+        get("/area/list", { params: { per_page: 1000 } }),
+        get("/onboard/list", { params: { per_page: 1000 } }),
+        get("/fishing/list", { params: { per_page: 1000 } }),
+        get("/item/list", { params: { per_page: 1000, status: "Y" } }),
+      ]);
+      if (fieldRes.data?.success) fieldOptions.value = (fieldRes.data.data.items || []).reverse();
+      if (areaRes.data?.success) areaOptions.value = (areaRes.data.data.items || []).reverse();
+
+      // 선상 + 낚시터 통합 (_placeType으로 구분)
+      const onboards = (onboardRes.data?.success ? (onboardRes.data.data.items || []) : [])
+        .map((o) => ({ ...o, _placeType: "onboard" }));
+      const fishings = (fishingRes.data?.success ? (fishingRes.data.data.items || []) : [])
+        .map((f) => ({ ...f, _placeType: "fishing" }));
+      placesAll.value = [...onboards, ...fishings];
+
+      if (itemRes.data?.success) itemsAll.value = itemRes.data.data.items || [];
+    } catch (e) {
+      console.error("Load options error:", e);
+    }
+  }
+
+  // ============================
+  // 챌린지 로드 + 폼 채우기
+  // ============================
+  async function loadChallenge() {
+    isLoading.value = true;
+    try {
+      const { data, error } = await get(`/challenge/${challengeId}`);
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "챌린지를 불러올 수 없습니다.";
+        return;
+      }
+      const c = data.data;
+
+      // 기본 정보
+      const feeNum = Number(c.fee);
+      isFree.value = !isNaN(feeNum) && feeNum === 0;
+      formData.value = {
+        name: c.name || "",
+        fee: String(c.fee ?? ""),
+        max_participants: c.max_participants || "",
+        status_YN: c.status_YN || "Y",
+        description: c.description || "",
+      };
+      startDate.value = c.start_date ? String(c.start_date).substring(0, 10) : "";
+      endDate.value = c.end_date ? String(c.end_date).substring(0, 10) : "";
+
+      // 기존 이미지
+      if (c.file_path) {
+        existingImagePath.value = c.file_path;
+        existingImageName.value = c.file_name;
+      }
+
+      // 라운드 변환 (응답 구조 → 폼 구조)
+      rounds.value = (c.rounds || []).map((r) => {
+        const round = createRound(r.round_no);
+        round.place_mode = r.place_mode;
+        round.qualified = String(r.qualified || "");
+
+        if (r.place_mode === "all") {
+          round.items = (r.items || []).map((it) => ({
+            item_id: it.item_id,
+            name: it.name,
+            type: it.type,
+            point: it.point,
+          }));
+          round.places = [];
+        } else {
+          round.items = [];
+          round.places = (r.places || []).map((p) => {
+            const place = createPlace();
+            // field_id, area_id, partnership_YN은 응답에 없음 (필터 조건은 저장 안 됨)
+            // 사용자가 수정 시 다시 필터 가능
+            place.onboards = (p.onboards || []).map((o) => `${o.place_type}-${o.place_id}`);
+            place.items = (p.items || []).map((it) => ({
+              item_id: it.item_id,
+              name: it.name,
+              type: it.type,
+              point: it.point,
+            }));
+            return place;
+          });
+        }
+        return round;
+      });
+
+      // 최소 2라운드 보장 (이상 케이스 대비)
+      while (rounds.value.length < 2) {
+        rounds.value.push(createRound(rounds.value.length + 1));
+      }
+    } catch (e) {
+      console.error("[ChallengeEdit] 로드 실패:", e);
+      errorMessage.value = "서버 오류가 발생했습니다.";
+    } finally {
+      isLoading.value = false;
+    }
+  }
+
+  // 기존 이미지 제거 (즉시 백엔드 호출)
+  async function removeExistingImage() {
+    if (!confirm("기존 이미지를 제거하시겠습니까?")) return;
+    try {
+      const { data, error } = await del(`/challenge/${challengeId}/image`);
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "이미지 제거에 실패했습니다.";
+        return;
+      }
+      existingImagePath.value = null;
+      existingImageName.value = null;
+      successMessage.value = "이미지가 제거되었습니다.";
+    } catch (e) {
+      console.error("[ChallengeEdit] 이미지 제거 실패:", e);
+      errorMessage.value = "서버 오류가 발생했습니다.";
+    }
+  }
+
+  // ============================
+  // 폼 제출
+  // ============================
+  async function handleSubmit() {
+    errorMessage.value = "";
+    successMessage.value = "";
+
+    // 프론트 1차 검증
+    if (!formData.value.name.trim()) return (errorMessage.value = "챌린지명을 입력하세요.");
+    if (!formData.value.fee.toString().trim()) return (errorMessage.value = "참가비를 입력하세요.");
+    if (!startDate.value) return (errorMessage.value = "시작일을 선택하세요.");
+    if (!endDate.value) return (errorMessage.value = "종료일을 선택하세요.");
+    if (!formData.value.max_participants) return (errorMessage.value = "최대 참가자를 입력하세요.");
+
+    for (let i = 0; i < rounds.value.length; i++) {
+      const r = rounds.value[i];
+      if (!r.qualified) {
+        return (errorMessage.value = `라운드 ${i + 1}의 진출자 수를 입력하세요.`);
+      }
+      if (r.place_mode === "specific") {
+        if (r.places.length === 0) {
+          return (errorMessage.value = `라운드 ${i + 1}에 장소를 1개 이상 추가하세요.`);
+        }
+        for (let j = 0; j < r.places.length; j++) {
+          if (r.places[j].onboards.length === 0) {
+            return (errorMessage.value = `라운드 ${i + 1} 장소 ${j + 1}에 선상을 1개 이상 선택하세요.`);
+          }
+        }
+      }
+    }
+
+    isSaving.value = true;
+    try {
+      const payload = {
+        name: formData.value.name,
+        fee: formData.value.fee,
+        start_date: startDate.value,
+        end_date: endDate.value,
+        max_participants: Number(formData.value.max_participants),
+        description: formData.value.description,
+        status_YN: formData.value.status_YN,
+        rounds: rounds.value.map((r) => ({
+          round_no: r.round_no,
+          place_mode: r.place_mode,
+          qualified: Number(r.qualified),
+          items: r.place_mode === "all"
+            ? r.items.map((it) => ({ item_id: it.item_id }))
+            : [],
+          places: r.place_mode === "specific"
+            ? r.places.map((p) => ({
+                // 'onboard-1', 'fishing-3' → [{type:'onboard', id:1}, {type:'fishing', id:3}, ...]
+                onboards: p.onboards.map((key) => {
+                  const i = key.indexOf("-");
+                  return { type: key.substring(0, i), id: Number(key.substring(i + 1)) };
+                }),
+                items: p.items.map((it) => ({ item_id: it.item_id })),
+              }))
+            : [],
+        })),
+      };
+
+      const { data, error } = await put(`/challenge/${challengeId}`, payload);
+
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "수정에 실패했습니다.";
+        return;
+      }
+
+      // 새 이미지가 선택되어 있으면 업로드 (기존 이미지 자동 교체)
+      if (image.value) {
+        const fd = new FormData();
+        fd.append("image", image.value.file);
+        const { data: imgRes, error: imgErr } = await upload(`/challenge/${challengeId}/image`, fd);
+
+        if (imgErr || !imgRes?.success) {
+          errorMessage.value = "챌린지는 수정됐지만 이미지 업로드에 실패했습니다.";
+          setTimeout(() => router.push(`/site-manager/challenge/detail/${challengeId}`), 1500);
+          return;
+        }
+      }
+
+      successMessage.value = data.message || "챌린지가 수정되었습니다.";
+      setTimeout(() => {
+        router.push(`/site-manager/challenge/detail/${challengeId}`);
+      }, 1000);
+    } catch (e) {
+      errorMessage.value = "서버 오류가 발생했습니다.";
+      console.error("Challenge save error:", e);
+    } finally {
+      isSaving.value = false;
+    }
+  }
+
+  const goToList = () => router.push("/site-manager/challenge/list");
+  const goToDetail = () => router.push(`/site-manager/challenge/detail/${challengeId}`);
+
+  onMounted(async () => {
+    document.addEventListener("click", handleDocumentClick);
+    // 옵션 먼저 로드한 후 챌린지 로드 (placesAll 채워야 onboards 매핑 가능)
+    await loadOptions();
+    await loadChallenge();
+  });
+
+  onBeforeUnmount(() => {
+    document.removeEventListener("click", handleDocumentClick);
+  });
+</script>

+ 27 - 31
app/pages/site-manager/challenge/list.vue

@@ -6,9 +6,9 @@
         <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>
+            <option value="recruiting">모집중</option>
+            <option value="running">진행중</option>
+            <option value="ended">종료</option>
           </select>
           <input
             v-model="searchQuery"
@@ -42,7 +42,7 @@
     </div>
 
     <!-- 테이블 -->
-    <p class="admin--table--count">전체 42ㆍ모집중 12ㆍ진행중 19ㆍ종료 12</p>
+    <p class="admin--table--count">전체 {{ counts.all }}ㆍ모집중 {{ counts.recruiting }}ㆍ진행중 {{ counts.running }}ㆍ종료 {{ counts.ended }}</p>
     <div class="admin--table-wrapper">
       <table class="admin--table fishing--table">
         <thead>
@@ -53,7 +53,7 @@
             <th style="width: 100px;">라운드</th>
             <th style="width: 100px;">모집/전체</th>
             <th style="">기간</th>
-            <th style="width: 120px;">액션</th>
+            <th style="width: 120px;">관리</th>
           </tr>
         </thead>
         <tbody>
@@ -71,23 +71,15 @@
             @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 :class="['admin--badge', getStatusBadgeClass(item.derived_status)]">
+                {{ getStatusLabel(item.derived_status) }}
               </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>R{{ item.current_round || 1 }}/{{ item.total_rounds }}</td>
+            <td>{{ item.max_participants }}</td>
+            <td class="date">{{ formatDate(item.start_date) }} ~ {{ formatDate(item.end_date) }}</td>
             <td>
               <div class="admin--table-actions">
                 <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(item.id)">
@@ -168,11 +160,10 @@
   const perPage = ref(10);
   const totalCount = ref(0);
   const totalPages = ref(0);
+  const counts = ref({ all: 0, recruiting: 0, running: 0, ended: 0 });
 
-  const searchField = ref("");      // '', field, area, name
   const searchQuery = ref("");
-  const filterPartnership = ref(""); // '', Y, N
-  const filterStatus = ref("");      // '', Y, N
+  const filterStatus = ref("");      // '', recruiting, running, ended
   const startDate = ref("");         // YYYY-MM-DD
   const endDate = ref("");           // YYYY-MM-DD
 
@@ -237,11 +228,7 @@
       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 (searchQuery.value) params.search = searchQuery.value;
     if (filterStatus.value) params.status = filterStatus.value;
     if (startDate.value) params.start_date = startDate.value;
     if (endDate.value) params.end_date = endDate.value;
@@ -253,10 +240,12 @@
       spots.value = [];
       totalCount.value = 0;
       totalPages.value = 0;
+      counts.value = { all: 0, recruiting: 0, running: 0, ended: 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;
+      counts.value = data.data.counts || { all: 0, recruiting: 0, running: 0, ended: 0 };
     }
 
     isLoading.value = false;
@@ -270,9 +259,7 @@
 
   // 검색 초기화
   const resetSearch = () => {
-    searchField.value = "";
     searchQuery.value = "";
-    filterPartnership.value = "";
     filterStatus.value = "";
     startDate.value = "";
     endDate.value = "";
@@ -293,10 +280,19 @@
   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" ? "사용중" : "미사용");
+  // 상태 라벨 / 뱃지 클래스 (derived_status: hidden/recruiting/running/ended)
+  const getStatusLabel = (status) =>
+    status === "hidden" ? "미사용"
+    : status === "recruiting" ? "모집중"
+    : status === "running" ? "진행중"
+    : status === "ended" ? "종료"
+    : "-";
   const getStatusBadgeClass = (status) =>
-    status === "Y" ? "admin--badge-active" : "admin--badge-ended";
+    status === "hidden" ? "admin--badge-hidden"
+    : status === "recruiting" ? "admin--badge-recruiting"
+    : status === "running" ? "admin--badge-running"
+    : status === "ended" ? "admin--badge-ended"
+    : "";
 
   // 날짜 포맷
   const formatDate = (dateString) => {

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

@@ -88,6 +88,16 @@ $routes->post('api/onboard/(:num)/photos', 'Api\OnboardController::uploadPhotos/
 $routes->delete('api/onboard/photo/(:num)', 'Api\OnboardController::deletePhoto/$1');
 $routes->delete('api/onboard/(:num)', 'Api\OnboardController::delete/$1');
 
+// Challenge (챌린지)
+$routes->get('api/challenge/list', 'Api\ChallengeController::index');
+$routes->get('api/challenge/(:num)', 'Api\ChallengeController::show/$1');
+$routes->post('api/challenge', 'Api\ChallengeController::create');
+$routes->put('api/challenge/(:num)', 'Api\ChallengeController::update/$1');
+$routes->post('api/challenge/round/(:num)/close', 'Api\ChallengeController::closeRound/$1');
+$routes->post('api/challenge/(:num)/image', 'Api\ChallengeController::uploadImage/$1');
+$routes->delete('api/challenge/(:num)/image', 'Api\ChallengeController::deleteImage/$1');
+$routes->delete('api/challenge/(:num)', 'Api\ChallengeController::delete/$1');
+
 // File Upload
 $routes->post('api/upload/file', 'Api\UploadController::uploadFile');
 $routes->post('api/upload/image', 'Api\UploadController::uploadImage');

+ 849 - 0
backend/app/Controllers/Api/ChallengeController.php

@@ -0,0 +1,849 @@
+<?php
+
+namespace App\Controllers\Api;
+
+use CodeIgniter\HTTP\ResponseInterface;
+
+class ChallengeController extends BaseApiController
+{
+    protected $format = 'json';
+    protected $table = 'challenge';
+
+    /**
+     * 챌린지 목록
+     * GET /api/challenge/list
+     */
+    public function index()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $page    = (int) ($this->request->getGet('page') ?? 1);
+            $perPage = (int) ($this->request->getGet('per_page') ?? 10);
+            if ($page < 1) $page = 1;
+            if ($perPage < 1) $perPage = 10;
+            $offset = ($page - 1) * $perPage;
+
+            $search    = trim((string) $this->request->getGet('search'));
+            $status    = trim((string) $this->request->getGet('status'));      // recruiting/running/ended
+            $statusYN  = trim((string) $this->request->getGet('status_YN'));   // Y/N
+            $startDate = trim((string) $this->request->getGet('start_date'));
+            $endDate   = trim((string) $this->request->getGet('end_date'));
+
+            $db = $this->getDB();
+            $builder = $db->table($this->table);
+            $builder->where('deleted_YN', 'N');
+
+            if ($search !== '') {
+                $builder->like('name', $search);
+            }
+            if ($statusYN === 'Y' || $statusYN === 'N') {
+                $builder->where('status_YN', $statusYN);
+            }
+
+            // 상태 필터 (비노출 > 명시 마감 > end_date 경과 > 모집중 > 진행중 순위)
+            $now = date('Y-m-d H:i:s');
+            if ($status === 'hidden') {
+                $builder->where('status_YN', 'N');
+            } elseif ($status === 'ended') {
+                $builder->where('status_YN', 'Y')
+                    ->groupStart()
+                        ->where('closed_at IS NOT NULL')
+                        ->orWhere('end_date <', $now)
+                    ->groupEnd();
+            } elseif ($status === 'recruiting') {
+                $builder->where('status_YN', 'Y')
+                    ->where('closed_at IS NULL')
+                    ->where('end_date >=', $now)
+                    ->where('start_date >', $now);
+            } elseif ($status === 'running') {
+                $builder->where('status_YN', 'Y')
+                    ->where('closed_at IS NULL')
+                    ->where('end_date >=', $now)
+                    ->where('start_date <=', $now);
+            }
+
+            // 등록일 기간 필터
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
+                $builder->where('created_at >=', $startDate . ' 00:00:00');
+            }
+            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+                $builder->where('created_at <=', $endDate . ' 23:59:59');
+            }
+
+            $total = $builder->countAllResults(false);
+
+            // 상태/현재라운드는 SQL CASE+subquery로 계산 (escape 비활성화)
+            $items = $builder
+                ->select("
+                    challenge.id, challenge.name, challenge.fee, challenge.start_date, challenge.end_date,
+                    challenge.max_participants, challenge.total_rounds,
+                    challenge.file_name, challenge.file_path, challenge.closed_at, challenge.closed_by,
+                    challenge.status_YN, challenge.created_at,
+                    CASE
+                        WHEN challenge.status_YN = 'N' THEN 'hidden'
+                        WHEN challenge.closed_at IS NOT NULL THEN 'ended'
+                        WHEN challenge.end_date < NOW() THEN 'ended'
+                        WHEN challenge.start_date > NOW() THEN 'recruiting'
+                        ELSE 'running'
+                    END AS derived_status,
+                    CASE
+                        WHEN challenge.closed_at IS NOT NULL THEN challenge.total_rounds
+                        WHEN challenge.end_date < NOW() THEN challenge.total_rounds
+                        ELSE COALESCE(
+                            (SELECT MIN(cr.round_no) FROM challenge_round cr
+                              WHERE cr.challenge_id = challenge.id AND cr.closed_at IS NULL),
+                            challenge.total_rounds
+                        )
+                    END AS current_round
+                ", false)
+                ->orderBy('challenge.id', 'DESC')
+                ->limit($perPage, $offset)
+                ->get()
+                ->getResult();
+
+            // 상태별 카운트 (필터 무관 — 전체 기준, 우선순위 derived_status와 동일)
+            $countAll = $db->table($this->table)->where('deleted_YN', 'N')->countAllResults();
+            $countHidden = $db->table($this->table)
+                ->where('deleted_YN', 'N')
+                ->where('status_YN', 'N')
+                ->countAllResults();
+            $countRecruiting = $db->table($this->table)
+                ->where('deleted_YN', 'N')
+                ->where('status_YN', 'Y')
+                ->where('closed_at IS NULL')
+                ->where('end_date >=', $now)
+                ->where('start_date >', $now)
+                ->countAllResults();
+            $countRunning = $db->table($this->table)
+                ->where('deleted_YN', 'N')
+                ->where('status_YN', 'Y')
+                ->where('closed_at IS NULL')
+                ->where('end_date >=', $now)
+                ->where('start_date <=', $now)
+                ->countAllResults();
+            $countEnded = $db->table($this->table)
+                ->where('deleted_YN', 'N')
+                ->where('status_YN', 'Y')
+                ->groupStart()
+                    ->where('closed_at IS NOT NULL')
+                    ->orWhere('end_date <', $now)
+                ->groupEnd()
+                ->countAllResults();
+
+            return $this->respondSuccess([
+                'items'       => $items,
+                'total'       => $total,
+                'page'        => $page,
+                'per_page'    => $perPage,
+                'total_pages' => (int) ceil($total / $perPage),
+                'counts'      => [
+                    'all'        => $countAll,
+                    'recruiting' => $countRecruiting,
+                    'running'    => $countRunning,
+                    'ended'      => $countEnded,
+                    'hidden'     => $countHidden,
+                ],
+            ]);
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController index error: ' . $e->getMessage());
+            return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 챌린지 상세 조회
+     * GET /api/challenge/:id
+     */
+    public function show($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $db = $this->getDB();
+
+            // 1. 챌린지 기본 정보 + 상태 계산 (CASE 표현식 때문에 escape 비활성화)
+            // detail은 시간 기반 상태만 — 노출 여부는 별도로 표시되므로 hidden 케이스 제외
+            $row = $db->table($this->table)
+                ->select("
+                    id, name, fee, start_date, end_date, max_participants, total_rounds,
+                    description, file_name, file_path, closed_at, closed_by,
+                    status_YN, deleted_YN, created_at, updated_at,
+                    CASE
+                        WHEN closed_at IS NOT NULL THEN 'ended'
+                        WHEN end_date < NOW() THEN 'ended'
+                        WHEN start_date > NOW() THEN 'recruiting'
+                        ELSE 'running'
+                    END AS derived_status
+                ", false)
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->get()
+                ->getRow();
+
+            if (!$row) {
+                return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            // 2. 라운드 목록
+            $rounds = $db->table('challenge_round')
+                ->where('challenge_id', (int) $id)
+                ->orderBy('round_no', 'ASC')
+                ->get()
+                ->getResult();
+
+            foreach ($rounds as $r) {
+                if ($r->place_mode === 'all') {
+                    // 라운드 단위 아이템
+                    $r->items = $db->table('challenge_round_item cri')
+                        ->select('cri.id, cri.item_id, i.name, i.type, i.point, i.file_name, i.file_path')
+                        ->join('item i', 'i.id = cri.item_id', 'left')
+                        ->where('cri.round_id', $r->id)
+                        ->orderBy('cri.id', 'ASC')
+                        ->get()
+                        ->getResult();
+                    $r->places = [];
+                } else {
+                    // specific — group_no로 묶어서 묶음(place) 단위로 반환
+                    $placeRows = $db->table('challenge_round_place crp')
+                        ->select("crp.id, crp.group_no, crp.place_type, crp.place_id,
+                                  COALESCE(o.name, f.name) AS place_name", false)
+                        ->join('onboard o', "o.id = crp.place_id AND crp.place_type = 'onboard'", 'left')
+                        ->join('fishing f', "f.id = crp.place_id AND crp.place_type = 'fishing'", 'left')
+                        ->where('crp.round_id', $r->id)
+                        ->orderBy('crp.group_no', 'ASC')
+                        ->orderBy('crp.id', 'ASC')
+                        ->get()
+                        ->getResult();
+
+                    $groups = []; // group_no => place 묶음
+                    foreach ($placeRows as $pr) {
+                        $g = (int) $pr->group_no;
+                        if (!isset($groups[$g])) {
+                            // group 단위로 아이템 조회 (round_id + group_no)
+                            $items = $db->table('challenge_round_group_item cgi')
+                                ->select('cgi.id, cgi.item_id, i.name, i.type, i.point, i.file_name, i.file_path')
+                                ->join('item i', 'i.id = cgi.item_id', 'left')
+                                ->where('cgi.round_id', $r->id)
+                                ->where('cgi.group_no', $g)
+                                ->orderBy('cgi.id', 'ASC')
+                                ->get()
+                                ->getResult();
+
+                            $groups[$g] = (object) [
+                                'group_no' => $g,
+                                'onboards' => [],
+                                'items'    => $items,
+                            ];
+                        }
+                        $groups[$g]->onboards[] = (object) [
+                            'id'         => $pr->id,
+                            'place_type' => $pr->place_type,
+                            'place_id'   => $pr->place_id,
+                            'place_name' => $pr->place_name,
+                        ];
+                    }
+
+                    $r->places = array_values($groups);
+                    $r->items = [];
+                }
+            }
+
+            $row->rounds = $rounds;
+
+            return $this->respondSuccess($row);
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController show error: ' . $e->getMessage());
+            return $this->respondError('조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 챌린지 삭제 (soft delete)
+     * DELETE /api/challenge/:id
+     */
+    public function delete($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $db = $this->getDB();
+            $exists = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
+            if ($exists === 0) {
+                return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'deleted_YN' => 'Y',
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            return $this->respondSuccess(null, '챌린지가 삭제되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController delete error: ' . $e->getMessage());
+            return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 챌린지 등록
+     * POST /api/challenge
+     */
+    public function create()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getPost() ?? [];
+            }
+
+            $name             = trim((string) ($payload['name'] ?? ''));
+            $fee              = trim((string) ($payload['fee'] ?? ''));
+            $startDate        = trim((string) ($payload['start_date'] ?? ''));
+            $endDate          = trim((string) ($payload['end_date'] ?? ''));
+            $maxParticipants  = (int) ($payload['max_participants'] ?? 0);
+            $description      = (string) ($payload['description'] ?? '');
+            $status           = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y';
+            $rounds           = $payload['rounds'] ?? [];
+
+            // ============================
+            // 기본 필수값 검증
+            // ============================
+            if ($name === '') {
+                return $this->respondError('챌린지명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (mb_strlen($name) > 255) {
+                return $this->respondError('챌린지명은 255자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if ($fee === '') {
+                return $this->respondError('참가비를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
+                return $this->respondError('시작일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+                return $this->respondError('종료일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (strtotime($endDate) < strtotime($startDate)) {
+                return $this->respondError('종료일은 시작일과 같거나 이후여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if ($maxParticipants < 100 || $maxParticipants > 999999) {
+                return $this->respondError('최대 참가자 수는 100명 이상 999,999명 이하로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // ============================
+            // 라운드 검증
+            // ============================
+            if (!is_array($rounds) || count($rounds) < 2 || count($rounds) > 5) {
+                return $this->respondError('라운드는 2~5개 사이여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            foreach ($rounds as $idx => $r) {
+                $no = $idx + 1;
+                $placeMode = (string) ($r['place_mode'] ?? '');
+                $qualified = (int) ($r['qualified'] ?? 0);
+
+                if (!in_array($placeMode, ['all', 'specific'], true)) {
+                    return $this->respondError("라운드 {$no}의 장소 모드를 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if ($qualified <= 0) {
+                    return $this->respondError("라운드 {$no}의 진출자 수를 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+
+                if ($placeMode === 'specific') {
+                    $places = $r['places'] ?? [];
+                    if (!is_array($places) || count($places) === 0) {
+                        return $this->respondError("라운드 {$no}에 장소를 1개 이상 추가하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                    }
+                    foreach ($places as $pIdx => $p) {
+                        $pNo = $pIdx + 1;
+                        $onboards = $p['onboards'] ?? [];
+                        if (!is_array($onboards) || count($onboards) === 0) {
+                            return $this->respondError("라운드 {$no} 장소 {$pNo}에 선상을 1개 이상 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                        }
+                    }
+                }
+            }
+
+            $db = $this->getDB();
+            $db->transStart();
+
+            // ============================
+            // 1. challenge INSERT
+            // ============================
+            $challengeData = [
+                'name'             => $name,
+                'fee'              => $fee,
+                'start_date'       => $startDate . ' 00:00:00',
+                'end_date'         => $endDate . ' 23:59:59',
+                'max_participants' => $maxParticipants,
+                'total_rounds'     => count($rounds),
+                'description'      => $description,
+                'status_YN'        => $status,
+                'deleted_YN'       => 'N',
+                'created_at'       => date('Y-m-d H:i:s'),
+            ];
+
+            $db->table('challenge')->insert($challengeData);
+            $challengeId = $db->insertID();
+
+            if (!$challengeId) {
+                $db->transRollback();
+                return $this->respondError('챌린지 등록에 실패했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+
+            // ============================
+            // 2. 라운드 + 자식 테이블 INSERT
+            // ============================
+            foreach ($rounds as $idx => $r) {
+                $roundNo   = $idx + 1;
+                $placeMode = $r['place_mode'];
+                $qualified = (int) $r['qualified'];
+
+                $db->table('challenge_round')->insert([
+                    'challenge_id' => $challengeId,
+                    'round_no'     => $roundNo,
+                    'place_mode'   => $placeMode,
+                    'qualified'    => $qualified,
+                ]);
+                $roundId = $db->insertID();
+
+                if ($placeMode === 'all') {
+                    // 라운드 단위 아이템 (0개 허용)
+                    $items = $r['items'] ?? [];
+                    foreach ($items as $it) {
+                        $itemId = (int) ($it['item_id'] ?? 0);
+                        if ($itemId <= 0) continue;
+                        $db->table('challenge_round_item')->insert([
+                            'round_id' => $roundId,
+                            'item_id'  => $itemId,
+                        ]);
+                    }
+                } else {
+                    // specific — 각 묶음(place)을 group_no로 식별
+                    // 장소는 선상별로 분해, 아이템은 group 단위로 1번만 INSERT
+                    $places = $r['places'] ?? [];
+                    foreach ($places as $placeIdx => $p) {
+                        $groupNo     = $placeIdx + 1;
+                        $onboards    = $p['onboards'] ?? []; // [{type:'onboard'|'fishing', id:int}, ...]
+                        $placeItems  = $p['items'] ?? [];
+
+                        // 1. 묶음의 각 장소 INSERT (challenge_round_place)
+                        foreach ($onboards as $sel) {
+                            $placeType = (string) ($sel['type'] ?? 'onboard');
+                            $placeId   = (int) ($sel['id'] ?? 0);
+                            if ($placeId <= 0) continue;
+                            if (!in_array($placeType, ['onboard', 'fishing'], true)) continue;
+
+                            $db->table('challenge_round_place')->insert([
+                                'round_id'   => $roundId,
+                                'group_no'   => $groupNo,
+                                'place_type' => $placeType,
+                                'place_id'   => $placeId,
+                            ]);
+                        }
+
+                        // 2. 묶음의 아이템 INSERT (challenge_round_group_item) — group 단위 1번만
+                        foreach ($placeItems as $it) {
+                            $itemId = (int) ($it['item_id'] ?? 0);
+                            if ($itemId <= 0) continue;
+                            $db->table('challenge_round_group_item')->insert([
+                                'round_id' => $roundId,
+                                'group_no' => $groupNo,
+                                'item_id'  => $itemId,
+                            ]);
+                        }
+                    }
+                }
+            }
+
+            $db->transComplete();
+
+            if ($db->transStatus() === false) {
+                return $this->respondError('등록 중 데이터베이스 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+
+            // 결과 응답
+            $row = $db->table('challenge')->where('id', $challengeId)->get()->getRow();
+            return $this->respondSuccess($row, '챌린지가 등록되었습니다.', ResponseInterface::HTTP_CREATED);
+
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController create error: ' . $e->getMessage());
+            return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 챌린지 수정
+     * PUT /api/challenge/:id
+     * 주의: end_date는 등록 후 수정 불가 (정책)
+     * 자식 데이터(라운드/장소/아이템)는 모두 DELETE 후 재구성
+     */
+    public function update($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload) || empty($payload)) {
+                $payload = $this->request->getRawInput() ?? [];
+            }
+
+            $name             = trim((string) ($payload['name'] ?? ''));
+            $fee              = trim((string) ($payload['fee'] ?? ''));
+            $startDate        = trim((string) ($payload['start_date'] ?? ''));
+            $endDate          = trim((string) ($payload['end_date'] ?? ''));
+            $maxParticipants  = (int) ($payload['max_participants'] ?? 0);
+            $description      = (string) ($payload['description'] ?? '');
+            $status           = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y';
+            $rounds           = $payload['rounds'] ?? [];
+
+            // 필수값 검증
+            if ($name === '') return $this->respondError('챌린지명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            if (mb_strlen($name) > 255) return $this->respondError('챌린지명은 255자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            if ($fee === '') return $this->respondError('참가비를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
+                return $this->respondError('시작일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+                return $this->respondError('종료일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if (strtotime($endDate) < strtotime($startDate)) {
+                return $this->respondError('종료일은 시작일과 같거나 이후여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            if ($maxParticipants < 100 || $maxParticipants > 999999) {
+                return $this->respondError('최대 참가자 수는 100명 이상 999,999명 이하로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // 라운드 검증
+            if (!is_array($rounds) || count($rounds) < 2 || count($rounds) > 5) {
+                return $this->respondError('라운드는 2~5개 사이여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            foreach ($rounds as $idx => $r) {
+                $no = $idx + 1;
+                $placeMode = (string) ($r['place_mode'] ?? '');
+                $qualified = (int) ($r['qualified'] ?? 0);
+                if (!in_array($placeMode, ['all', 'specific'], true)) {
+                    return $this->respondError("라운드 {$no}의 장소 모드를 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if ($qualified <= 0) {
+                    return $this->respondError("라운드 {$no}의 진출자 수를 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                }
+                if ($placeMode === 'specific') {
+                    $places = $r['places'] ?? [];
+                    if (!is_array($places) || count($places) === 0) {
+                        return $this->respondError("라운드 {$no}에 장소를 1개 이상 추가하세요.", ResponseInterface::HTTP_BAD_REQUEST);
+                    }
+                    foreach ($places as $pIdx => $p) {
+                        $pNo = $pIdx + 1;
+                        $onboards = $p['onboards'] ?? [];
+                        if (!is_array($onboards) || count($onboards) === 0) {
+                            return $this->respondError("라운드 {$no} 장소 {$pNo}에 선상/낚시터를 1개 이상 선택하세요.", 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);
+            }
+
+            $db->transStart();
+
+            // 1. challenge UPDATE
+            $db->table('challenge')
+                ->where('id', (int) $id)
+                ->update([
+                    'name'             => $name,
+                    'fee'              => $fee,
+                    'start_date'       => $startDate . ' 00:00:00',
+                    'end_date'         => $endDate . ' 23:59:59',
+                    'max_participants' => $maxParticipants,
+                    'total_rounds'     => count($rounds),
+                    'description'      => $description,
+                    'status_YN'        => $status,
+                    'updated_at'       => date('Y-m-d H:i:s'),
+                ]);
+
+            // 2. 기존 자식 데이터 모두 DELETE (CASCADE로 round_item, round_place, group_item 다 정리됨)
+            $db->table('challenge_round')->where('challenge_id', (int) $id)->delete();
+
+            // 3. 라운드 + 자식 INSERT (create 와 동일 로직)
+            foreach ($rounds as $idx => $r) {
+                $roundNo   = $idx + 1;
+                $placeMode = $r['place_mode'];
+                $qualified = (int) $r['qualified'];
+
+                $db->table('challenge_round')->insert([
+                    'challenge_id' => (int) $id,
+                    'round_no'     => $roundNo,
+                    'place_mode'   => $placeMode,
+                    'qualified'    => $qualified,
+                ]);
+                $roundId = $db->insertID();
+
+                if ($placeMode === 'all') {
+                    $items = $r['items'] ?? [];
+                    foreach ($items as $it) {
+                        $itemId = (int) ($it['item_id'] ?? 0);
+                        if ($itemId <= 0) continue;
+                        $db->table('challenge_round_item')->insert([
+                            'round_id' => $roundId,
+                            'item_id'  => $itemId,
+                        ]);
+                    }
+                } else {
+                    $places = $r['places'] ?? [];
+                    foreach ($places as $placeIdx => $p) {
+                        $groupNo    = $placeIdx + 1;
+                        $onboards   = $p['onboards'] ?? [];
+                        $placeItems = $p['items'] ?? [];
+
+                        foreach ($onboards as $sel) {
+                            $placeType = (string) ($sel['type'] ?? 'onboard');
+                            $placeId   = (int) ($sel['id'] ?? 0);
+                            if ($placeId <= 0) continue;
+                            if (!in_array($placeType, ['onboard', 'fishing'], true)) continue;
+
+                            $db->table('challenge_round_place')->insert([
+                                'round_id'   => $roundId,
+                                'group_no'   => $groupNo,
+                                'place_type' => $placeType,
+                                'place_id'   => $placeId,
+                            ]);
+                        }
+
+                        foreach ($placeItems as $it) {
+                            $itemId = (int) ($it['item_id'] ?? 0);
+                            if ($itemId <= 0) continue;
+                            $db->table('challenge_round_group_item')->insert([
+                                'round_id' => $roundId,
+                                'group_no' => $groupNo,
+                                'item_id'  => $itemId,
+                            ]);
+                        }
+                    }
+                }
+            }
+
+            $db->transComplete();
+
+            if ($db->transStatus() === false) {
+                return $this->respondError('수정 중 데이터베이스 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+
+            $row = $db->table('challenge')->where('id', (int) $id)->get()->getRow();
+            return $this->respondSuccess($row, '챌린지가 수정되었습니다.');
+
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController update error: ' . $e->getMessage());
+            return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 라운드 마감
+     * POST /api/challenge/round/:round_id/close
+     * 마지막 라운드 마감 시 challenge.closed_at 도 자동 설정 (자동 종료)
+     */
+    public function closeRound($roundId = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($roundId)) {
+            return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $db = $this->getDB();
+            $round = $db->table('challenge_round')
+                ->where('id', (int) $roundId)->get()->getRow();
+            if (!$round) {
+                return $this->respondError('해당 라운드를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+            if ($round->closed_at !== null) {
+                return $this->respondError('이미 마감된 라운드입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $now = date('Y-m-d H:i:s');
+            $db->transStart();
+
+            // 라운드 마감
+            $db->table('challenge_round')
+                ->where('id', (int) $roundId)
+                ->update(['closed_at' => $now]);
+
+            // 모든 라운드 마감되면 challenge.closed_at 자동 설정 (closed_by NULL = 자동)
+            $remaining = $db->table('challenge_round')
+                ->where('challenge_id', (int) $round->challenge_id)
+                ->where('closed_at IS NULL')
+                ->countAllResults();
+
+            $challengeClosed = false;
+            if ($remaining === 0) {
+                $db->table('challenge')
+                    ->where('id', (int) $round->challenge_id)
+                    ->update([
+                        'closed_at'  => $now,
+                        'closed_by'  => null,
+                        'updated_at' => $now,
+                    ]);
+                $challengeClosed = true;
+            }
+
+            $db->transComplete();
+
+            if ($db->transStatus() === false) {
+                return $this->respondError('마감 처리 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+            }
+
+            $msg = $challengeClosed
+                ? '마지막 라운드가 마감되어 챌린지도 종료되었습니다.'
+                : '라운드가 마감되었습니다.';
+            return $this->respondSuccess(['challenge_closed' => $challengeClosed], $msg);
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController closeRound error: ' . $e->getMessage());
+            return $this->respondError('마감 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 챌린지 타이틀 이미지 업로드 (교체)
+     * POST /api/challenge/:id/image
+     */
+    public function uploadImage($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();
+            $row = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow();
+            if (!$row) {
+                return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            $file = $this->request->getFile('image');
+            if (!$file || !$file->isValid()) {
+                return $this->respondError('이미지가 전송되지 않았습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+            $mime = $file->getMimeType();
+            if (!in_array($mime, $allowed, true)) {
+                return $this->respondError('이미지 형식이 올바르지 않습니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            $uploadPath = FCPATH . 'uploads/challenge/';
+            if (!is_dir($uploadPath)) {
+                mkdir($uploadPath, 0755, true);
+            }
+            $fileName = $file->getClientName();
+            $stored = $file->getRandomName();
+            $file->move($uploadPath, $stored);
+
+            // 기존 이미지 삭제
+            if (!empty($row->file_path)) {
+                $oldFull = FCPATH . ltrim($row->file_path, '/');
+                if (is_file($oldFull)) @unlink($oldFull);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'file_name'  => $fileName,
+                'file_path'  => '/uploads/challenge/' . $stored,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            $updated = $db->table($this->table)->where('id', (int) $id)->get()->getRow();
+            return $this->respondSuccess($updated, '이미지가 교체되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController uploadImage error: ' . $e->getMessage());
+            return $this->respondError('이미지 업로드 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 챌린지 타이틀 이미지 제거
+     * DELETE /api/challenge/:id/image
+     */
+    public function deleteImage($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();
+            $row = $db->table($this->table)
+                ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow();
+            if (!$row) {
+                return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            if (!empty($row->file_path)) {
+                $full = FCPATH . ltrim($row->file_path, '/');
+                if (is_file($full)) @unlink($full);
+            }
+
+            $db->table($this->table)->where('id', (int) $id)->update([
+                'file_name'  => null,
+                'file_path'  => null,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+            return $this->respondSuccess(null, '이미지가 제거되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController deleteImage error: ' . $e->getMessage());
+            return $this->respondError('이미지 제거 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+}

+ 8 - 0
backend/app/Controllers/Api/OnboardController.php

@@ -33,6 +33,8 @@ class OnboardController extends BaseApiController
             $status      = trim((string) $this->request->getGet('status'));
             $startDate   = trim((string) $this->request->getGet('start_date')); // YYYY-MM-DD
             $endDate     = trim((string) $this->request->getGet('end_date'));   // YYYY-MM-DD
+            $fieldId     = (int) ($this->request->getGet('field_id') ?? 0);
+            $areaId      = (int) ($this->request->getGet('area_id') ?? 0);
 
             $db = $this->getDB();
             $builder = $db->table($this->table . ' o');
@@ -62,6 +64,12 @@ class OnboardController extends BaseApiController
             if ($status === 'Y' || $status === 'N') {
                 $builder->where('o.status_YN', $status);
             }
+            if ($fieldId > 0) {
+                $builder->where('o.field_id', $fieldId);
+            }
+            if ($areaId > 0) {
+                $builder->where('o.area_id', $areaId);
+            }
 
             // 등록일 기간 필터 (YYYY-MM-DD)
             if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {

+ 86 - 173
db.vuerd.json

@@ -4,8 +4,8 @@
   "settings": {
     "width": 3000,
     "height": 3000,
-    "scrollTop": -1252.5063,
-    "scrollLeft": -810,
+    "scrollTop": -1808.5063,
+    "scrollLeft": -1644,
     "zoomLevel": 1,
     "show": 431,
     "database": 4,
@@ -405,39 +405,6 @@
           "createAt": 1780299725141
         }
       },
-      "0qOhpokdRsP9PKwViW3I4": {
-        "id": "0qOhpokdRsP9PKwViW3I4",
-        "name": "species_type",
-        "comment": "어종구분",
-        "columnIds": [
-          "zrzX4czwgIrVMdPxnO-66",
-          "44ArXMoHz57DHQDiiXSX8",
-          "shPUMVKh6Vsw3ww8m2SEi",
-          "XeIFH6IWDqylVxN9MFVwC",
-          "C9F2vG1hx9OjZO7x1IC5z",
-          "UW5iGbQHdo2szbqff2XTV"
-        ],
-        "seqColumnIds": [
-          "zrzX4czwgIrVMdPxnO-66",
-          "44ArXMoHz57DHQDiiXSX8",
-          "shPUMVKh6Vsw3ww8m2SEi",
-          "XeIFH6IWDqylVxN9MFVwC",
-          "C9F2vG1hx9OjZO7x1IC5z",
-          "UW5iGbQHdo2szbqff2XTV"
-        ],
-        "ui": {
-          "x": 592.1133,
-          "y": 1286.598,
-          "zIndex": 608,
-          "widthName": 68,
-          "widthComment": 60,
-          "color": ""
-        },
-        "meta": {
-          "updateAt": 1781682607840,
-          "createAt": 1780383472103
-        }
-      },
       "kx1Wu65aSaH1nPc3asZqQ": {
         "id": "kx1Wu65aSaH1nPc3asZqQ",
         "name": "species_challenge",
@@ -670,6 +637,7 @@
         "columnIds": [
           "aXVd_RUxYmjJpTOacWO2G",
           "yl1XQGnZIJ5y5Skgf7MD2",
+          "AnZuanRis-IrF1-VBSo8j",
           "_b51xA6GPf02vvs5HIz4-",
           "t_lm4N0PKIviaZnieeqLZ",
           "keeqVP3OAHo7kIj1lqj3U",
@@ -687,6 +655,7 @@
         "seqColumnIds": [
           "aXVd_RUxYmjJpTOacWO2G",
           "yl1XQGnZIJ5y5Skgf7MD2",
+          "AnZuanRis-IrF1-VBSo8j",
           "_b51xA6GPf02vvs5HIz4-",
           "t_lm4N0PKIviaZnieeqLZ",
           "keeqVP3OAHo7kIj1lqj3U",
@@ -711,7 +680,7 @@
           "color": ""
         },
         "meta": {
-          "updateAt": 1781851315679,
+          "updateAt": 1782112607793,
           "createAt": 1781682559633
         }
       },
@@ -813,53 +782,57 @@
         "columnIds": [
           "CRnbkBNwjakc3_TGdbsVl",
           "8MnyYDWiLfhIBQOM-3T5A",
+          "byxRuGokGUVaN3Y0ANfdF",
           "BY7WQOAwakpyXuGAfMP8K",
           "XhGzfZA4odVWTnSL39J5k"
         ],
         "seqColumnIds": [
           "CRnbkBNwjakc3_TGdbsVl",
           "8MnyYDWiLfhIBQOM-3T5A",
+          "byxRuGokGUVaN3Y0ANfdF",
           "BY7WQOAwakpyXuGAfMP8K",
           "XhGzfZA4odVWTnSL39J5k"
         ],
         "ui": {
-          "x": 1974.9054,
-          "y": 1954.2848,
+          "x": 1972.9054,
+          "y": 1912.2848,
           "zIndex": 1295,
           "widthName": 122,
           "widthComment": 265,
           "color": ""
         },
         "meta": {
-          "updateAt": 1781850477184,
+          "updateAt": 1782179694424,
           "createAt": 1781850324881
         }
       },
       "ENjuT56buJGJXbrxHT5r8": {
         "id": "ENjuT56buJGJXbrxHT5r8",
-        "name": "challenge_round_place_item",
+        "name": "challenge_round_group_item",
         "comment": "챌린지 라운드가 specific 모드일 경우 각각의 장소별 아이템",
         "columnIds": [
           "t_5VMeV59FBiCNTrwTXPG",
           "YJU7UWghlPexR4Ui7uNKR",
+          "98fbii7RhNlVjVvzmHZYe",
           "hZC4Koh5tQDthUjPbl5Wb"
         ],
         "seqColumnIds": [
           "t_5VMeV59FBiCNTrwTXPG",
           "-HdDr1oGCrMrGBkvRQAGz",
           "YJU7UWghlPexR4Ui7uNKR",
+          "98fbii7RhNlVjVvzmHZYe",
           "hZC4Koh5tQDthUjPbl5Wb"
         ],
         "ui": {
           "x": 1975.7923,
           "y": 2152.2021,
           "zIndex": 1340,
-          "widthName": 151,
+          "widthName": 155,
           "widthComment": 316,
           "color": ""
         },
         "meta": {
-          "updateAt": 1781851105287,
+          "updateAt": 1782180008475,
           "createAt": 1781850563485
         }
       }
@@ -2765,126 +2738,6 @@
           "createAt": 1780374834161
         }
       },
-      "zrzX4czwgIrVMdPxnO-66": {
-        "id": "zrzX4czwgIrVMdPxnO-66",
-        "tableId": "0qOhpokdRsP9PKwViW3I4",
-        "name": "id",
-        "comment": "",
-        "dataType": "INT",
-        "default": "",
-        "options": 10,
-        "ui": {
-          "keys": 1,
-          "widthName": 60,
-          "widthComment": 60,
-          "widthDataType": 60,
-          "widthDefault": 60
-        },
-        "meta": {
-          "updateAt": 1780383503249,
-          "createAt": 1780383496357
-        }
-      },
-      "44ArXMoHz57DHQDiiXSX8": {
-        "id": "44ArXMoHz57DHQDiiXSX8",
-        "tableId": "0qOhpokdRsP9PKwViW3I4",
-        "name": "name",
-        "comment": "",
-        "dataType": "VARCHAR",
-        "default": "",
-        "options": 8,
-        "ui": {
-          "keys": 0,
-          "widthName": 60,
-          "widthComment": 60,
-          "widthDataType": 60,
-          "widthDefault": 60
-        },
-        "meta": {
-          "updateAt": 1780383513607,
-          "createAt": 1780383507209
-        }
-      },
-      "shPUMVKh6Vsw3ww8m2SEi": {
-        "id": "shPUMVKh6Vsw3ww8m2SEi",
-        "tableId": "0qOhpokdRsP9PKwViW3I4",
-        "name": "sort_order",
-        "comment": "",
-        "dataType": "INT",
-        "default": "1",
-        "options": 8,
-        "ui": {
-          "keys": 0,
-          "widthName": 60,
-          "widthComment": 60,
-          "widthDataType": 60,
-          "widthDefault": 60
-        },
-        "meta": {
-          "updateAt": 1780383536269,
-          "createAt": 1780383515908
-        }
-      },
-      "XeIFH6IWDqylVxN9MFVwC": {
-        "id": "XeIFH6IWDqylVxN9MFVwC",
-        "tableId": "0qOhpokdRsP9PKwViW3I4",
-        "name": "status_YN",
-        "comment": "",
-        "dataType": "VARCHAR",
-        "default": "Y",
-        "options": 8,
-        "ui": {
-          "keys": 0,
-          "widthName": 60,
-          "widthComment": 60,
-          "widthDataType": 60,
-          "widthDefault": 60
-        },
-        "meta": {
-          "updateAt": 1780383561260,
-          "createAt": 1780383531958
-        }
-      },
-      "UW5iGbQHdo2szbqff2XTV": {
-        "id": "UW5iGbQHdo2szbqff2XTV",
-        "tableId": "0qOhpokdRsP9PKwViW3I4",
-        "name": "deleted_YN",
-        "comment": "",
-        "dataType": "VARCHAR",
-        "default": "N",
-        "options": 8,
-        "ui": {
-          "keys": 0,
-          "widthName": 63,
-          "widthComment": 60,
-          "widthDataType": 60,
-          "widthDefault": 60
-        },
-        "meta": {
-          "updateAt": 1780383576573,
-          "createAt": 1780383555367
-        }
-      },
-      "C9F2vG1hx9OjZO7x1IC5z": {
-        "id": "C9F2vG1hx9OjZO7x1IC5z",
-        "tableId": "0qOhpokdRsP9PKwViW3I4",
-        "name": "created_at",
-        "comment": "",
-        "dataType": "TIMESTAMP",
-        "default": "",
-        "options": 8,
-        "ui": {
-          "keys": 0,
-          "widthName": 60,
-          "widthComment": 60,
-          "widthDataType": 65,
-          "widthDefault": 60
-        },
-        "meta": {
-          "updateAt": 1780383591546,
-          "createAt": 1780383578130
-        }
-      },
       "myCdRl4MHgzHrwC60jBkx": {
         "id": "myCdRl4MHgzHrwC60jBkx",
         "tableId": "kx1Wu65aSaH1nPc3asZqQ",
@@ -4848,20 +4701,20 @@
       "YJU7UWghlPexR4Ui7uNKR": {
         "id": "YJU7UWghlPexR4Ui7uNKR",
         "tableId": "ENjuT56buJGJXbrxHT5r8",
-        "name": "round_place_id",
+        "name": "round_id",
         "comment": "",
         "dataType": "INT",
         "default": "",
         "options": 8,
         "ui": {
           "keys": 2,
-          "widthName": 82,
+          "widthName": 60,
           "widthComment": 60,
           "widthDataType": 60,
           "widthDefault": 60
         },
         "meta": {
-          "updateAt": 1781851110564,
+          "updateAt": 1782179993603,
           "createAt": 1781851096423
         }
       },
@@ -4904,6 +4757,66 @@
           "updateAt": 1781851307837,
           "createAt": 1781851297288
         }
+      },
+      "AnZuanRis-IrF1-VBSo8j": {
+        "id": "AnZuanRis-IrF1-VBSo8j",
+        "tableId": "EbUfMTU8kVVRPKTRvE79m",
+        "name": "fee",
+        "comment": "참가비",
+        "dataType": "INT",
+        "default": "0",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782112622713,
+          "createAt": 1782112604394
+        }
+      },
+      "byxRuGokGUVaN3Y0ANfdF": {
+        "id": "byxRuGokGUVaN3Y0ANfdF",
+        "tableId": "uErSlvV8r5f5ifu5jqD3m",
+        "name": "group_no",
+        "comment": "같은 묶음(place)에 속한 선상들끼리 같은 값",
+        "dataType": "INT",
+        "default": "1",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 234,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782179709051,
+          "createAt": 1782179684622
+        }
+      },
+      "98fbii7RhNlVjVvzmHZYe": {
+        "id": "98fbii7RhNlVjVvzmHZYe",
+        "tableId": "ENjuT56buJGJXbrxHT5r8",
+        "name": "group_no",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1782180005436,
+          "createAt": 1782179998093
+        }
       }
     },
     "relationshipEntities": {
@@ -5170,7 +5083,7 @@
             "aXVd_RUxYmjJpTOacWO2G"
           ],
           "x": 1439.6779,
-          "y": 1818.6185,
+          "y": 1830.6185,
           "direction": 2
         },
         "end": {
@@ -5318,8 +5231,8 @@
           "columnIds": [
             "8MnyYDWiLfhIBQOM-3T5A"
           ],
-          "x": 2210.9054,
-          "y": 1954.2848,
+          "x": 2242.4054,
+          "y": 1912.2848,
           "direction": 4
         },
         "meta": {
@@ -5375,7 +5288,7 @@
             "hZC4Koh5tQDthUjPbl5Wb"
           ],
           "x": 1975.7923,
-          "y": 2216.2021,
+          "y": 2228.2021,
           "direction": 1
         },
         "meta": {
@@ -5393,8 +5306,8 @@
           "columnIds": [
             "CRnbkBNwjakc3_TGdbsVl"
           ],
-          "x": 2210.9054,
-          "y": 2106.2848,
+          "x": 2242.4054,
+          "y": 2088.2848,
           "direction": 8
         },
         "end": {
@@ -5402,7 +5315,7 @@
           "columnIds": [
             "YJU7UWghlPexR4Ui7uNKR"
           ],
-          "x": 2226.2923,
+          "x": 2228.2923,
           "y": 2152.2021,
           "direction": 4
         },
@@ -5431,7 +5344,7 @@
             "Ns2FZgLiHf4lS1krXpTTy"
           ],
           "x": 1029.6779,
-          "y": 1818.6185,
+          "y": 1830.6185,
           "direction": 1
         },
         "meta": {

Неке датотеке нису приказане због велике количине промена