Просмотр исходного кода

[챌린지관리] 지역 전체 선택시 한 버블로 묶음

DESKTOP-T61HUSC\user 3 часов назад
Родитель
Сommit
3b7989b31b

+ 326 - 220
app/assets/scss/admin.scss

@@ -1663,18 +1663,6 @@ html {
         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;
@@ -1700,252 +1688,316 @@ html {
         margin-top: 16px;
         padding: 16px;
       }
-      .place--select--wrap{
-        .input--wrap{
-          flex-wrap: wrap;
-          .admin--form-select{
-            height: 40px;
-            min-width: 120px;
-            width: fit-content;
-            line-height: 1;
-            text-align: left;
-          }
-        }
-        input[type="checkbox"] {
-          appearance: none;
-          -webkit-appearance: none;
-          -moz-appearance: none;
-          width: 18px;
-          height: 18px;
-          margin: 0;
-          border: 1.5px solid #cbd5e0;
-          border-radius: 4px;
-          background-color: #fff;
-          background-position: center;
-          background-repeat: no-repeat;
-          background-size: 12px 12px;
-          cursor: pointer;
-          display: inline-block;
-          vertical-align: middle;
-          transition: background-color 0.15s ease, border-color 0.15s ease;
-          flex-shrink: 0;
+      .place--add--btn{
+        margin-top: 16px;
+        text-align: center;
+        border-radius: 6px;
+        border: 1px dashed #c8d5e6;
+        background-color: #fff;
+        padding: 8px;
+        color: #666b75;
+        font-size: 14px;
+        cursor: pointer;
+        font-weight: 400;
+      }
+    }
+  }
+  //  챌린지 등록, 퀘스트 등록에서 공통 사용 : S
+  .place--select--wrap{
+    .input--wrap{
+      flex-wrap: wrap;
+      .admin--form-select{
+        height: 40px;
+        min-width: 120px;
+        width: fit-content;
+        line-height: 1;
+        text-align: left;
+      }
+    }
+    input[type="checkbox"] {
+      appearance: none;
+      -webkit-appearance: none;
+      -moz-appearance: none;
+      width: 18px;
+      height: 18px;
+      margin: 0;
+      border: 1.5px solid #cbd5e0;
+      border-radius: 4px;
+      background-color: #fff;
+      background-position: center;
+      background-repeat: no-repeat;
+      background-size: 12px 12px;
+      cursor: pointer;
+      display: inline-block;
+      vertical-align: middle;
+      transition: background-color 0.15s ease, border-color 0.15s ease;
+      flex-shrink: 0;
 
-          &:hover {
-            border-color: var(--admin-accent-primary);
-          }
+      &:hover {
+        border-color: var(--admin-accent-primary);
+      }
 
-          &:focus-visible {
-            outline: 2px solid rgba(26, 35, 50, 0.25);
-            outline-offset: 1px;
-          }
+      &:focus-visible {
+        outline: 2px solid rgba(26, 35, 50, 0.25);
+        outline-offset: 1px;
+      }
 
-          &:checked {
-            background-color: #fff;
-            border-color: var(--admin-accent-primary);
-            background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M3.5 8L6.75 11.25L12.5 5.25' stroke='%231A2332' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
-          }
-        }
-        .place--select--btn--wrap{
-          .all--place--wrap{
-            margin-top: 12px;
-            width: 460px;
-            background-color: #fff;
-            border: 1px solid #c8d5e6;
-            border-radius: 8px;
-            .place--top{
-              padding: 8px 12px;
-              .search--wrap{
-                margin-bottom: 6px;
-                input{
-                  border-radius: 6px;
-                  border: 1px solid #eaecf0;
-                  background-color: #f7f8fa;
-                  width: 100%;
-                  padding: 8px 12px;
-                  color: #9aa0aa;
-                  font-size: 13px;
-                  font-weight: 400;
-                }
-              }
-              .check--wrap{
-                margin-bottom: 10px;
-                label{
-                  cursor: pointer;
-                  border-radius: 6px;
-                  background-color: #edf5fc;
-                  padding: 8px 12px;
-                  color: #125ea6;
-                  font-weight: 600;
-                  font-size: 13px;
-                  display: flex;
-                  gap: 10px;
-                  span{
-                    font-weight: 400;
-                  }
-                }
-              }
-              .all--place{
-                .all--place--list{
-                  min-height: 100px;
-                  max-height: 480px;
-                  overflow-y: auto;
-                  li{
-                    label{
-                      padding: 6px 0;
-                      cursor: pointer;
-                      display: flex;
-                      align-items: center;
-                      gap: 10px;
-                      color: #1a2b4a;
-                      font-size: 13px;
-                      font-weight: 600;
-                      span{
-                        &:nth-of-type(1){
-                          flex: 1;
-                        }
-                      }
-                      .off{
-                        width: 50px;
-                        min-width: 50px;
-                        border-radius: 4px;
-                        background-color: #f0f2f5;
-                        padding: 4px;
-                        text-align: center;
-                        color: #666b75;
-                        font-size: 12px;
-                        font-weight: 600;
-                      }
-                      .on{
-                        width: 50px;
-                        min-width: 50px;
-                        border-radius: 4px;
-                        padding: 4px;
-                        text-align: center;
-                        font-size: 12px;
-                        font-weight: 600;
-                        background-color: #EDF5FC;
-                        color: #125ea6;
-                      }
-                      p {
-                        width: 200px;
-                        min-width: 200px;
-                      }
-                    }
-                  }
-                }
-              }
+      &:checked {
+        background-color: #fff;
+        border-color: var(--admin-accent-primary);
+        background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M3.5 8L6.75 11.25L12.5 5.25' stroke='%231A2332' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+      }
+    }
+    .place--select--btn--wrap{
+      display: flex;
+      flex-direction: column;
+      .all--place--wrap{
+        margin-top: 12px;
+        width: 460px;
+        background-color: #fff;
+        border: 1px solid #c8d5e6;
+        border-radius: 8px;
+        .place--top{
+          padding: 8px 12px;
+          .all--place{
+            p{
+              text-align: left;
             }
-            .place--bot{
+            .group--header{
+              color: #374151;
+              font-weight: 600;
               display: flex;
               justify-content: space-between;
-              padding: 8px 12px;
-              align-items: center;
-              border-top: 1px solid #eaecf0;
+              padding: 16px 0 6px;
               button{
-                font-weight: 600;
                 cursor: pointer;
-                color: #fff;
-                background-color: #17a2b8;
-                border-radius: 4px;
-                padding: 4px 20px;
-                font-size: 12px;
+                color: #125ea6;
+                font-weight: 400;
               }
             }
           }
-          div{
-            &.admin--form-select{
-              display: flex;
-              gap: 8px;
+          .search--wrap{
+            margin-bottom: 6px;
+            input{
+              border-radius: 6px;
+              border: 1px solid #eaecf0;
+              background-color: #f7f8fa;
               width: 100%;
-              max-width: 460px;
-              .place--selected{
-                border-radius: 5px;
-                background-color: #edf5fc;
-                padding: 6px 12px;
-                color: #125ea6;
-                font-size: 12px;
-                font-weight: 600;
-                button{
-                  color: #7394c2;
-                  font-weight: 400;
-                  cursor: pointer;
-                  padding-left: 10px;
-                }
+              padding: 8px 12px;
+              color: #9aa0aa;
+              font-size: 13px;
+              font-weight: 400;
+            }
+          }
+          .check--wrap{
+            margin-bottom: 10px;
+            label{
+              cursor: pointer;
+              border-radius: 6px;
+              background-color: #edf5fc;
+              padding: 8px 12px;
+              color: #125ea6;
+              font-weight: 600;
+              font-size: 13px;
+              display: flex;
+              gap: 10px;
+              span{
+                font-weight: 400;
               }
             }
           }
-        }
-        .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);
+          .all--place{
+            .all--place--list{
+              min-height: 100px;
+              max-height: 480px;
+              overflow-y: auto;
+              li{
+                label{
+                  padding: 6px 0;
+                  cursor: pointer;
+                  display: flex;
+                  align-items: center;
+                  gap: 10px;
+                  color: #1a2b4a;
+                  font-size: 13px;
+                  font-weight: 600;
+                  span{
+                    &:nth-of-type(1){
+                      flex: 1;
+                    }
+                  }
+                  .off{
+                    width: 50px;
+                    min-width: 50px;
+                    border-radius: 4px;
+                    background-color: #f0f2f5;
+                    padding: 4px;
+                    text-align: center;
+                    color: #666b75;
+                    font-size: 12px;
+                    font-weight: 600;
+                  }
+                  .on{
+                    width: 50px;
+                    min-width: 50px;
+                    border-radius: 4px;
+                    padding: 4px;
+                    text-align: center;
+                    font-size: 12px;
+                    font-weight: 600;
+                    background-color: #EDF5FC;
+                    color: #125ea6;
+                  }
+                  p {
+                    width: 200px;
+                    min-width: 200px;
+                  }
+                }
+              }
             }
           }
         }
-      }
-      .item--select--wrap{
-        .item--select--btn--wrap{
+        .place--bot{
           display: flex;
           justify-content: space-between;
-          align-items: flex-end;
+          padding: 8px 12px;
+          align-items: center;
+          border-top: 1px solid #eaecf0;
           button{
-            padding: 8px 16px;
-            cursor: pointer;
             font-weight: 600;
-            font-size: 13px;
+            cursor: pointer;
             color: #fff;
             background-color: #17a2b8;
             border-radius: 4px;
+            padding: 4px 20px;
+            font-size: 12px;
           }
         }
-        .item--selected--wrap{
+      }
+      div{
+        &.admin--form-select{
           display: flex;
           gap: 8px;
-          flex-wrap: wrap;
-          .item--selected{
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            min-width: 140px;
-            border-radius: 6px;
-            padding: 8px 12px;
+          width: 100%;
+          max-width: 460px;
+          .place--selected{
+            border-radius: 5px;
+            background-color: #edf5fc;
+            padding: 6px 12px;
+            color: #125ea6;
             font-size: 12px;
-            font-weight: 400;
-            color: #374151;
-            border: 1px solid #eaecf0;
-            background-color: #f3f4f6;
+            font-weight: 600;
             button{
-              color: #9aa0aa;
+              color: #7394c2;
+              font-weight: 400;
               cursor: pointer;
-              font-size: 10px;
+              padding-left: 10px;
             }
           }
         }
       }
-      .place--add--btn{
-        margin-top: 16px;
-        text-align: center;
-        border-radius: 6px;
-        border: 1px dashed #c8d5e6;
-        background-color: #fff;
-        padding: 8px;
-        color: #666b75;
-        font-size: 14px;
+    }
+
+    // 지역별 그룹 묶음 (detail 페이지)
+    .area--group--wrap{
+      display: flex;
+      flex-direction: column;
+      gap: 14px;
+    }
+    .area--group{
+      &__name{
+        font-size: 13px;
+        font-weight: 700;
+        color: #1f2937;
+        margin-bottom: 6px;
+        padding-bottom: 4px;
+      }
+    }
+
+    .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;
+        transition: all 0.15s ease;
+
+        &.onboard{
+          background: rgba(16, 185, 129, 0.1);
+          color: var(--admin-success);
+        }
+
+        // "○○ 전체" 그룹 칩 — 보라색
+        &.is-group{
+          background: rgba(139, 92, 246, 0.12);
+          color: #7c3aed;
+          font-weight: 700;
+        }
+
+        // "○○ 접기" 액션 칩 — 회색 + 점선
+        &.is-collapse{
+          background: #f3f4f6;
+          color: #6b7280;
+          border: 1px dashed #d1d5db;
+          // font-style: italic;
+        }
+
+        // 클릭 가능 (group / collapse) — 호버 효과
+        &.is-clickable{
+          cursor: pointer;
+          &:hover{
+            transform: translateY(-1px);
+            box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+          }
+        }
+      }
+    }
+  }
+  .item--select--wrap{
+    .item--select--btn--wrap{
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-end;
+      button{
+        padding: 8px 16px;
         cursor: pointer;
+        font-weight: 600;
+        font-size: 13px;
+        color: #fff;
+        background-color: #17a2b8;
+        border-radius: 4px;
+      }
+    }
+    .item--selected--wrap{
+      display: flex;
+      gap: 8px;
+      flex-wrap: wrap;
+      .item--selected{
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        min-width: 140px;
+        border-radius: 6px;
+        padding: 8px 12px;
+        font-size: 12px;
         font-weight: 400;
+        color: #374151;
+        border: 1px solid #eaecf0;
+        background-color: #f3f4f6;
+        button{
+          color: #9aa0aa;
+          cursor: pointer;
+          font-size: 10px;
+        }
       }
     }
   }
+  //  챌린지 등록, 퀘스트 등록에서 공통 사용 : E
   .round--add--btn{
     text-align: center;
     margin-top: 16px;
@@ -7611,6 +7663,60 @@ html {
   .admin--inner--table--wrap{
     margin-top: 12px;
     margin-bottom: 12px;
+    .quest--step--wrap{
+      display: flex;
+      justify-content: center;
+      gap: 6px;
+      align-items: center;
+      button{
+        width: 40px;
+        height: 32px;
+        color: #666b75;
+        font-size: 12px;
+        font-weight: 400;
+        border-radius: 4px;
+        border: 1px solid #c8d5e6;
+        &.active{
+          background-color: #3c80f2;
+          color: #fff;
+          font-weight: 700;
+        }
+      }
+    }
+    .item--select--wrap{
+      .item--select--btn--wrap{
+        justify-content: center;
+        gap: 12px;
+        align-items: center;
+      }
+    }
+    .quest--add--btn{
+      button{
+        text-align: center;
+        width: 100%;
+        margin-top: 6px;
+        border-radius: 4px;
+        border: 1px dashed #c8d5e6;
+        background-color: #f8f9fb;
+        padding: 12px 0;
+        color: #3c80f2;
+        font-size: 13px;
+        cursor: pointer;
+        font-weight: 600;
+      }
+    }
+    .place--select--wrap{
+      .place--select--btn--wrap{
+        position: relative;
+        .all--place--wrap{
+          position: absolute;
+          z-index: 8;
+          margin-top: 0;
+          top: 52px;
+          left: 0;
+        }
+      }
+    }
   }
   .admin--quest--table{
     width: 100%;
@@ -7618,13 +7724,13 @@ html {
       th, td{
         padding: 14px 8px;
         text-align: center;
-        .input--wrap{
-          justify-content: center;
-        }
         .admin--form-select{
           height: 40px;
           line-height: 1;
           width: 120px;
+          &.place--select{
+            height: auto;
+          }
         }
       }
     }

+ 57 - 7
app/pages/site-manager/challenge/create.vue

@@ -28,7 +28,7 @@
                     v-model="formData.fee"
                     type="number"
                     min="0"
-                    class="admin--form-input w--200"
+                    class="admin--form-input w--120"
                     :placeholder="isFree ? '0 (무료)' : '예: 10000'"
                     :disabled="isFree"
                     required
@@ -309,18 +309,19 @@
                         @click.stop="openDropdown(place)"
                       >
                         <div
-                          v-for="key in place.onboards.slice(0, 2)"
-                          :key="key"
+                          v-for="chip in displayChips(place).slice(0, 2)"
+                          :key="chip.key"
                           class="place--selected"
+                          :class="{ 'is-group': chip.type === 'group' }"
                         >
-                          {{ placeTypeByKey(key) === 'onboard' ? '🚤' : '🎣' }} {{ placeNameByKey(key) }}
+                          {{ chip.icon }} {{ chip.label }}
                           <button
                             type="button"
-                            @click.stop="removePlaceChip(place, key)"
+                            @click.stop="removeChipFromPlace(place, chip)"
                           >✕</button>
                         </div>
-                        <div v-if="place.onboards.length > 2" class="place--selected">
-                          + {{ place.onboards.length - 2 }}
+                        <div v-if="displayChips(place).length > 2" class="place--selected">
+                          + {{ displayChips(place).length - 2 }}
                         </div>
                       </div>
 
@@ -775,6 +776,55 @@
     place.onboards = place.onboards.filter((k) => k !== key);
   }
 
+  // 칩 표시용 — 같은 지역의 모든 장소가 선택됐으면 "○○ 전체" 그룹 칩으로 묶음
+  function displayChips(place) {
+    const selectedKeys = new Set(place.onboards);
+
+    const groupedAll = new Map();
+    placesAll.value.forEach((p) => {
+      const area = p.area_name || "미분류";
+      if (!groupedAll.has(area)) groupedAll.set(area, []);
+      groupedAll.get(area).push(p);
+    });
+
+    const result = [];
+    const processedKeys = new Set();
+
+    for (const [area, places] of groupedAll.entries()) {
+      if (places.length < 2) continue;
+      const groupKeys = places.map(placeKey);
+      const allSelected = groupKeys.every((k) => selectedKeys.has(k));
+      if (allSelected) {
+        result.push({
+          key: `group:${area}`,
+          type: "group",
+          label: `${area} 전체`,
+          icon: "📍",
+          keys: groupKeys,
+        });
+        groupKeys.forEach((k) => processedKeys.add(k));
+      }
+    }
+
+    for (const key of place.onboards) {
+      if (processedKeys.has(key)) continue;
+      result.push({
+        key,
+        type: "single",
+        label: placeNameByKey(key),
+        icon: placeTypeByKey(key) === "onboard" ? "🚤" : "🎣",
+        keys: [key],
+      });
+    }
+
+    return result;
+  }
+
+  function removeChipFromPlace(place, chip) {
+    const keysToRemove = new Set(chip.keys);
+    place.onboards = place.onboards.filter((k) => !keysToRemove.has(k));
+  }
+
   // 외부 클릭 시 모든 드롭다운 닫기
   function handleDocumentClick() {
     closeAllDropdowns();

+ 161 - 5
app/pages/site-manager/challenge/detail/[id].vue

@@ -213,13 +213,29 @@
 
                 <div class="place--select--wrap">
                   <p class="mt--8 mb--4">장소 목록</p>
-                  <div class="item--selected--wrap">
+                  <div class="area--group--wrap">
                     <div
-                      v-for="o in place.onboards"
-                      :key="o.id"
-                      :class="[o.place_type === 'onboard' ? 'item--selected onboard' : 'item--selected']"
+                      v-for="group in chipsByArea(place)"
+                      :key="group.area"
+                      class="area--group"
                     >
-                      {{ o.place_type === 'onboard' ? '🚤' : '🎣' }} {{ o.place_name || '(삭제됨)' }}
+                      <p class="area--group__name">📍 {{ group.area }}</p>
+                      <div class="item--selected--wrap">
+                        <div
+                          v-for="chip in group.chips"
+                          :key="chip.key"
+                          :class="[
+                            'item--selected',
+                            chip.type === 'group' ? 'is-group is-clickable' : '',
+                            chip.type === 'collapse' ? 'is-collapse is-clickable' : '',
+                            chip.type === 'expanded' && chip.placeType === 'onboard' ? 'onboard' : '',
+                            chip.type === 'single' && chip.placeType === 'onboard' ? 'onboard' : ''
+                          ]"
+                          @click="(chip.type === 'group' || chip.type === 'collapse') && toggleExpandedArea(place, chip.area)"
+                        >
+                          {{ chip.icon }} {{ chipLabelInArea(chip) }}
+                        </div>
+                      </div>
                     </div>
                   </div>
 
@@ -377,6 +393,145 @@
     return num.toLocaleString() + "원";
   };
 
+  // ============================
+  // 그룹 칩 표시용 — 같은 지역 전체 선택이면 "○○ 전체" 묶음
+  // ============================
+  const placesAll = ref([]); // 모든 선상+낚시터 (area_name 포함)
+
+  const loadPlacesAll = async () => {
+    try {
+      const [onboardRes, fishingRes] = await Promise.all([
+        get("/onboard/list", { params: { per_page: 1000 } }),
+        get("/fishing/list", { params: { per_page: 1000 } }),
+      ]);
+      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];
+    } catch (e) {
+      console.error("[Detail] placesAll 로드 실패:", e);
+    }
+  };
+
+  const placeKey = (p) => `${p._placeType}-${p.id}`;
+  const onboardObjKey = (o) => `${o.place_type}-${o.place_id}`;
+
+  function displayChips(place) {
+    if (!place.expandedAreas) place.expandedAreas = [];
+    const selectedKeys = new Set((place.onboards || []).map(onboardObjKey));
+    const expandedAreas = new Set(place.expandedAreas);
+
+    // 지역별 전체 장소 그룹화 (placesAll 기준)
+    const groupedAll = new Map();
+    placesAll.value.forEach((p) => {
+      const area = p.area_name || "미분류";
+      if (!groupedAll.has(area)) groupedAll.set(area, []);
+      groupedAll.get(area).push(p);
+    });
+
+    const result = [];
+    const processedKeys = new Set();
+
+    // 1) 같은 지역의 모든 장소가 선택됐고 2개 이상이면 → 그룹 칩 (펼침 가능)
+    for (const [area, places] of groupedAll.entries()) {
+      if (places.length < 2) continue;
+      const groupKeys = places.map(placeKey);
+      const allSelected = groupKeys.every((k) => selectedKeys.has(k));
+      if (!allSelected) continue;
+
+      if (expandedAreas.has(area)) {
+        // 펼친 상태 — 개별 칩들 + 접기 칩
+        for (const p of places) {
+          result.push({
+            key: placeKey(p),
+            type: "expanded",
+            label: p.name,
+            icon: p._placeType === "onboard" ? "🚤" : "🎣",
+            placeType: p._placeType,
+            area,
+          });
+        }
+        result.push({
+          key: `collapse:${area}`,
+          type: "collapse",
+          label: `${area} 접기`,
+          icon: "↩",
+          placeType: null,
+          area,
+        });
+      } else {
+        // 접힌 상태 — 그룹 칩
+        result.push({
+          key: `group:${area}`,
+          type: "group",
+          label: `${area} 전체`,
+          icon: "📍",
+          placeType: null,
+          area,
+        });
+      }
+      groupKeys.forEach((k) => processedKeys.add(k));
+    }
+
+    // 2) 남은 onboards는 개별 칩
+    for (const o of (place.onboards || [])) {
+      const key = onboardObjKey(o);
+      if (processedKeys.has(key)) continue;
+      result.push({
+        key: `single:${key}`,
+        type: "single",
+        label: o.place_name || "(삭제됨)",
+        icon: o.place_type === "onboard" ? "🚤" : "🎣",
+        placeType: o.place_type,
+      });
+    }
+
+    return result;
+  }
+
+  // 그룹/접기 칩 클릭 → 토글
+  function toggleExpandedArea(place, area) {
+    if (!place.expandedAreas) place.expandedAreas = [];
+    const idx = place.expandedAreas.indexOf(area);
+    if (idx === -1) place.expandedAreas.push(area);
+    else place.expandedAreas.splice(idx, 1);
+  }
+
+  // 지역별로 묶어서 반환 — 각 지역명 헤더 + 그 안의 칩들
+  function chipsByArea(place) {
+    const chips = displayChips(place);
+    const placeAreaMap = new Map();
+    placesAll.value.forEach((p) => {
+      placeAreaMap.set(placeKey(p), p.area_name || "미분류");
+    });
+
+    const groupMap = new Map(); // area => chips[]
+    for (const chip of chips) {
+      let area = chip.area;
+      if (!area) {
+        // single chip — placesAll에서 area 찾기
+        if (chip.type === "single") {
+          area = placeAreaMap.get(chip.key.replace(/^single:/, "")) || "미분류";
+        } else if (chip.type === "expanded") {
+          area = placeAreaMap.get(chip.key) || "미분류";
+        }
+      }
+      if (!area) area = "미분류";
+      if (!groupMap.has(area)) groupMap.set(area, []);
+      groupMap.get(area).push(chip);
+    }
+
+    return Array.from(groupMap.entries()).map(([area, chips]) => ({ area, chips }));
+  }
+
+  // 헤더에 area명이 표시되므로 칩 라벨에서 area명 제거
+  function chipLabelInArea(chip) {
+    if (chip.type === "group") return "전체";
+    if (chip.type === "collapse") return "접기";
+    return chip.label;
+  }
+
   const loadChallenge = async () => {
     isLoading.value = true;
     try {
@@ -453,6 +608,7 @@
   const goToEdit = () => router.push(`/site-manager/challenge/edit/${challengeId}`);
 
   onMounted(() => {
+    loadPlacesAll();
     loadChallenge();
   });
 </script>

+ 56 - 6
app/pages/site-manager/challenge/edit/[id].vue

@@ -326,18 +326,19 @@
                         @click.stop="openDropdown(place)"
                       >
                         <div
-                          v-for="key in place.onboards.slice(0, 2)"
-                          :key="key"
+                          v-for="chip in displayChips(place).slice(0, 2)"
+                          :key="chip.key"
                           class="place--selected"
+                          :class="{ 'is-group': chip.type === 'group' }"
                         >
-                          {{ placeTypeByKey(key) === 'onboard' ? '🚤' : '🎣' }} {{ placeNameByKey(key) }}
+                          {{ chip.icon }} {{ chip.label }}
                           <button
                             type="button"
-                            @click.stop="removePlaceChip(place, key)"
+                            @click.stop="removeChipFromPlace(place, chip)"
                           >✕</button>
                         </div>
-                        <div v-if="place.onboards.length > 2" class="place--selected">
-                          + {{ place.onboards.length - 2 }}
+                        <div v-if="displayChips(place).length > 2" class="place--selected">
+                          + {{ displayChips(place).length - 2 }}
                         </div>
                       </div>
 
@@ -772,6 +773,55 @@
     place.onboards = place.onboards.filter((k) => k !== key);
   }
 
+  // 칩 표시용 — 같은 지역의 모든 장소가 선택됐으면 "○○ 전체" 그룹 칩으로 묶음
+  function displayChips(place) {
+    const selectedKeys = new Set(place.onboards);
+
+    const groupedAll = new Map();
+    placesAll.value.forEach((p) => {
+      const area = p.area_name || "미분류";
+      if (!groupedAll.has(area)) groupedAll.set(area, []);
+      groupedAll.get(area).push(p);
+    });
+
+    const result = [];
+    const processedKeys = new Set();
+
+    for (const [area, places] of groupedAll.entries()) {
+      if (places.length < 2) continue;
+      const groupKeys = places.map(placeKey);
+      const allSelected = groupKeys.every((k) => selectedKeys.has(k));
+      if (allSelected) {
+        result.push({
+          key: `group:${area}`,
+          type: "group",
+          label: `${area} 전체`,
+          icon: "📍",
+          keys: groupKeys,
+        });
+        groupKeys.forEach((k) => processedKeys.add(k));
+      }
+    }
+
+    for (const key of place.onboards) {
+      if (processedKeys.has(key)) continue;
+      result.push({
+        key,
+        type: "single",
+        label: placeNameByKey(key),
+        icon: placeTypeByKey(key) === "onboard" ? "🚤" : "🎣",
+        keys: [key],
+      });
+    }
+
+    return result;
+  }
+
+  function removeChipFromPlace(place, chip) {
+    const keysToRemove = new Set(chip.keys);
+    place.onboards = place.onboards.filter((k) => !keysToRemove.has(k));
+  }
+
   // 외부 클릭 시 모든 드롭다운 닫기
   function handleDocumentClick() {
     closeAllDropdowns();