瀏覽代碼

[챌린지 관리] 임시저장 기능 추가

DESKTOP-T61HUSC\user 4 天之前
父節點
當前提交
1eb6376a71

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

@@ -8251,6 +8251,79 @@ footer {
   }
 }
 
+.admin--quest--tab--wrap{
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+  align-items: center;
+  margin-bottom: 16px;
+  .tab--wrap {
+    position: relative;
+    display: inline-flex;
+    padding: 4px;
+    background: #f8f9fb;
+    border-radius: 8px;
+    border: 1px solid #e8eaef;
+    user-select: none;
+  
+    // 슬라이드 인디케이터 — 활성 탭 배경
+    .quest--tab__indicator {
+      position: absolute;
+      top: 4px;
+      bottom: 4px;
+      left: 4px;
+      background-color: #fff;
+      width: calc(50% - 4px);
+      border: 1px solid #e8eaef;
+      border-radius: 6px;
+      transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); // bouncy easing
+      z-index: 0;
+    }
+  
+    // 우측으로 이동 (챌린지 연동 활성)
+    &.is-right .quest--tab__indicator {
+      transform: translateX(100%);
+    }
+  
+    .quest--tab {
+      position: relative;
+      z-index: 1;
+      padding: 10px 28px;
+      min-width: 140px;
+      background: transparent;
+      border: none;
+      cursor: pointer;
+      font-size: 14px;
+      font-weight: 400;
+      color: #666b75;
+      transition: color 0.25s ease;
+      text-align: center;
+  
+      &.is-active {
+        font-weight: 700;
+        color: #1a2b4a;
+      }
+    }
+  }
+  >p{
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    color: #666b75;
+    font-size: 13px;
+    span{
+      display: inline-flex;
+      line-height: 1;
+      align-items: center;
+      justify-content: center;
+      width: 28px;
+      height: 28px;
+      border-radius: 50%;
+      background-color: #e8f0fe;
+    }
+  }
+}
+
 .admin--form--table{
   width: 100%;
   color: #666b75;
@@ -8260,6 +8333,20 @@ footer {
     color: #666b75;
     font-weight: 400;
     font-size: 12px;
+    &.yellow--desc{
+      border-radius: 4px;
+      width: fit-content;
+      padding: 4px 12px;
+      color: #1a2b4a;
+      background-color: #fff8de;
+    }
+    &.green--desc{
+      padding: 4px 12px;
+      background-color: #E8F7ED;
+      color: #2db672;
+      font-weight: 600;
+      width: fit-content;
+    }
   }
   tr{
     border-top: 1px solid #F0F2F6;
@@ -8326,6 +8413,29 @@ footer {
       }
     }
   }
+
+  // 테이블 내 테이블 (퀘스트 등록)
+  .admin--inner--table--wrap{
+    margin-top: 12px;
+    margin-bottom: 12px;
+  }
+  .admin--quest--table{
+    width: 100%;
+    tr{
+      th, td{
+        padding: 14px 8px;
+        text-align: center;
+        .input--wrap{
+          justify-content: center;
+        }
+        .admin--form-select{
+          height: 40px;
+          line-height: 1;
+          width: 120px;
+        }
+      }
+    }
+  }
 }
 
 .admin--form-section-title{
@@ -8654,6 +8764,20 @@ footer {
   display: none;
 }
 
+// ============================================
+// number input spinner (증감 화살표) 전역 제거
+// ============================================
+.admin--form-input[type="number"] {
+  -moz-appearance: textfield;
+  appearance: textfield;
+
+  &::-webkit-inner-spin-button,
+  &::-webkit-outer-spin-button {
+    -webkit-appearance: none;
+    margin: 0;
+  }
+}
+
 // ============================================
 // 챌린지 디테일 - 메인 탭 (챌린지/신청자/참가자 관리)
 // ============================================

+ 158 - 4
app/pages/site-manager/challenge/create.vue

@@ -422,7 +422,15 @@
           <button type="button" class="admin--btn" @click="goToList">
             ← 목록으로
           </button>
-          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+          <button
+            type="button"
+            class="admin--btn admin--btn-primary ml--auto"
+            :disabled="isSavingDraft"
+            @click="handleSaveDraft"
+          >
+            {{ isSavingDraft ? "저장 중..." : "임시저장" }}
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red" :disabled="isSaving">
             {{ isSaving ? "저장 중..." : "저장" }}
           </button>
         </div>
@@ -505,6 +513,17 @@
         </div>
       </Teleport>
     </ClientOnly>
+
+    <!-- 임시저장 불러오기 모달 -->
+    <AdminAlertModal
+      v-if="showDraftModal"
+      title="임시저장 불러오기"
+      :message="`임시저장된 챌린지가 있습니다.\n(저장: ${draftSavedAt})\n불러올까요?\n\n[확인] 불러오기   [취소] 새로 작성 (임시저장 삭제)`"
+      type="confirm"
+      @confirm="loadDraft"
+      @cancel="discardDraft"
+      @close="showDraftModal = false"
+    />
   </div>
 </template>
 
@@ -513,6 +532,7 @@
   import { useRouter } from "vue-router";
   import DatePicker from "~/components/admin/DatePicker.vue";
   import SunEditor from "~/components/admin/SunEditor.vue";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
 
   definePageMeta({
     layout: "admin",
@@ -520,13 +540,19 @@
   });
 
   const router = useRouter();
-  const { get, post, upload } = useApi();
+  const { get, post, del, upload } = useApi();
   const { getImageUrl } = useImage();
 
   const isSaving = ref(false);
+  const isSavingDraft = ref(false);
   const successMessage = ref("");
   const errorMessage = ref("");
 
+  // 임시저장 관련 상태
+  const showDraftModal = ref(false);
+  const draftSavedAt = ref("");
+  const draftData = ref(null);
+
   // ============================
   // 옵션 데이터
   // ============================
@@ -835,6 +861,129 @@
     }
   }
 
+  // ============================
+  // 임시저장 (draft)
+  // ============================
+
+  // 페이지 진입 시 임시저장 있는지 확인
+  async function checkDraft() {
+    try {
+      const { data } = await get("/challenge/draft");
+      if (data?.success && data.data) {
+        draftData.value = data.data.data; // 응답의 data 컬럼 (이미 객체로 파싱됨)
+        const ts = data.data.updated_at || data.data.created_at;
+        draftSavedAt.value = ts
+          ? new Date(String(ts).replace(" ", "T")).toLocaleString("ko-KR")
+          : "";
+        showDraftModal.value = true;
+      }
+    } catch (e) {
+      console.error("[Draft] 조회 실패:", e);
+    }
+  }
+
+  // 임시저장 불러오기 (모달 확인 후)
+  function loadDraft() {
+    showDraftModal.value = false;
+    const d = draftData.value;
+    if (!d) return;
+
+    formData.value = {
+      name: d.name || "",
+      fee: d.fee || "",
+      max_participants: d.max_participants || "",
+      status_YN: d.status_YN || "Y",
+      description: d.description || "",
+    };
+    startDate.value = d.start_date || "";
+    endDate.value = d.end_date || "";
+    isFree.value = !!d.is_free;
+
+    if (Array.isArray(d.rounds) && d.rounds.length >= 2) {
+      rounds.value = d.rounds.map((r) => {
+        const round = createRound(r.round_no);
+        round.place_mode = r.place_mode || "all";
+        round.qualified = String(r.qualified || "");
+        round.items = (r.items || []).map((it) => ({ ...it }));
+        round.places = (r.places || []).map((p) => {
+          const place = createPlace();
+          place.field_id = p.field_id || "";
+          place.area_id = p.area_id || "";
+          place.partnership_YN = p.partnership_YN || "";
+          place.onboards = [...(p.onboards || [])]; // 키 배열 그대로
+          place.items = (p.items || []).map((it) => ({ ...it }));
+          return place;
+        });
+        return round;
+      });
+    }
+    successMessage.value = "임시저장을 불러왔습니다.";
+  }
+
+  // 임시저장 삭제하고 새로 작성
+  async function discardDraft() {
+    showDraftModal.value = false;
+    try {
+      await del("/challenge/draft");
+    } catch (e) {
+      console.error("[Draft] 삭제 실패:", e);
+    }
+    draftData.value = null;
+  }
+
+  // 임시저장 버튼 핸들러
+  async function handleSaveDraft() {
+    errorMessage.value = "";
+    successMessage.value = "";
+
+    if (!formData.value.name.trim()) {
+      return (errorMessage.value = "임시저장하려면 최소한 챌린지명은 입력하세요.");
+    }
+
+    isSavingDraft.value = true;
+    try {
+      const payload = {
+        name: formData.value.name,
+        fee: formData.value.fee,
+        max_participants: formData.value.max_participants,
+        status_YN: formData.value.status_YN,
+        description: formData.value.description,
+        start_date: startDate.value,
+        end_date: endDate.value,
+        is_free: isFree.value,
+        rounds: rounds.value.map((r) => ({
+          round_no: r.round_no,
+          place_mode: r.place_mode,
+          qualified: r.qualified,
+          items: r.items.map((it) => ({
+            item_id: it.item_id, name: it.name, type: it.type, point: it.point,
+          })),
+          places: r.places.map((p) => ({
+            field_id: p.field_id,
+            area_id: p.area_id,
+            partnership_YN: p.partnership_YN,
+            onboards: [...p.onboards],
+            items: p.items.map((it) => ({
+              item_id: it.item_id, name: it.name, type: it.type, point: it.point,
+            })),
+          })),
+        })),
+      };
+
+      const { data, error } = await post("/challenge/draft", payload);
+      if (error || !data?.success) {
+        errorMessage.value = error?.message || data?.message || "임시저장 실패";
+        return;
+      }
+      successMessage.value = "임시저장되었습니다.";
+    } catch (e) {
+      console.error("[Draft] 저장 실패:", e);
+      errorMessage.value = "서버 오류가 발생했습니다.";
+    } finally {
+      isSavingDraft.value = false;
+    }
+  }
+
   // ============================
   // 폼 제출
   // ============================
@@ -918,6 +1067,9 @@
         }
       }
 
+      // 정식 등록 성공 → 임시저장 삭제
+      try { await del("/challenge/draft"); } catch (_) { /* noop */ }
+
       successMessage.value = data.message || "챌린지가 등록되었습니다.";
       setTimeout(() => {
         router.push("/site-manager/challenge/list");
@@ -932,9 +1084,11 @@
 
   const goToList = () => router.push("/site-manager/challenge/list");
 
-  onMounted(() => {
-    loadOptions();
+  onMounted(async () => {
     document.addEventListener("click", handleDocumentClick);
+    await loadOptions();
+    // 옵션 로드 후 임시저장 확인 (불러올 때 onboards 매핑 안전)
+    await checkDraft();
   });
 
   onBeforeUnmount(() => {

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

@@ -89,6 +89,9 @@ $routes->delete('api/onboard/photo/(:num)', 'Api\OnboardController::deletePhoto/
 $routes->delete('api/onboard/(:num)', 'Api\OnboardController::delete/$1');
 
 // Challenge (챌린지)
+$routes->get('api/challenge/draft', 'Api\ChallengeController::showDraft');
+$routes->post('api/challenge/draft', 'Api\ChallengeController::saveDraft');
+$routes->delete('api/challenge/draft', 'Api\ChallengeController::deleteDraft');
 $routes->get('api/challenge/list', 'Api\ChallengeController::index');
 $routes->get('api/challenge/(:num)', 'Api\ChallengeController::show/$1');
 $routes->post('api/challenge', 'Api\ChallengeController::create');

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

@@ -745,6 +745,106 @@ class ChallengeController extends BaseApiController
         }
     }
 
+    /**
+     * 내 임시저장 조회
+     * GET /api/challenge/draft
+     */
+    public function showDraft()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $adminId = (int) $auth->admin_id;
+            $row = $this->getDB()->table('challenge_draft')
+                ->where('admin_id', $adminId)
+                ->get()
+                ->getRow();
+
+            if (!$row) {
+                return $this->respondSuccess(null, '임시저장 없음');
+            }
+
+            // data 컬럼은 JSON 문자열로 저장됐다고 가정
+            $row->data = is_string($row->data) ? json_decode($row->data, true) : $row->data;
+
+            return $this->respondSuccess($row, '임시저장 조회 성공');
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController showDraft error: ' . $e->getMessage());
+            return $this->respondError('조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 임시저장 (upsert — 1인 1개)
+     * POST /api/challenge/draft
+     */
+    public function saveDraft()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $payload = $this->request->getJSON(true);
+            if (!is_array($payload)) $payload = [];
+
+            $adminId = (int) $auth->admin_id;
+            $dataJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
+            $now = date('Y-m-d H:i:s');
+
+            $db = $this->getDB();
+            $existing = $db->table('challenge_draft')
+                ->where('admin_id', $adminId)->get()->getRow();
+
+            if ($existing) {
+                $db->table('challenge_draft')
+                    ->where('admin_id', $adminId)
+                    ->update([
+                        'data'       => $dataJson,
+                        'updated_at' => $now,
+                    ]);
+            } else {
+                $db->table('challenge_draft')->insert([
+                    'admin_id'   => $adminId,
+                    'data'       => $dataJson,
+                    'created_at' => $now,
+                ]);
+            }
+
+            return $this->respondSuccess(['updated_at' => $now], '임시저장 완료');
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController saveDraft error: ' . $e->getMessage());
+            return $this->respondError('임시저장 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 임시저장 삭제
+     * DELETE /api/challenge/draft
+     */
+    public function deleteDraft()
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        try {
+            $adminId = (int) $auth->admin_id;
+            $this->getDB()->table('challenge_draft')
+                ->where('admin_id', $adminId)
+                ->delete();
+            return $this->respondSuccess(null, '임시저장이 삭제되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'ChallengeController deleteDraft error: ' . $e->getMessage());
+            return $this->respondError('삭제 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
     /**
      * 챌린지 타이틀 이미지 업로드 (교체)
      * POST /api/challenge/:id/image

+ 2 - 2
db.vuerd.json

@@ -4,8 +4,8 @@
   "settings": {
     "width": 3000,
     "height": 3000,
-    "scrollTop": -1808.5063,
-    "scrollLeft": -1644,
+    "scrollTop": -1654.5063,
+    "scrollLeft": -759,
     "zoomLevel": 1,
     "show": 431,
     "database": 4,

+ 144 - 1
info.md

@@ -170,12 +170,148 @@
 - **삭제 표시 모드**: 즉시 삭제 X, 화면에서 사라지고 일괄 저장 시 함께 처리
 - **number input 4자리 제한** (`maxlength`는 number에서 무먹어서 `@input` 핸들러로 자르기) + spinner 화살표 숨김
 
+### 🏆 챌린지 관리 (Challenge)
+
+#### 📊 테이블 관계도 (⚠️ 6개 테이블 복합 구조)
+
+```
+challenge  (마스터)
+   │   ├ fee, start_date, end_date(등록 후 수정 가능), max_participants(100~999999)
+   │   ├ total_rounds, closed_at, closed_by(NULL=자동, admin_id=수동)
+   │   ├ status_YN(노출여부), deleted_YN(soft delete)
+   │   └ description(TEXT, SunEditor HTML), file_name/file_path
+   │
+   ├──── challenge_round  (라운드, 2~5개)
+   │       │   ├ round_no, place_mode('all'|'specific'), qualified, closed_at
+   │       │
+   │       ├──── challenge_round_item       ← all 모드: 라운드 단위 아이템
+   │       │       └ (round_id + item_id) UNIQUE
+   │       │
+   │       ├──── challenge_round_place      ← specific 모드: 묶음의 장소들 (1선상=1row)
+   │       │       └ group_no(묶음 marker), place_type('onboard'|'fishing'), place_id
+   │       │
+   │       └──── challenge_round_group_item ← specific 모드: 묶음별 아이템
+   │               └ (round_id, group_no)로 묶음 매칭, item_id FK
+   │
+   └ FK: closed_by → admin_users.id (ON DELETE SET NULL)
+
+challenge_draft  (별개, 1인당 1개 임시저장)
+   └ admin_id UNIQUE FK → admin_users (ON DELETE CASCADE)
+   └ data JSON (폼 전체 통째로 저장)
+```
+
+#### 🔑 group_no 패턴 (specific 모드의 핵심)
+
+프론트의 "장소 1, 장소 2"는 **묶음(group)** — 같은 분야/지역/제휴 조건으로 묶인 여러 선상/낚시터가 동일 아이템을 공유.
+
+**DB 저장**:
+- 묶음 1번에 선상 3개 + 아이템 2개면 → `challenge_round_place` 3 row + `challenge_round_group_item` 2 row
+- `group_no`로 묶음 식별 (등록 시 1, 2, 3... 순차 부여)
+- ⚠️ `group_no`는 **FK 아닌 의미적 marker** — `challenge_round_place`의 `(round_id, group_no)`가 UNIQUE 아니라서 FK 불가
+- 정합성은 **백엔드 코드가 보장** (`ChallengeController::create()/update()`에서 같은 `group_no`로 묶어 INSERT)
+
+**조회 시 묶음 복원**:
+- `challenge_round_place`를 `group_no`로 PHP foreach 그룹화
+- 각 그룹의 아이템은 `challenge_round_group_item WHERE round_id=? AND group_no=?`로 가져옴
+
+#### 📄 페이지
+| 경로 | 역할 |
+|---|---|
+| `/site-manager/challenge/list` | 목록 (상태 필터/검색/기간) |
+| `/site-manager/challenge/create` | 등록 (라운드/장소/아이템 다 입력) |
+| `/site-manager/challenge/detail/[id]` | 상세 (메인 탭 3종 + 라운드 탭) |
+| `/site-manager/challenge/edit/[id]` | 수정 (기존 데이터 폼 채움) |
+
+#### 🔌 API
+| 메서드/경로 | 설명 |
+|---|---|
+| `GET /api/challenge/list` | 목록 + 상태 카운트 5종 (recruiting/running/ended/hidden/all) |
+| `GET /api/challenge/:id` | 상세 (rounds + items + places + group_items 전체 트리) |
+| `POST /api/challenge` | 등록 (트랜잭션, 5개 테이블 INSERT) |
+| `PUT /api/challenge/:id` | 수정 (자식 DELETE 후 재INSERT) |
+| `POST /api/challenge/:id/image` | 타이틀 이미지 업로드 |
+| `DELETE /api/challenge/:id/image` | 이미지 제거 |
+| `POST /api/challenge/round/:round_id/close` | **라운드 마감** (마지막 라운드면 challenge.closed_at 자동 설정) |
+| `DELETE /api/challenge/:id` | soft delete (`deleted_YN='Y'`) |
+
+#### 🚦 상태 계산 (derived_status — SQL CASE)
+
+list/detail 둘 다 SQL에서 실시간 계산 (별도 cron 불필요).
+
+**우선순위:**
+```sql
+CASE
+  WHEN status_YN = 'N' THEN 'hidden'           -- list만 (detail은 제외)
+  WHEN closed_at IS NOT NULL THEN 'ended'      -- 명시적 마감 (관리자 강제 or 자동 종료)
+  WHEN end_date < NOW() THEN 'ended'           -- 종료일 경과 ⏰
+  WHEN start_date > NOW() THEN 'recruiting'    -- 모집중
+  ELSE 'running'                                -- 진행중
+END
+```
+
+- **list**: 4종(`hidden/recruiting/running/ended`) — 비노출 표시
+- **detail**: 3종(`recruiting/running/ended`) — 노출여부는 별도 row로 표시되므로 hidden 제외
+- **카운트 5종 응답** (`counts.all/recruiting/running/ended/hidden`)
+
+#### 🔢 현재 라운드 (current_round)
+
+list의 `R{현재}/{총}` 표시용. SQL subquery로 계산:
+```sql
+CASE
+  WHEN closed_at IS NOT NULL THEN total_rounds                       -- 챌린지 종료
+  WHEN end_date < NOW() THEN total_rounds                            -- 종료일 경과
+  ELSE COALESCE(
+    (SELECT MIN(round_no) FROM challenge_round
+      WHERE challenge_id = challenge.id AND closed_at IS NULL),
+    total_rounds
+  )
+END AS current_round
+```
+
+#### 🏁 라운드 마감 → 자동 챌린지 종료
+
+`closeRound()` 트랜잭션:
+1. `challenge_round.closed_at = NOW()`
+2. 남은 미마감 라운드 0개면 → `challenge.closed_at = NOW()`, `closed_by = NULL` (자동)
+3. detail에서 "라운드 마감" 버튼은 **현재 라운드(currentRoundIdx) + 챌린지 미종료**일 때만 노출
+
+#### 🎨 detail 페이지 UI 구조
+- **메인 탭** (`.admin--main-tabs`): 챌린지관리 / 신청자관리(준비중) / 참가자관리(준비중)
+- **라운드 탭** (`.admin--round--tabs`): 라운드별 콘텐츠 전환 + 마감된 라운드는 "종료" 뱃지
+- **활성 탭 자동 설정**: 페이지 진입 시 `activeRoundIdx` = 현재 라운드
+- 라운드 마감/챌린지 삭제 모두 `AdminAlertModal`(type="confirm") 사용
+
+#### 🖼️ edit 페이지 — 기존 이미지 처리
+- `existingImagePath`/`existingImageName` ref로 기존 이미지 분리
+- 신규 선택(`image`) 시 미리보기 우선, 없으면 기존 표시
+- "이미지 제거" 버튼 → 즉시 `DELETE /api/challenge/:id/image` 호출
+
+#### 💰 참가비 (fee) — 무료 체크박스
+- `type="number" min="0"` + `<span>원</span>`
+- "무료" 체크박스 → `isFree=true` 시 input disabled + `fee='0'` 자동 입력
+- edit 진입 시 `fee === 0` → `isFree` 자동 ON
+
+#### 📋 등록/수정 검증 (백엔드 + 프론트 양방향)
+- 챌린지명 1~255자
+- 참가비 필수 (0 허용 = 무료)
+- 시작일/종료일 YYYY-MM-DD, **end_date ≥ start_date**
+- max_participants **100 ~ 999,999** (1라운드 정원)
+- 라운드 **2~5개**, 각 라운드 `qualified > 0`
+- specific 모드: **장소(묶음) 최소 1개**, 각 묶음에 **선상/낚시터 최소 1개**
+- 아이템 0개 허용
+
+#### 📝 임시저장 (challenge_draft) — 1인 1개
+- `admin_id UNIQUE FK` (DB 레벨 강제)
+- `data JSON` — 폼 전체(`rounds` 포함) 통째로 저장
+- 등록 페이지 진입 시 모달로 "임시저장 불러올래?" 처리 예정 (현재 테이블만 준비)
+
 ---
 
 ## 🟡 작업 예정 (메뉴)
 
 - 📊 대시보드 (현재 빈 페이지)
-- 🎯 챌린지 / 퀘스트 관리 (보상 아이템 M:N 관계)
+- 🏆 챌린지 신청자관리/참가자관리 탭 콘텐츠
+- 🎯 퀘스트 관리 (생성 페이지 작업중 — 단독진행/챌린지 연동 토글)
 - 👥 회원 관리
 
 ---
@@ -232,3 +368,10 @@ ERD 새로 짜면서 기존 비즈니스 컨트롤러(Branch, Showroom, Service,
   - `localStorage.admin_user.permissions` 기반으로 사이드바 메뉴 필터링
   - URL 직접 입력 시 미들웨어가 권한 확인 후 dashboard로 redirect
   - 백엔드 가드도 동시 적용 (UI 우회 차단)
+- **시간 기반 상태 자동 계산** — 챌린지처럼 시작일/종료일/마감일에 따라 상태가 결정되는 경우 별도 cron 안 쓰고 `SQL CASE WHEN ... NOW() ...`로 매 조회마다 실시간 계산 (`derived_status`, `current_round`). escape 이슈 때문에 select 두 번째 인자 `false` 필수
+- **자식 데이터 재구성 패턴** (수정 시) — 부모-자식 다단계 트리 구조(챌린지 라운드/장소/아이템)는 자식 모두 DELETE 후 재INSERT가 가장 단순. CASCADE FK로 한 줄에 정리 (`DELETE FROM challenge_round WHERE challenge_id=?`)
+- **묶음(group) marker 패턴** — UNIQUE 제약 못 거는 묶음 정보는 `group_no` 같은 의미적 INT marker로. FK 강제 못 하므로 백엔드 코드가 정합성 보장
+- **PHP foreach 변수 재사용 주의** — `$row` 같은 변수명을 outer/inner 양쪽에 쓰면 outer 변수가 덮어쓰여 응답이 잘못 나감 (자주 발생한 버그). inner는 `$pr`, `$placeRows` 등 다른 이름 사용
+- **이미지 분리 패턴** (수정 페이지) — `existingImagePath`(기존) vs `image`(신규) ref 분리해서 미리보기 우선순위 처리. 신규 있으면 신규, 없으면 기존 표시. "이미지 제거"는 즉시 DELETE API 호출
+- **무료/0원 처리** — `INT` 컬럼에 0 허용. 프론트는 무료 체크박스 추가 → input disabled + `'0'` 자동 입력. edit 진입 시 `fee === 0` → 체크박스 자동 ON
+- **탭 vs 페이지 분리 판단** — 동일 부모(챌린지)의 여러 관리 화면(신청자/참가자)은 **탭** 권장 (컨텍스트 공유, 빠른 전환). 페이지 분리는 SEO/북마크가 필요한 공개 화면에만