ソースを参照

[관리자관리] 완료

DESKTOP-T61HUSC\user 2 週間 前
コミット
7c2748109e

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

@@ -210,6 +210,10 @@ html {
 |END
 =================================================*/
 
+.txt--error{
+  color: var(--admin-error)!important;
+}
+
 .radius--img--wrap {
   overflow: hidden;
   border-radius: 20px;
@@ -2660,6 +2664,11 @@ footer {
   font-weight: 600;
   white-space: nowrap;
 
+  &.admin--badge-sus{
+    background-color: #fff0ec;
+    color: var(--admin-red);
+  }
+
   &.admin--badge-html {
     background: rgba(59, 130, 246, 0.1);
     color: #3b82f6;
@@ -2685,6 +2694,32 @@ footer {
     color: #6b7280;
   }
 
+  // 슈퍼 관리자 전용 — 진한 살구/골드 톤 + 진한 글씨
+  &.admin--badge-super {
+    color: #fff;
+    background-color: var(--admin-accent-primary);
+  }
+
+  // 메뉴 권한 칩 (관리자 list — admin/field/fishing 등)
+  &.admin--badge-perm {
+    background: #eef2ff;
+    color: #4f46e5;
+    border: 1px solid #e0e7ff;
+    padding: 3px 9px;
+    font-size: 11.5px;
+    font-weight: 600;
+  }
+
+  // 권한 칩 +N 더보기
+  &.admin--badge-more {
+    background: #f3f4f6;
+    color: #4b5563;
+    border: 1px solid #e5e7eb;
+    padding: 3px 9px;
+    font-size: 11.5px;
+    font-weight: 700;
+  }
+
   &.item--ticket{
     background-color: #E8F0FE;
     color: #3c80f2;
@@ -3036,11 +3071,9 @@ footer {
 .admin--btn-small-excel {
   background: #217346;
   color: #ffffff;
-  border: 1px solid #1a5c37;
 
   &:hover {
     background: #185c37;
-    border-color: #144d2d;
   }
 }
 
@@ -3173,18 +3206,58 @@ footer {
 }
 
 .admin--checkbox-label {
-  display: flex;
+  display: inline-flex;
   align-items: center;
   gap: 8px;
   color: var(--admin-text-primary);
   font-size: 14px;
   cursor: pointer;
 
+  // 테이블 체크박스와 동일한 SVG 스타일
   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;
-    accent-color: var(--admin-accent-primary);
+    display: inline-block;
+    vertical-align: middle;
+    transition: background-color 0.15s ease, border-color 0.15s ease;
+    flex-shrink: 0;
+
+    &:hover {
+      border-color: var(--admin-accent-primary);
+    }
+
+    &:focus-visible {
+      outline: 2px solid rgba(26, 35, 50, 0.25);
+      outline-offset: 1px;
+    }
+
+    &:checked {
+      background-color: #fff;
+      border-color: var(--admin-accent-primary);
+      background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M3.5 8L6.75 11.25L12.5 5.25' stroke='%231A2332' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+    }
+
+    &:indeterminate {
+      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='M4 8h8' stroke='%231A2332' stroke-width='2.2' stroke-linecap='round'/%3E%3C/svg%3E");
+    }
+
+    &:disabled {
+      opacity: 0.4;
+      cursor: not-allowed;
+    }
   }
 
   span {
@@ -3196,6 +3269,40 @@ footer {
   }
 }
 
+// 메뉴 권한 체크박스 그리드 — 페이지에 가득 차게 배치
+.admin--permissions-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 12px 24px;
+  padding: 16px;
+  background: #F8F9FB;
+  border: 1px solid #E8EAEF;
+  border-radius: 6px;
+}
+
+// 마진 유틸리티
+.mb--8  { margin-bottom: 8px; }
+.mb--16 { margin-bottom: 16px; }
+
+// 권한 칩 가로 정렬 (한 줄 고정, 넘침 방지)
+.admin--perm-list {
+  display: flex;
+  gap: 4px;
+  flex-wrap: nowrap;
+  align-items: center;
+}
+
+// 비활성/안내 유틸
+.is-disabled {
+  opacity: 0.5;
+  cursor: not-allowed !important;
+  pointer-events: none;
+}
+.txt--muted {
+  color: var(--admin-text-muted, #8a93a3);
+  font-size: 13px;
+}
+
 // Admin Footer
 .admin--footer {
   margin-top: 64px;
@@ -3240,6 +3347,11 @@ footer {
   justify-content: center;
 }
 
+// 다른 모달 위에 떠야 하는 알림/확인 (AdminAlertModal 전용)
+.admin--alert-overlay {
+  z-index: 10010;
+}
+
 .admin--alert-modal {
   background: #ffffff;
   padding: 0;
@@ -3289,6 +3401,161 @@ footer {
   }
 }
 
+// 폼 형식 모달 (비밀번호 변경 등) — 헤더 primary, 본문 form input
+.admin--form-modal {
+  min-width: 440px;
+  max-width: 520px;
+  overflow: hidden;
+  border-radius: 10px;
+
+  .admin--modal-header {
+    background: var(--admin-accent-primary);
+    border-bottom: none;
+    padding: 18px 22px;
+
+    h4 {
+      color: var(--admin-yellow, #ffd84d);
+      font-size: 16px;
+      font-weight: 600;
+      letter-spacing: 0.2px;
+      margin: 0;
+    }
+  }
+
+  .admin--modal-close {
+    color: var(--admin-yellow, #ffd84d);
+    font-size: 26px;
+    line-height: 1;
+    opacity: 0.9;
+
+    &:hover {
+      color: #fff;
+      opacity: 1;
+    }
+  }
+
+  .admin--modal-body {
+    padding: 24px 22px 8px;
+
+    .admin--modal-target {
+      margin: 0 0 18px;
+      padding: 12px 14px;
+      background: #f6f7fb;
+      border-radius: 6px;
+      font-size: 13px;
+      color: #555;
+
+      strong {
+        margin-left: 8px;
+        color: var(--admin-accent-primary);
+        font-weight: 700;
+      }
+    }
+
+    .admin--form-field {
+      margin-bottom: 16px;
+    }
+
+    .admin--form-label {
+      display: block;
+      font-size: 13px;
+      font-weight: 600;
+      color: #333;
+      margin-bottom: 6px;
+    }
+
+    .admin--form-input {
+      width: 100%;
+      padding: 12px 14px;
+      font-size: 14px;
+      border: 1px solid #e2e5ec;
+      border-radius: 6px;
+      background: #fff;
+      color: #333;
+      transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+      &::placeholder {
+        color: #b2b6bd;
+      }
+
+      &:focus {
+        outline: none;
+        border-color: var(--admin-accent-primary);
+        box-shadow: 0 0 0 3px rgba(26, 35, 50, 0.08);
+      }
+    }
+  }
+
+  .admin--modal-footer {
+    padding: 16px 22px 22px;
+    border-top: none;
+    gap: 8px;
+    display: flex;
+    align-items: center;
+
+    // 모달 안 버튼 — 글로벌 .admin--btn 정의가 .admin--form 안에 nested돼있어 여기서 별도 정의
+    .admin--btn {
+      padding: 10px 22px;
+      min-width: 96px;
+      font-size: 14px;
+      font-weight: 600;
+      border-radius: 6px;
+      cursor: pointer;
+      background: #fff;
+      border: 1px solid #d8dce4;
+      color: #555b66;
+      transition: all 0.18s ease;
+
+      &:hover:not(:disabled) {
+        background: #f4f6f9;
+        border-color: #b9bfc9;
+        color: #333;
+      }
+
+      &:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+      }
+    }
+
+    // 강조 버튼 (확인/저장/변경 등)
+    .admin--btn-red,
+    .admin--btn-primary-fill {
+      background: var(--admin-accent-primary);
+      border-color: var(--admin-accent-primary);
+      color: var(--admin-yellow, #ffd84d);
+
+      &:hover:not(:disabled) {
+        background: var(--admin-accent-hover, #0f1a2c);
+        border-color: var(--admin-accent-hover, #0f1a2c);
+        color: #fff;
+      }
+    }
+
+    // 위험 액션용 (실제 삭제 같은 거)
+    .admin--btn-danger {
+      background: #e85d3f;
+      border-color: #e85d3f;
+      color: #fff;
+
+      &:hover:not(:disabled) {
+        background: #d24a2c;
+        border-color: #d24a2c;
+      }
+    }
+  }
+
+  // alert/confirm 용 텍스트 박스
+  .admin--alert-content p {
+    margin: 0;
+    font-size: 15px;
+    line-height: 1.65;
+    color: #333;
+    text-align: left;
+    white-space: pre-line; // \n 줄바꿈 표시
+  }
+}
+
 .admin--alert-modal .admin--modal-footer {
   padding: 15px 20px;
   border-top: 1px solid #e0e0e0;
@@ -7599,6 +7866,9 @@ footer {
         &.w--300{
           width: 300px;
         }
+        &.center{
+          text-align: center;
+        }
       }
       input[type=radio]{
         appearance: none;

+ 9 - 7
app/components/admin/AdminAlertModal.vue

@@ -1,7 +1,8 @@
 <template>
-  <Transition name="modal-fade">
-    <div class="admin--modal-overlay" @click.self="handleClose">
-      <div class="admin--modal admin--modal-sm admin--alert-modal">
+  <Teleport to="body">
+    <Transition name="modal-fade">
+      <div class="admin--modal-overlay admin--alert-overlay" @click.self="handleClose">
+        <div class="admin--modal admin--modal-sm admin--alert-modal admin--form-modal">
         <div class="admin--modal-header">
           <h4>{{ title }}</h4>
           <button
@@ -21,20 +22,21 @@
           <button
             v-if="type === 'confirm'"
             @click="handleCancel"
-            class="admin--btn-secondary"
+            class="admin--btn"
           >
             취소
           </button>
           <button
             @click="handleConfirm"
-            class="admin--btn-primary"
+            class="admin--btn admin--btn-primary-fill ml--auto"
           >
             확인
           </button>
         </div>
       </div>
-    </div>
-  </Transition>
+      </div>
+    </Transition>
+  </Teleport>
 </template>
 
 <script setup>

+ 37 - 0
app/composables/useAuth.js

@@ -0,0 +1,37 @@
+import { ref, computed } from "vue";
+
+/**
+ * 로그인된 관리자 정보 + 권한 체크 헬퍼
+ * localStorage.admin_user를 읽어 메모리로 보관.
+ *
+ * 사용:
+ *   const { user, isSuperAdmin, isAdmin, canAccess } = useAuth();
+ */
+export const useAuth = () => {
+  const user = ref(null);
+
+  const load = () => {
+    if (typeof window === "undefined") return;
+    try {
+      user.value = JSON.parse(localStorage.getItem("admin_user") || "null");
+    } catch {
+      user.value = null;
+    }
+  };
+  load();
+
+  const isSuperAdmin = computed(() => user.value?.role === "super_admin");
+  const isAdmin = computed(() => user.value?.role === "admin");
+
+  // 메뉴 권한 체크 — dashboard는 모두 접근
+  const canAccess = (menuId) => {
+    if (menuId === "dashboard") return true;
+    const u = user.value;
+    if (!u) return false;
+    if (u.role === "super_admin") return true;
+    if (u.permissions === "all") return true;
+    return Array.isArray(u.permissions) && u.permissions.includes(menuId);
+  };
+
+  return { user, isSuperAdmin, isAdmin, canAccess, reload: load };
+};

+ 30 - 3
app/layouts/admin.vue

@@ -1,5 +1,5 @@
 <script setup>
-  import { ref, computed, watch } from "vue";
+  import { ref, computed, watch, onMounted } from "vue";
   import { useRoute, useRouter } from "vue-router";
   import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
   import AdminModal from "~/components/admin/AdminModal.vue";
@@ -10,7 +10,33 @@
   // 메뉴 열림 상태 관리
   const openMenus = ref(["dashboard"]);
 
+  // 로그인한 관리자 정보 (권한 분기용 — 모달용 currentAdmin과 별개)
+  const authUser = ref(null);
+  const loadAuthUser = () => {
+    if (typeof window === "undefined") return;
+    try {
+      authUser.value = JSON.parse(localStorage.getItem("admin_user") || "null");
+    } catch {
+      authUser.value = null;
+    }
+  };
+  onMounted(loadAuthUser);
+
+  // 권한 체크 — dashboard는 모두 접근, super_admin은 전체 접근
+  const canAccess = (menuId) => {
+    if (menuId === "dashboard") return true;
+    const u = authUser.value;
+    if (!u) return false;
+    if (u.role === "super_admin") return true;
+    if (u.permissions === "all") return true;
+    return Array.isArray(u.permissions) && u.permissions.includes(menuId);
+  };
+
+  // 권한 필터링된 메뉴
+  const visibleMenuItems = computed(() => menuItems.value.filter((m) => canAccess(m.id)));
+
   // GNB 메뉴 구조
+  // 각 메뉴별 id 변경 금지
   const menuItems = ref([
     {
       id: "dashboard",
@@ -21,6 +47,7 @@
       id: "admin",
       title: "🔑 관리자 관리",
       path: "/site-manager/admin/list",
+      pattern: /^\/site-manager\/admin\/(list|create|edit|detail)/,
     },
     {
       id: "field",
@@ -383,7 +410,7 @@
         <h1>{{ pageTitle }}</h1>
       </div>
       <div class="admin--header-right">
-        <button class="admin--header-btn" @click="goToProfile">정보수정</button>
+        <!-- <button class="admin--header-btn" @click="goToProfile">정보수정</button> -->
         <button
           type="button"
           class="admin--header-btn admin--header-btn-logout"
@@ -406,7 +433,7 @@
           </div>
         </NuxtLink>
         <nav class="admin--gnb">
-          <div v-for="menu in menuItems" :key="menu.id" class="admin--gnb-group">
+          <div v-for="menu in visibleMenuItems" :key="menu.id" class="admin--gnb-group">
             <!-- children 없는 메뉴: 토글 없이 바로 이동 -->
             <NuxtLink
               v-if="!menu.children"

+ 51 - 21
app/middleware/auth.js

@@ -1,38 +1,68 @@
+// URL → 메뉴 권한 id 매핑 (admin.vue menuItems의 id와 동일)
+const URL_PERMISSION_MAP = [
+  { prefix: '/site-manager/dashboard',         id: 'dashboard' },
+  { prefix: '/site-manager/admin',             id: 'admin' },
+  { prefix: '/site-manager/field',             id: 'field' },
+  { prefix: '/site-manager/area',              id: 'field' },
+  { prefix: '/site-manager/onboard',           id: 'fishing' },
+  { prefix: '/site-manager/fishing',           id: 'fishing' },
+  { prefix: '/site-manager/challenge',         id: 'challenge' },
+  { prefix: '/site-manager/quest',             id: 'quest' },
+  { prefix: '/site-manager/item',              id: 'item' },
+  { prefix: '/site-manager/species_challenge', id: 'species' },
+  { prefix: '/site-manager/species_quest',     id: 'species' },
+  { prefix: '/site-manager/species',           id: 'species' },
+  { prefix: '/site-manager/user',              id: 'user' },
+]
+
+// URL에서 필요한 권한 id를 찾는다 (없으면 null = 인증만 통과하면 OK)
+const getRequiredPermission = (path) => {
+  for (const m of URL_PERMISSION_MAP) {
+    if (path.startsWith(m.prefix)) return m.id
+  }
+  return null
+}
+
+// 현재 로그인된 admin이 해당 권한을 가지고 있는지
+const hasPermission = (user, menuId) => {
+  if (menuId === 'dashboard') return true
+  if (!user) return false
+  if (user.role === 'super_admin') return true
+  if (user.permissions === 'all') return true
+  return Array.isArray(user.permissions) && user.permissions.includes(menuId)
+}
+
 export default defineNuxtRouteMiddleware((to) => {
   // SSR에서는 middleware 실행 안 함 (클라이언트에서만)
-  if (import.meta.server) {
-    return
-  }
+  if (import.meta.server) return
 
-  // Admin 페이지가 아니면 middleware 실행 안 함
-  if (!to.path.startsWith('/site-manager')) {
-    return
-  }
+  // Admin 페이지가 아니면 패스
+  if (!to.path.startsWith('/site-manager')) return
 
   const token = localStorage.getItem('admin_token')
 
-  console.log('[Auth Middleware]', {
-    path: to.path,
-    hasToken: !!token,
-    token: token ? token.substring(0, 20) + '...' : null
-  })
-
   // 로그인 페이지는 예외 처리
   if (to.path === '/site-manager' || to.path === '/site-manager/') {
-    // 이미 로그인된 경우 대시보드로
-    if (token) {
-      console.log('[Auth] 이미 로그인됨, dashboard로 이동')
-      return navigateTo('/site-manager/dashboard')
-    }
-    console.log('[Auth] 로그인 페이지 접근 허용')
+    if (token) return navigateTo('/site-manager/dashboard')
     return
   }
 
   // Admin 페이지 접근 시 토큰 체크
   if (!token) {
-    console.log('[Auth] 토큰 없음, 로그인 페이지로 이동')
     return navigateTo('/site-manager')
   }
 
-  console.log('[Auth] 토큰 확인됨, 페이지 접근 허용')
+  // 권한 체크
+  let user = null
+  try {
+    user = JSON.parse(localStorage.getItem('admin_user') || 'null')
+  } catch {
+    user = null
+  }
+
+  const requiredId = getRequiredPermission(to.path)
+  if (requiredId && !hasPermission(user, requiredId)) {
+    console.warn('[Auth] 권한 없음 — dashboard로 이동:', to.path, '필요 권한:', requiredId)
+    return navigateTo('/site-manager/dashboard')
+  }
 })

+ 402 - 0
app/pages/site-manager/admin/create.vue

@@ -0,0 +1,402 @@
+<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.username"
+                    type="text"
+                    class="admin--form-input w--280"
+                    placeholder="4~20자 영문/숫자"
+                    maxlength="20"
+                    autocomplete="off"
+                    @input="resetUsernameCheck"
+                  />
+                  <button type="button" class="admin--btn-small admin--btn-blue ml--8" @click="checkUsername">
+                    중복체크
+                  </button>
+                  <span v-if="usernameStatus.message" :class="['ml--16', usernameStatus.ok ? 'txt--ok' : 'txt--error']">
+                    {{ usernameStatus.message }}
+                  </span>
+                </div>
+                <p class="mt--10">4~20자, 영문/숫자 조합. 등록 후 변경 불가.</p>
+              </td>
+            </tr>
+            <tr>
+              <th><div>비밀번호 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model="formData.password"
+                    type="password"
+                    class="admin--form-input w--280"
+                    placeholder="8자 이상 입력하세요"
+                    autocomplete="new-password"
+                  />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>비밀번호 확인 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model="formData.passwordConfirm"
+                    type="password"
+                    class="admin--form-input w--280"
+                    placeholder="다시 한번 입력하세요"
+                    autocomplete="new-password"
+                  />
+                  <span v-if="formData.passwordConfirm && !passwordMatch" class="ml--16 txt--error">비밀번호가 일치하지 않습니다.</span>
+                  <span v-else-if="formData.passwordConfirm && passwordMatch" class="ml--16 txt--ok">일치</span>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>이름 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.name" type="text" class="admin--form-input w--280" placeholder="" maxlength="30" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>핸드폰 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="phone1" class="admin--form-select w--120">
+                    <option value="010">010</option>
+                    <option value="011">011</option>
+                    <option value="016">016</option>
+                    <option value="017">017</option>
+                    <option value="018">018</option>
+                    <option value="019">019</option>
+                  </select>
+                  <span class="mx--8">-</span>
+                  <input
+                    v-model="phone2"
+                    type="text"
+                    inputmode="numeric"
+                    class="admin--form-input w--120"
+                    maxlength="4"
+                    placeholder=""
+                    @input="onlyDigits('phone2')"
+                  />
+                  <span class="mx--8">-</span>
+                  <input
+                    v-model="phone3"
+                    type="text"
+                    inputmode="numeric"
+                    class="admin--form-input w--120 "
+                    maxlength="4"
+                    placeholder=""
+                    @input="onlyDigits('phone3')"
+                  />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>이메일 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model="emailLocal"
+                    type="text"
+                    class="admin--form-input w--160"
+                    placeholder="아이디"
+                    maxlength="50"
+                    autocomplete="off"
+                  />
+                  <span class="mx--8">@</span>
+                  <input
+                    v-model="emailDomain"
+                    type="text"
+                    class="admin--form-input w--160"
+                    placeholder="domain.com"
+                    maxlength="50"
+                    :readonly="emailDomainSelect !== 'custom'"
+                    autocomplete="off"
+                  />
+                  <select v-model="emailDomainSelect" @change="onDomainChange" class="admin--form-select w--160 ml--8">
+                    <option value="">선택</option>
+                    <option value="naver.com">naver.com</option>
+                    <option value="gmail.com">gmail.com</option>
+                    <option value="daum.net">daum.net</option>
+                    <option value="hanmail.net">hanmail.net</option>
+                    <option value="kakao.com">kakao.com</option>
+                    <option value="nate.com">nate.com</option>
+                    <option value="custom">직접입력</option>
+                  </select>
+                </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.role" value="admin" /> 관리자
+                  </label>
+                  <label class="admin--radio-label ml--16" :class="{ 'is-disabled': !isSuperAdmin }">
+                    <input type="radio" v-model="formData.role" value="super_admin" :disabled="!isSuperAdmin" />
+                    슈퍼 관리자
+                  </label>
+                </div>
+                <p v-if="!isSuperAdmin" class="mt--10 txt--muted">슈퍼 관리자 권한 부여는 슈퍼 관리자만 할 수 있습니다.</p>
+                <p v-else-if="formData.role === 'super_admin'" class="mt--10">슈퍼 관리자는 모든 메뉴에 접근할 수 있습니다.</p>
+              </td>
+            </tr>
+            <tr v-if="formData.role === 'admin'">
+              <th><div>메뉴 권한 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="admin--permissions-grid">
+                  <label v-for="opt in menuOptions" :key="opt.id" class="admin--checkbox-label">
+                    <input type="checkbox" :value="opt.id" v-model="formData.permissions" />
+                    {{ opt.title }}
+                  </label>
+                </div>
+                <p class="mt--10">관리자가 접근할 수 있는 메뉴를 선택하세요. 대시보드는 모든 관리자에게 기본 제공됩니다.</p>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <!-- 버튼 영역 -->
+        <div class="admin--form-actions">
+          <button type="button" class="admin--btn" @click="goToList">
+            ← 목록으로
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+            {{ isSaving ? "등록 중..." : "등록" }}
+          </button>
+        </div>
+      </form>
+    </div>
+
+    <!-- 알림 모달 -->
+    <AdminAlertModal
+      v-if="alertModal.show"
+      :title="alertModal.title"
+      :message="alertModal.message"
+      :type="alertModal.type"
+      @confirm="handleAlertConfirm"
+      @cancel="handleAlertCancel"
+      @close="closeAlertModal"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed } from "vue";
+  import { useRouter } from "vue-router";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
+
+  definePageMeta({
+    layout: "admin",
+    middleware: ["auth"],
+  });
+
+  const router = useRouter();
+  const { get, post } = useApi();
+  const { isSuperAdmin } = useAuth();
+
+  const isSaving = ref(false);
+
+  const formData = ref({
+    username: "",
+    password: "",
+    passwordConfirm: "",
+    name: "",
+    role: "admin",
+    status: "active",
+    permissions: [],   // role === 'admin'일 때 사용. value: menuItems의 id
+  });
+
+  // 메뉴 권한 옵션 (admin.vue의 menuItems와 동일 id 사용)
+  // 메뉴별 id 변경 금지
+  const menuOptions = [
+    { id: "admin",     title: "관리자 관리" },
+    { id: "field",     title: "분야 및 지역 관리" },
+    { id: "fishing",   title: "선상 및 낚시터 관리" },
+    { id: "challenge", title: "챌린지 관리" },
+    { id: "quest",     title: "퀘스트 관리" },
+    { id: "item",      title: "아이템 관리" },
+    { id: "species",   title: "어종 관리" },
+    { id: "user",      title: "회원 관리" },
+  ];
+
+  const isAllPermissionsSelected = computed(
+    () => menuOptions.every((m) => formData.value.permissions.includes(m.id))
+  );
+  const isPartialPermissionsSelected = computed(
+    () => formData.value.permissions.length > 0 && !isAllPermissionsSelected.value
+  );
+  const toggleAllPermissions = (checked) => {
+    formData.value.permissions = checked ? menuOptions.map((m) => m.id) : [];
+  };
+
+  // 이메일 분할 입력
+  const emailLocal = ref("");
+  const emailDomain = ref("");
+  const emailDomainSelect = ref("");   // 셀렉트 선택값. 'custom' 또는 도메인 문자열
+  const onDomainChange = () => {
+    if (emailDomainSelect.value === "custom") {
+      emailDomain.value = "";   // 직접 입력하도록 비움
+    } else if (emailDomainSelect.value !== "") {
+      emailDomain.value = emailDomainSelect.value;
+    }
+  };
+
+  // 핸드폰 분할 입력
+  const phone1 = ref("010");
+  const phone2 = ref("");
+  const phone3 = ref("");
+  const onlyDigits = (key) => {
+    if (key === "phone2") phone2.value = phone2.value.replace(/\D/g, "");
+    else if (key === "phone3") phone3.value = phone3.value.replace(/\D/g, "");
+  };
+
+  // 아이디 중복체크 상태
+  // ok=true 사용가능, ok=false 사용불가, null 미체크
+  const usernameStatus = ref({ checked: false, ok: null, message: "" });
+  const resetUsernameCheck = () => {
+    usernameStatus.value = { checked: false, ok: null, message: "" };
+  };
+
+  const passwordMatch = computed(
+    () => formData.value.password && formData.value.password === formData.value.passwordConfirm
+  );
+
+  // 알림 모달
+  const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
+  const showAlert = (message, title = "알림") => {
+    alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
+  };
+  const closeAlertModal = () => { alertModal.value.show = false; };
+  const handleAlertConfirm = () => {
+    if (alertModal.value.onConfirm) alertModal.value.onConfirm();
+    closeAlertModal();
+  };
+  const handleAlertCancel = () => closeAlertModal();
+
+  // 아이디 중복체크
+  const checkUsername = async () => {
+    const username = formData.value.username.trim();
+    if (!username) {
+      usernameStatus.value = { checked: false, ok: false, message: "아이디를 입력하세요." };
+      return;
+    }
+    if (username.length < 4 || username.length > 20) {
+      usernameStatus.value = { checked: false, ok: false, message: "4~20자로 입력하세요." };
+      return;
+    }
+    if (!/^[a-zA-Z0-9_]+$/.test(username)) {
+      usernameStatus.value = { checked: false, ok: false, message: "영문/숫자/_만 사용 가능합니다." };
+      return;
+    }
+
+    const { data, error } = await get("/admin/check-username", { params: { username } });
+    if (error || !data?.success) {
+      usernameStatus.value = { checked: false, ok: false, message: error?.message || data?.message || "중복 확인 실패" };
+      return;
+    }
+    if (data.data?.available) {
+      usernameStatus.value = { checked: true, ok: true, message: "사용 가능한 아이디입니다." };
+    } else {
+      usernameStatus.value = { checked: false, ok: false, message: "이미 사용 중인 아이디입니다." };
+    }
+  };
+
+  // 폼 검증
+  const validate = () => {
+    const f = formData.value;
+    const username = f.username.trim();
+    if (!username) return "아이디를 입력하세요.";
+    if (username.length < 4 || username.length > 20) return "아이디는 4~20자";
+    if (!/^[a-zA-Z0-9_]+$/.test(username)) return "아이디는 영문/숫자/_만 사용";
+    if (!usernameStatus.value.checked || !usernameStatus.value.ok) return "아이디 중복체크를 완료해주세요.";
+
+    if (!f.password) return "비밀번호를 입력하세요.";
+    if (f.password.length < 8) return "비밀번호는 8자 이상 입력해 주세요.";
+    if (f.password !== f.passwordConfirm) return "비밀번호가 일치하지 않습니다.";
+
+    const name = f.name.trim();
+    if (!name) return "이름을 입력하세요.";
+    if (name.length > 30) return "이름은 30자 이내";
+
+    // 이메일
+    const local = emailLocal.value.trim();
+    const domain = emailDomain.value.trim();
+    if (!local) return "이메일 아이디를 입력하세요.";
+    if (!domain) return "이메일 도메인을 입력하거나 선택하세요.";
+    const email = `${local}@${domain}`;
+    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "이메일 형식이 올바르지 않습니다.";
+
+    // 핸드폰
+    if (!phone1.value) return "핸드폰 앞자리를 선택하세요.";
+    if (!/^\d{3,4}$/.test(phone2.value)) return "핸드폰 가운데 자리(3~4자리 숫자)를 입력하세요.";
+    if (!/^\d{4}$/.test(phone3.value)) return "핸드폰 끝자리(4자리 숫자)를 입력하세요.";
+
+    if (!["super_admin", "admin"].includes(f.role)) return "권한을 선택하세요.";
+    if (!["active", "inactive", "suspended"].includes(f.status)) return "상태를 선택하세요.";
+
+    // 일반 관리자는 메뉴 권한 1개 이상 필요
+    if (f.role === "admin" && f.permissions.length === 0) {
+      return "관리자에게 부여할 메뉴 권한을 1개 이상 선택하세요.";
+    }
+
+    return null;
+  };
+
+  // 폼 제출
+  const handleSubmit = async () => {
+    const err = validate();
+    if (err) {
+      showAlert(err, "입력 오류");
+      return;
+    }
+
+    isSaving.value = true;
+    const f = formData.value;
+    const payload = {
+      username: f.username.trim(),
+      password: f.password,
+      name: f.name.trim(),
+      email: `${emailLocal.value.trim()}@${emailDomain.value.trim()}`,
+      phone: `${phone1.value}-${phone2.value}-${phone3.value}`,
+      role: f.role,
+      status: f.status,
+      // 슈퍼관리자는 권한 row 안 박음 (role=super_admin 자체가 전권)
+      permissions: f.role === "admin" ? f.permissions : [],
+    };
+
+    const { data, error } = await post("/admin", payload);
+    isSaving.value = false;
+
+    if (error || !data?.success) {
+      showAlert(error?.message || data?.message || "등록에 실패했습니다.", "오류");
+      return;
+    }
+
+    alertModal.value = {
+      show: true,
+      title: "성공",
+      message: data.message || "관리자가 등록되었습니다.",
+      type: "alert",
+      onConfirm: () => router.push("/site-manager/admin/list"),
+    };
+    setTimeout(() => router.push("/site-manager/admin/list"), 800);
+  };
+
+  // 이동
+  const goToList = () => router.push("/site-manager/admin/list");
+</script>

+ 353 - 0
app/pages/site-manager/admin/detail/[id].vue

@@ -0,0 +1,353 @@
+<template>
+  <div class="admin--page-content">
+    <div v-if="isLoading" class="admin--loading">데이터를 불러오는 중...</div>
+    <div v-else class="admin--form">
+      <!-- 관리자 정보 -->
+      <table class="admin--form--table">
+        <colgroup>
+          <col style="width: 140px;">
+          <col>
+        </colgroup>
+        <tbody>
+          <tr>
+            <th><div>아이디</div></th>
+            <td class="admin--table-title">{{ data.username || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>이름</div></th>
+            <td>{{ data.name || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>이메일</div></th>
+            <td>{{ data.email || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>핸드폰</div></th>
+            <td>{{ data.phone || "-" }}</td>
+          </tr>
+          <tr>
+            <th><div>권한</div></th>
+            <td>
+              <span v-if="data.role === 'super_admin'" class="admin--badge admin--badge-super">
+                슈퍼 관리자
+              </span>
+              <div v-else class="admin--perm-list">
+                <span v-for="p in data.permissions" :key="p" class="admin--badge admin--badge-perm">
+                  {{ permLabel(p) }}
+                </span>
+                <span v-if="!data.permissions.length" class="admin--badge admin--badge-ended">
+                  권한 없음
+                </span>
+              </div>
+            </td>
+          </tr>
+          <tr>
+            <th><div>상태</div></th>
+            <td>
+              <span :class="['admin--badge', getStatusBadgeClass(data.status)]">
+                {{ getStatusLabel(data.status) }}
+              </span>
+              <span v-if="isLocked" class="admin--badge admin--badge-ended ml--16">🔒 잠김 (5회 실패)</span>
+            </td>
+          </tr>
+          <tr>
+            <th><div>최근 로그인</div></th>
+            <td>{{ formatDateTime(data.last_login) }}</td>
+          </tr>
+          <tr>
+            <th><div>로그인 실패 횟수</div></th>
+            <td>
+              {{ data.login_attempts || 0 }}회
+              <span v-if="data.last_failed_login" class="ml--16">(최근 실패: {{ formatDateTime(data.last_failed_login) }})</span>
+            </td>
+          </tr>
+          <tr>
+            <th><div>등록일</div></th>
+            <td>{{ formatDateTime(data.created_at) }}</td>
+          </tr>
+          <tr>
+            <th><div>최근 수정</div></th>
+            <td>{{ formatDateTime(data.updated_at) }}</td>
+          </tr>
+        </tbody>
+      </table>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button type="button" class="admin--btn" @click="goToList">
+          ← 목록으로
+        </button>
+        <button
+          v-if="canModify && isLocked"
+          type="button"
+          class="admin--btn admin--btn-blue ml--auto"
+          @click="handleUnlock"
+        >🔓 잠금 해제</button>
+        <button
+          v-if="canModify"
+          type="button"
+          class="admin--btn admin--btn-blue-border"
+          :class="{ 'ml--auto': !isLocked }"
+          @click="openPasswordModal"
+        >비밀번호 변경</button>
+        <button v-if="canModify" type="button" class="admin--btn admin--btn-red-border" @click="handleDelete">
+          삭제
+        </button>
+        <button v-if="canModify" type="button" class="admin--btn admin--btn-red" @click="goToEdit">
+          수정
+        </button>
+      </div>
+    </div>
+
+    <!-- 비밀번호 변경 모달 -->
+    <Teleport to="body">
+      <div v-if="passwordModal.show" class="admin--modal-overlay" @click.self="closePasswordModal">
+        <div class="admin--alert-modal admin--form-modal">
+          <div class="admin--modal-header">
+            <h4>🔒 비밀번호 변경</h4>
+            <button class="admin--modal-close" @click="closePasswordModal">×</button>
+          </div>
+          <div class="admin--modal-body">
+            <p class="admin--modal-target">대상 계정 <strong>{{ data.username }}</strong></p>
+            <div class="admin--form-field">
+              <label class="admin--form-label">새 비밀번호</label>
+              <input
+                v-model="passwordModal.newPassword"
+                type="password"
+                class="admin--form-input"
+                placeholder="8자 이상"
+                autocomplete="new-password"
+              />
+            </div>
+            <div class="admin--form-field">
+              <label class="admin--form-label">새 비밀번호 확인</label>
+              <input
+                v-model="passwordModal.confirmPassword"
+                type="password"
+                class="admin--form-input"
+                placeholder="다시 입력"
+                autocomplete="new-password"
+                @keyup.enter="handleChangePassword"
+              />
+            </div>
+          </div>
+          <div class="admin--modal-footer">
+            <button class="admin--btn" @click="closePasswordModal">취소</button>
+            <button class="admin--btn admin--btn-red ml--auto" :disabled="passwordModal.isSaving" @click="handleChangePassword">
+              {{ passwordModal.isSaving ? "변경 중..." : "변경하기" }}
+            </button>
+          </div>
+        </div>
+      </div>
+    </Teleport>
+
+    <!-- 알림 모달 -->
+    <AdminAlertModal
+      v-if="alertModal.show"
+      :title="alertModal.title"
+      :message="alertModal.message"
+      :type="alertModal.type"
+      @confirm="handleAlertConfirm"
+      @cancel="handleAlertCancel"
+      @close="closeAlertModal"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { ref, 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 { isSuperAdmin } = useAuth();
+
+  const adminId = route.params.id;
+  // 일반 admin은 슈퍼관리자 정보 수정/삭제/비번/잠금 불가
+  const canModify = computed(() => isSuperAdmin.value || data.value.role !== "super_admin");
+
+  const isLoading = ref(true);
+  const data = ref({
+    username: "",
+    name: "",
+    email: "",
+    phone: "",
+    role: "",
+    status: "",
+    permissions: [],
+    login_attempts: 0,
+    last_failed_login: "",
+    last_login: "",
+    created_at: "",
+    updated_at: "",
+  });
+
+  // 권한 라벨 매핑 (admin.vue menuItems와 동일)
+  const PERM_LABELS = {
+    admin:     "관리자",
+    field:     "분야/지역",
+    fishing:   "선상/낚시터",
+    challenge: "챌린지",
+    quest:     "퀘스트",
+    item:      "아이템",
+    species:   "어종",
+    user:      "회원",
+  };
+  const permLabel = (id) => PERM_LABELS[id] || id;
+
+  const isLocked = computed(() => (data.value.login_attempts || 0) >= 5);
+
+  // 알림 모달
+  const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
+  const showAlert = (message, title = "알림") => {
+    alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
+  };
+  const showConfirm = (message, onConfirm, title = "확인") => {
+    alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
+  };
+  const closeAlertModal = () => { alertModal.value.show = false; };
+  const handleAlertConfirm = () => {
+    if (alertModal.value.onConfirm) alertModal.value.onConfirm();
+    closeAlertModal();
+  };
+  const handleAlertCancel = () => closeAlertModal();
+
+  // 비밀번호 변경 모달
+  const passwordModal = ref({ show: false, newPassword: "", confirmPassword: "", isSaving: false });
+  const openPasswordModal = () => {
+    passwordModal.value = { show: true, newPassword: "", confirmPassword: "", isSaving: false };
+  };
+  const closePasswordModal = () => {
+    passwordModal.value.show = false;
+  };
+
+  // 상세 조회
+  const loadDetail = async () => {
+    isLoading.value = true;
+    const { data: res, error } = await get(`/admin/${adminId}`);
+    if (error || !res?.success) {
+      showAlert(error?.message || res?.message || "조회에 실패했습니다.", "오류");
+      isLoading.value = false;
+      return;
+    }
+    const row = res.data || {};
+    data.value = {
+      username: row.username ?? "",
+      name: row.name ?? "",
+      email: row.email ?? "",
+      phone: row.phone ?? "",
+      role: row.role ?? "",
+      status: row.status ?? "",
+      permissions: Array.isArray(row.permissions) ? row.permissions : [],
+      login_attempts: row.login_attempts ?? 0,
+      last_failed_login: row.last_failed_login ?? "",
+      last_login: row.last_login ?? "",
+      created_at: row.created_at ?? "",
+      updated_at: row.updated_at ?? "",
+    };
+    isLoading.value = false;
+  };
+
+  // 삭제
+  const handleDelete = () => {
+    showConfirm(
+      `'${data.value.username}' 관리자를 삭제하시겠습니까?`,
+      async () => {
+        const { data: res, error } = await del(`/admin/${adminId}`);
+        if (error || !res?.success) {
+          showAlert(error?.message || res?.message || "삭제에 실패했습니다.", "오류");
+        } else {
+          showAlert(res.message || "삭제되었습니다.", "성공");
+          setTimeout(() => router.push("/site-manager/admin/list"), 800);
+        }
+      },
+      "관리자 삭제"
+    );
+  };
+
+  // 잠금 해제
+  const handleUnlock = () => {
+    showConfirm(
+      `'${data.value.username}' 계정의 잠금을 해제하시겠습니까?`,
+      async () => {
+        const { data: res, error } = await post(`/admin/${adminId}/unlock`, {});
+        if (error || !res?.success) {
+          showAlert(error?.message || res?.message || "잠금 해제에 실패했습니다.", "오류");
+        } else {
+          showAlert(res.message || "잠금이 해제되었습니다.", "성공");
+          await loadDetail();
+        }
+      },
+      "잠금 해제"
+    );
+  };
+
+  // 비밀번호 변경
+  const handleChangePassword = async () => {
+    const pw = passwordModal.value.newPassword;
+    const confirm = passwordModal.value.confirmPassword;
+    if (!pw || pw.length < 8) {
+      showAlert("비밀번호는 8자 이상 입력하세요.", "입력 오류");
+      return;
+    }
+    if (pw !== confirm) {
+      showAlert("비밀번호가 일치하지 않습니다.", "입력 오류");
+      return;
+    }
+
+    passwordModal.value.isSaving = true;
+    const { data: res, error } = await post(`/admin/${adminId}/password`, { new_password: pw });
+    passwordModal.value.isSaving = false;
+
+    if (error || !res?.success) {
+      showAlert(error?.message || res?.message || "비밀번호 변경에 실패했습니다.", "오류");
+    } else {
+      closePasswordModal();
+      showAlert(res.message || "비밀번호가 변경되었습니다.", "성공");
+    }
+  };
+
+  // 이동
+  const goToList = () => router.push("/site-manager/admin/list");
+  const goToEdit = () => router.push(`/site-manager/admin/edit/${adminId}`);
+
+  // 라벨 / 뱃지
+  const getStatusLabel = (status) => {
+    if (status === "active") return "활성";
+    if (status === "inactive") return "휴면";
+    if (status === "suspended") return "정지";
+    return "-";
+  };
+  const getStatusBadgeClass = (status) => {
+    if (status === "active") return "admin--badge-active";
+    if (status === "inactive") return "admin--badge-ended";
+    if (status === "suspended") return "admin--badge-sus";
+    return "";
+  };
+
+  // 일시 포맷 (24시)
+  const formatDateTime = (dateString) => {
+    if (!dateString) return "-";
+    const date = new Date(dateString.replace(" ", "T"));
+    if (isNaN(date.getTime())) return dateString;
+    return date.toLocaleString("ko-KR", {
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+      hour12: false,
+    });
+  };
+
+  onMounted(() => {
+    loadDetail();
+  });
+</script>

+ 379 - 0
app/pages/site-manager/admin/edit/[id].vue

@@ -0,0 +1,379 @@
+<template>
+  <div class="admin--page-content">
+    <div v-if="isLoading" class="admin--loading">데이터를 불러오는 중...</div>
+    <div v-else class="admin--form">
+      <form @submit.prevent="handleSubmit">
+        <table class="admin--form--table">
+          <colgroup>
+            <col style="width: 140px;">
+            <col>
+          </colgroup>
+          <tbody>
+            <tr>
+              <th><div>아이디</div></th>
+              <td>
+                <div class="input--wrap">
+                  <span class="admin--table-title">{{ formData.username }}</span>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>이름 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input v-model="formData.name" type="text" class="admin--form-input w--280" maxlength="30" />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>핸드폰 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <select v-model="phone1" class="admin--form-select w--120">
+                    <option value="010">010</option>
+                    <option value="011">011</option>
+                    <option value="016">016</option>
+                    <option value="017">017</option>
+                    <option value="018">018</option>
+                    <option value="019">019</option>
+                  </select>
+                  <span class="mx--8">-</span>
+                  <input
+                    v-model="phone2"
+                    type="text"
+                    inputmode="numeric"
+                    class="admin--form-input w--120"
+                    maxlength="4"
+                    @input="onlyDigits('phone2')"
+                  />
+                  <span class="mx--8">-</span>
+                  <input
+                    v-model="phone3"
+                    type="text"
+                    inputmode="numeric"
+                    class="admin--form-input w--120"
+                    maxlength="4"
+                    @input="onlyDigits('phone3')"
+                  />
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>이메일 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <input
+                    v-model="emailLocal"
+                    type="text"
+                    class="admin--form-input w--160"
+                    maxlength="50"
+                    autocomplete="off"
+                  />
+                  <span class="mx--8">@</span>
+                  <input
+                    v-model="emailDomain"
+                    type="text"
+                    class="admin--form-input w--160"
+                    placeholder="domain.com"
+                    maxlength="50"
+                    :readonly="emailDomainSelect !== 'custom'"
+                    autocomplete="off"
+                  />
+                  <select v-model="emailDomainSelect" @change="onDomainChange" class="admin--form-select w--160 ml--8">
+                    <option value="">선택</option>
+                    <option value="naver.com">naver.com</option>
+                    <option value="gmail.com">gmail.com</option>
+                    <option value="daum.net">daum.net</option>
+                    <option value="hanmail.net">hanmail.net</option>
+                    <option value="kakao.com">kakao.com</option>
+                    <option value="nate.com">nate.com</option>
+                    <option value="custom">직접입력</option>
+                  </select>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th><div>권한 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="input--wrap">
+                  <label class="admin--radio-label" :class="{ 'is-disabled': !isSuperAdmin }">
+                    <input type="radio" v-model="formData.role" value="admin" :disabled="!isSuperAdmin" />
+                    관리자
+                  </label>
+                  <label class="admin--radio-label ml--16" :class="{ 'is-disabled': !isSuperAdmin }">
+                    <input type="radio" v-model="formData.role" value="super_admin" :disabled="!isSuperAdmin" />
+                    슈퍼 관리자
+                  </label>
+                </div>
+                <p v-if="!isSuperAdmin" class="mt--10 txt--muted">권한 변경은 슈퍼 관리자만 할 수 있습니다.</p>
+                <p v-else-if="formData.role === 'super_admin'" class="mt--10">슈퍼 관리자는 모든 메뉴에 접근할 수 있습니다.</p>
+              </td>
+            </tr>
+            <tr v-if="formData.role === 'admin'">
+              <th><div>메뉴 권한 <span class="admin--required">*</span></div></th>
+              <td>
+                <div class="admin--permissions-grid" :class="{ 'is-disabled': !isSuperAdmin }">
+                  <label v-for="opt in menuOptions" :key="opt.id" class="admin--checkbox-label" :class="{ 'is-disabled': !isSuperAdmin }">
+                    <input type="checkbox" :value="opt.id" v-model="formData.permissions" :disabled="!isSuperAdmin" />
+                    {{ opt.title }}
+                  </label>
+                </div>
+                <p v-if="!isSuperAdmin" class="mt--10 txt--muted">메뉴 권한 변경은 슈퍼 관리자만 할 수 있습니다.</p>
+                <p v-else class="mt--10">관리자가 접근할 수 있는 메뉴를 선택하세요. 대시보드는 모든 관리자에게 기본 제공됩니다.</p>
+              </td>
+            </tr>
+            <tr v-if="!isMyAccount">
+              <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" value="active" /> 활성
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.status" value="inactive" /> 휴면
+                  </label>
+                  <label class="admin--radio-label ml--16">
+                    <input type="radio" v-model="formData.status" value="suspended" /> 정지
+                  </label>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <!-- 버튼 영역 -->
+        <div class="admin--form-actions">
+          <button type="button" class="admin--btn" @click="goToDetail">
+            ← 취소
+          </button>
+          <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
+            {{ isSaving ? "저장 중..." : "저장" }}
+          </button>
+        </div>
+      </form>
+    </div>
+
+    <!-- 알림 모달 -->
+    <AdminAlertModal
+      v-if="alertModal.show"
+      :title="alertModal.title"
+      :message="alertModal.message"
+      :type="alertModal.type"
+      @confirm="handleAlertConfirm"
+      @cancel="handleAlertCancel"
+      @close="closeAlertModal"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { ref, 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, put } = useApi();
+  const { user, isSuperAdmin } = useAuth();
+
+  const adminId = route.params.id;
+  const isMyAccount = computed(() => Number(user.value?.id) === Number(adminId));
+
+  const isLoading = ref(true);
+  const isSaving = ref(false);
+
+  const formData = ref({
+    username: "",
+    name: "",
+    role: "admin",
+    status: "active",
+    permissions: [],
+  });
+
+  // 메뉴 권한 옵션 (admin.vue menuItems와 동일)
+  const menuOptions = [
+    { id: "admin",     title: "관리자 관리" },
+    { id: "field",     title: "분야 및 지역 관리" },
+    { id: "fishing",   title: "선상 및 낚시터 관리" },
+    { id: "challenge", title: "챌린지 관리" },
+    { id: "quest",     title: "퀘스트 관리" },
+    { id: "item",      title: "아이템 관리" },
+    { id: "species",   title: "어종 관리" },
+    { id: "user",      title: "회원 관리" },
+  ];
+
+  // 이메일 분할
+  const KNOWN_DOMAINS = ["naver.com", "gmail.com", "daum.net", "hanmail.net", "kakao.com", "nate.com"];
+  const emailLocal = ref("");
+  const emailDomain = ref("");
+  const emailDomainSelect = ref("");
+  const onDomainChange = () => {
+    if (emailDomainSelect.value === "custom") {
+      emailDomain.value = "";
+    } else if (emailDomainSelect.value !== "") {
+      emailDomain.value = emailDomainSelect.value;
+    }
+  };
+
+  // 핸드폰 분할
+  const phone1 = ref("010");
+  const phone2 = ref("");
+  const phone3 = ref("");
+  const onlyDigits = (key) => {
+    if (key === "phone2") phone2.value = phone2.value.replace(/\D/g, "");
+    else if (key === "phone3") phone3.value = phone3.value.replace(/\D/g, "");
+  };
+
+  // 알림 모달
+  const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
+  const showAlert = (message, title = "알림") => {
+    alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
+  };
+  const closeAlertModal = () => { alertModal.value.show = false; };
+  const handleAlertConfirm = () => {
+    if (alertModal.value.onConfirm) alertModal.value.onConfirm();
+    closeAlertModal();
+  };
+  const handleAlertCancel = () => closeAlertModal();
+
+  // 상세 조회 + 초기화
+  const loadDetail = async () => {
+    isLoading.value = true;
+    const { data: res, error } = await get(`/admin/${adminId}`);
+    if (error || !res?.success) {
+      showAlert(error?.message || res?.message || "조회에 실패했습니다.", "오류");
+      isLoading.value = false;
+      return;
+    }
+    const row = res.data || {};
+
+    // 일반 admin이 슈퍼관리자 수정 진입 시 차단
+    if (row.role === "super_admin" && !isSuperAdmin.value) {
+      isLoading.value = false;
+      alertModal.value = {
+        show: true,
+        title: "접근 불가",
+        message: "슈퍼 관리자 계정은 슈퍼 관리자만 수정할 수 있습니다.",
+        type: "alert",
+        onConfirm: () => router.push(`/site-manager/admin/detail/${adminId}`),
+      };
+      setTimeout(() => router.push(`/site-manager/admin/detail/${adminId}`), 1200);
+      return;
+    }
+
+    formData.value = {
+      username: row.username ?? "",
+      name: row.name ?? "",
+      role: row.role ?? "admin",
+      status: row.status ?? "active",
+      // super_admin은 "all" 문자열이 올 수 있어 배열로 통일
+      permissions: Array.isArray(row.permissions) ? [...row.permissions] : [],
+    };
+
+    // 이메일 분할
+    const email = row.email ?? "";
+    const at = email.lastIndexOf("@");
+    if (at > 0) {
+      emailLocal.value = email.slice(0, at);
+      emailDomain.value = email.slice(at + 1);
+      emailDomainSelect.value = KNOWN_DOMAINS.includes(emailDomain.value) ? emailDomain.value : "custom";
+    } else {
+      emailLocal.value = "";
+      emailDomain.value = "";
+      emailDomainSelect.value = "";
+    }
+
+    // 핸드폰 분할 — "010-1234-5678" / "01012345678" / "010 1234 5678" 모두 처리
+    const phoneRaw = String(row.phone ?? "").replace(/\D/g, "");
+    if (phoneRaw.length >= 9) {
+      if (phoneRaw.length === 11) {
+        phone1.value = phoneRaw.slice(0, 3);
+        phone2.value = phoneRaw.slice(3, 7);
+        phone3.value = phoneRaw.slice(7, 11);
+      } else if (phoneRaw.length === 10) {
+        phone1.value = phoneRaw.slice(0, 3);
+        phone2.value = phoneRaw.slice(3, 6);
+        phone3.value = phoneRaw.slice(6, 10);
+      }
+    } else {
+      phone1.value = "010";
+      phone2.value = "";
+      phone3.value = "";
+    }
+
+    isLoading.value = false;
+  };
+
+  // 폼 검증
+  const validate = () => {
+    const f = formData.value;
+    const name = f.name.trim();
+    if (!name) return "이름을 입력하세요.";
+    if (name.length > 30) return "이름은 30자 이내";
+
+    const local = emailLocal.value.trim();
+    const domain = emailDomain.value.trim();
+    if (!local) return "이메일 아이디를 입력하세요.";
+    if (!domain) return "이메일 도메인을 입력하거나 선택하세요.";
+    const email = `${local}@${domain}`;
+    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "이메일 형식이 올바르지 않습니다.";
+
+    if (!phone1.value) return "핸드폰 앞자리를 선택하세요.";
+    if (!/^\d{3,4}$/.test(phone2.value)) return "핸드폰 가운데 자리(3~4자리 숫자)를 입력하세요.";
+    if (!/^\d{4}$/.test(phone3.value)) return "핸드폰 끝자리(4자리 숫자)를 입력하세요.";
+
+    if (!["super_admin", "admin"].includes(f.role)) return "권한을 선택하세요.";
+    if (!["active", "inactive", "suspended"].includes(f.status)) return "상태를 선택하세요.";
+
+    if (f.role === "admin" && f.permissions.length === 0) {
+      return "관리자에게 부여할 메뉴 권한을 1개 이상 선택하세요.";
+    }
+
+    return null;
+  };
+
+  // 폼 제출
+  const handleSubmit = async () => {
+    const err = validate();
+    if (err) {
+      showAlert(err, "입력 오류");
+      return;
+    }
+
+    isSaving.value = true;
+    const f = formData.value;
+    const payload = {
+      name: f.name.trim(),
+      email: `${emailLocal.value.trim()}@${emailDomain.value.trim()}`,
+      phone: `${phone1.value}-${phone2.value}-${phone3.value}`,
+      status: f.status,
+    };
+    // role/permissions는 슈퍼관리자만 전송 (백엔드 가드와 매칭)
+    if (isSuperAdmin.value) {
+      payload.role = f.role;
+      payload.permissions = f.role === "admin" ? f.permissions : [];
+    }
+
+    const { data, error } = await put(`/admin/${adminId}`, payload);
+    isSaving.value = false;
+
+    if (error || !data?.success) {
+      showAlert(error?.message || data?.message || "수정에 실패했습니다.", "오류");
+      return;
+    }
+
+    showAlert(data.message || "수정되었습니다.", "성공");
+    setTimeout(() => router.push(`/site-manager/admin/detail/${adminId}`), 800);
+  };
+
+  const goToDetail = () => router.push(`/site-manager/admin/detail/${adminId}`);
+
+  onMounted(() => {
+    loadDetail();
+  });
+</script>

+ 496 - 154
app/pages/site-manager/admin/list.vue

@@ -1,40 +1,94 @@
 <template>
   <div class="admin--field-list">
     <!-- 상단 검색/액션 영역 -->
-    <div class="admin--search-box">
-      <div class="admin--search-form">
-        <select v-model="searchField" class="admin--form-select admin--search-select">
-          <option value="">전체</option>
-          <option value="username">아이디</option>
-          <option value="name">이름</option>
-          <option value="email">이메일</option>
-        </select>
-        <input
-          v-model="searchQuery"
-          type="text"
-          placeholder="검색어 입력"
-          @keyup.enter="onSearch"
-          class="admin--form-input admin--search-input"
-        />
-        <select v-model="filterPartnership" @change="onSearch" class="admin--form-select admin--search-select">
-          <option value="">전체</option>
-          <option value="N">슈퍼 관리자</option>
-          <option value="N">관리자 관리</option>
-          <option value="Y">아이템 관리</option>
-          <option value="Y">퀘스트 관리</option>
-          <option value="Y">챌린지 관리</option>
-        </select>
-        <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
-          <option value="">전체</option>
-          <option value="Y">활성</option>
-          <option value="N">휴면</option>
-          <option value="N">정지</option>
-        </select>
-        <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
-        <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
+    <div class="admin--search-box type2">
+      <div class="admin--search--inner--box">
+        <div class="admin--search-form">
+          <select v-model="searchField" class="admin--form-select admin--search-select">
+            <option value="">전체</option>
+            <option value="username">아이디</option>
+            <option value="name">이름</option>
+            <option value="email">이메일</option>
+          </select>
+          <input
+            v-model="searchQuery"
+            type="text"
+            placeholder="검색어 입력"
+            @keyup.enter="onSearch"
+            class="admin--form-input admin--search-input"
+          />
+          <select v-model="filterRole" @change="onSearch" class="admin--form-select admin--search-select">
+            <option value="">전체 권한</option>
+            <option value="super_admin">슈퍼 관리자</option>
+            <option value="admin">관리자</option>
+          </select>
+          <select v-model="filterStatus" @change="onSearch" class="admin--form-select admin--search-select">
+            <option value="">전체 상태</option>
+            <option value="active">활성</option>
+            <option value="inactive">휴면</option>
+            <option value="suspended">정지</option>
+          </select>
+          <button @click="onSearch" class="admin--btn-small admin--btn-small-primary">검색</button>
+          <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">초기화</button>
+        </div>
       </div>
-      <div class="admin--search-actions">
-        <button class="admin--btn-add" @click="goToCreate">+ 관리자 추가</button>
+      <div class="admin--search--inner--box">
+        <button
+          v-if="isSuperAdmin"
+          :class="[viewMode=== 'active' ? 'admin--btn-small admin--btn-small-primary' : 'admin--btn-small admin--btn-small-secondary']"
+          @click="toggleViewMode"
+        >
+          {{ viewMode === 'active' ? '삭제 관리자 관리' : '← 목록으로' }}
+        </button>
+        <div class="admin--search-actions">
+          <button
+            v-if="viewMode === 'active'"
+            class="admin--btn-small admin--btn-small-excel"
+            :disabled="isExporting"
+            @click="handleExportCsv"
+          >
+            {{ isExporting ? "내보내는 중..." : "CSV 내보내기" }}<span></span>
+          </button>
+
+          <!-- 활성 모드 액션 -->
+          <template v-if="viewMode === 'active'">
+            <button
+              class="admin--btn-small admin--btn-small-secondary"
+              :disabled="selectedIds.length === 0 || isProcessing"
+              @click="bulkDelete"
+            >선택 삭제</button>
+            <button
+              class="admin--btn-small admin--btn-small-secondary"
+              :disabled="selectedIds.length === 0 || isProcessing"
+              @click="bulkSetStatus('active')"
+            >선택 활성</button>
+            <button
+              class="admin--btn-small admin--btn-small-secondary"
+              :disabled="selectedIds.length === 0 || isProcessing"
+              @click="bulkSetStatus('inactive')"
+            >선택 휴면</button>
+            <button
+              class="admin--btn-small admin--btn-small-secondary"
+              :disabled="selectedIds.length === 0 || isProcessing"
+              @click="bulkSetStatus('suspended')"
+            >선택 정지</button>
+            <button class="admin--btn-add" @click="goToCreate">+ 관리자 추가</button>
+          </template>
+
+          <!-- 삭제 모드 액션 -->
+          <template v-else>
+            <button
+              class="admin--btn-small admin--btn-small-secondary"
+              :disabled="selectedIds.length === 0 || isProcessing"
+              @click="bulkRestore"
+            >선택 복구</button>
+            <button
+              class="admin--btn-add"
+              :disabled="selectedIds.length === 0 || isProcessing"
+              @click="bulkHardDelete"
+            >선택 영구 삭제</button>
+          </template>
+        </div>
       </div>
     </div>
 
@@ -43,51 +97,88 @@
       <table class="admin--table">
         <thead>
           <tr>
-            <th style="width: 80px;"></th>
+            <th style="width: 48px;">
+              <div class="input--wrap">
+                <input
+                  type="checkbox"
+                  :checked="isAllSelected"
+                  :indeterminate.prop="isPartialSelected"
+                  :disabled="selectableAdmins.length === 0"
+                  @change="toggleAll($event.target.checked)"
+                  aria-label="전체 선택"
+                />
+              </div>
+            </th>
             <th style="width: 140px;">아이디</th>
             <th style="width: 140px;">이름</th>
             <th>핸드폰</th>
-            <th style="width: 100px;">이메일</th>
-            <th style="width: 100px;">권한</th>
-            <th style="width: 120px;">최근 로그인</th>
-            <th>상태</th>
+            <th>이메일</th>
+            <th style="width: 200px;">권한</th>
+            <th style="width: 140px;">최근 로그인</th>
+            <th style="width: 100px;">상태</th>
             <th style="width: 120px;">관리</th>
           </tr>
         </thead>
         <tbody>
           <tr v-if="isLoading">
-            <td colspan="8" class="admin--table-loading">데이터를 불러오는 중...</td>
+            <td colspan="9" class="admin--table-loading">데이터를 불러오는 중...</td>
           </tr>
-          <tr v-else-if="!onboards || onboards.length === 0">
-            <td colspan="8" class="admin--table-empty">등록된 관리자가 없습니다.</td>
+          <tr v-else-if="!admins || admins.length === 0">
+            <td colspan="9" class="admin--table-empty">
+              {{ viewMode === 'deleted' ? '삭제된 관리자가 없습니다.' : '등록된 관리자가 없습니다.' }}
+            </td>
           </tr>
           <tr
             v-else
-            v-for="(item, index) in onboards"
+            v-for="item in admins"
             :key="item.id"
-            class="admin--table-row-clickable"
-            @click="goToDetail(item.id)"
+            :class="viewMode === 'active' ? 'admin--table-row-clickable' : ''"
+            @click="viewMode === 'active' && 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>
-              <span :class="['admin--badge', item.partnership_YN === 'Y' ? 'admin--badge-active' : 'admin--badge-ended']">
-                {{ item.partnership_YN === "Y" ? "제휴" : "비제휴" }}
+            <td @click.stop>
+              <div class="input--wrap">
+                <input
+                  type="checkbox"
+                  :value="item.id"
+                  v-model="selectedIds"
+                  :disabled="!canSelect(item)"
+                  :title="!canSelect(item) ? '슈퍼 관리자 또는 본인 계정은 선택할 수 없습니다.' : ''"
+                />
+              </div>
+            </td>
+            <td class="admin--table-title">{{ item.username }}</td>
+            <td>{{ item.name || "-" }}</td>
+            <td>{{ item.phone || "-" }}</td>
+            <td>{{ item.email || "-" }}</td>
+            <td class="left">
+              <span v-if="item.role === 'super_admin'" class="admin--badge admin--badge-super">
+                슈퍼 관리자
               </span>
+              <div v-else class="admin--perm-list">
+                <span v-for="p in visiblePerms(item)" :key="p" class="admin--badge admin--badge-perm">
+                  {{ permLabel(p) }}
+                </span>
+                <span v-if="extraPermCount(item) > 0" class="admin--badge admin--badge-more">
+                  +{{ extraPermCount(item) }}
+                </span>
+                <span v-if="!visiblePerms(item).length" class="admin--badge admin--badge-ended">
+                  권한 없음
+                </span>
+              </div>
             </td>
-            <td>
-              <span :class="['admin--badge', getStatusBadgeClass(item.status_YN)]">
-                {{ getStatusLabel(item.status_YN) }}
+            <td class="date">{{ formatDateTime(item.last_login) }}</td>
+            <td cl>
+              <span :class="['admin--badge', getStatusBadgeClass(item.status)]">
+                {{ getStatusLabel(item.status) }}
               </span>
             </td>
-            <td class="date">{{ formatDate(item.created_at) }}</td>
             <td>
               <div class="admin--table-actions">
-                <button class="admin--btn-small admin--btn-blue" @click.stop="goToEdit(item.id)">
-                  수정
-                </button>
+                <button
+                  v-if="viewMode === 'active' && canModify(item)"
+                  class="admin--btn-small admin--btn-blue"
+                  @click.stop="goToEdit(item.id)"
+                >수정</button>
               </div>
             </td>
           </tr>
@@ -103,51 +194,52 @@
         :disabled="currentPage === 1"
         @click="changePage(1)"
         title="처음"
-      >
-        ◀◀
-      </button>
+      >◀◀</button>
       <button
         class="admin--pagination-btn"
         :disabled="currentPage === 1"
         @click="changePage(currentPage - 1)"
         title="이전"
-      >
-        ◀
-      </button>
+      >◀</button>
       <button
         v-for="page in visiblePages"
         :key="page"
         class="admin--pagination-btn"
         :class="{ 'is-active': page === currentPage }"
         @click="changePage(page)"
-      >
-        {{ page }}
-      </button>
+      >{{ page }}</button>
       <button
         class="admin--pagination-btn"
         :disabled="currentPage === totalPages"
         @click="changePage(currentPage + 1)"
         title="다음"
-      >
-        ▶
-      </button>
+      >▶</button>
       <button
         v-if="totalPages > 2"
         class="admin--pagination-btn"
         :disabled="currentPage === totalPages"
         @click="changePage(totalPages)"
         title="끝"
-      >
-        ▶▶
-      </button>
+      >▶▶</button>
     </div>
+
+    <!-- 알림 모달 -->
+    <AdminAlertModal
+      v-if="alertModal.show"
+      :title="alertModal.title"
+      :message="alertModal.message"
+      :type="alertModal.type"
+      @confirm="handleAlertConfirm"
+      @cancel="handleAlertCancel"
+      @close="closeAlertModal"
+    />
   </div>
 </template>
 
 <script setup>
   import { ref, computed, onMounted } from "vue";
   import { useRouter } from "vue-router";
-  import DatePicker from "~/components/admin/DatePicker.vue";
+  import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
 
   definePageMeta({
     layout: "admin",
@@ -155,157 +247,407 @@
   });
 
   const router = useRouter();
-  const { get } = useApi();
+  const { get, post, put, del } = useApi();
+  const { user, isSuperAdmin } = useAuth();
+
+  // 일반 admin은 슈퍼관리자 행 수정 불가
+  const canModify = (item) => isSuperAdmin.value || item.role !== "super_admin";
+  // 선택 가능 여부 — 모드별 분기
+  const canSelect = (item) => {
+    const isMe = Number(item.id) === Number(user.value?.id);
+    if (isMe) return false;
+    // 삭제된 관리자 관리 모드: 슈퍼관리자가 영구삭제/복구 모두 가능
+    if (viewMode.value === "deleted") return true;
+    // 활성 모드: 슈퍼관리자 대상은 제외
+    return canModify(item);
+  };
+
+  // 권한 라벨 매핑 (admin.vue menuItems와 동일)
+  const PERM_LABELS = {
+    admin:     "관리자",
+    field:     "분야/지역",
+    fishing:   "선상/낚시터",
+    challenge: "챌린지",
+    quest:     "퀘스트",
+    item:      "아이템",
+    species:   "어종",
+    user:      "회원",
+  };
+  const MAX_VISIBLE_PERMS = 2;
+  const permLabel = (id) => PERM_LABELS[id] || id;
+  const permsArray = (item) =>
+    Array.isArray(item.permissions) ? item.permissions : [];
+  const visiblePerms = (item) => permsArray(item).slice(0, MAX_VISIBLE_PERMS);
+  const extraPermCount = (item) =>
+    Math.max(0, permsArray(item).length - MAX_VISIBLE_PERMS);
 
   const isLoading = ref(false);
-  const onboards = ref([]);
+  const isProcessing = ref(false);
+  const isExporting = ref(false);
+
+  // 보기 모드 — 'active' (정상 목록) | 'deleted' (삭제된 관리자 관리)
+  const viewMode = ref("active");
+  const toggleViewMode = () => {
+    viewMode.value = viewMode.value === "active" ? "deleted" : "active";
+    selectedIds.value = [];
+    currentPage.value = 1;
+    loadAdmins();
+  };
+  const admins = ref([]);
   const currentPage = ref(1);
   const perPage = ref(10);
   const totalCount = ref(0);
   const totalPages = ref(0);
 
-  const searchField = ref("");      // '', field, area, name
+  const searchField = ref("");     // '' | username | name | email
   const searchQuery = ref("");
-  const filterPartnership = ref(""); // '', Y, N
-  const filterStatus = ref("");      // '', Y, N
-  const startDate = ref("");         // YYYY-MM-DD
-  const endDate = ref("");           // YYYY-MM-DD
-
-  // YYYY-MM-DD 포맷터
-  const toYMD = (d) => {
-    const y = d.getFullYear();
-    const m = String(d.getMonth() + 1).padStart(2, "0");
-    const day = String(d.getDate()).padStart(2, "0");
-    return `${y}-${m}-${day}`;
-  };
+  const filterRole = ref("");      // '' | super_admin | admin
+  const filterStatus = ref("");    // '' | active | inactive | suspended
 
-  // 빠른 기간 선택 (오늘 기준)
-  const setRange = (kind) => {
-    const today = new Date();
-    const end = toYMD(today);
-    const startDt = new Date();
-    switch (kind) {
-      case "today":
-        break;
-      case "7d":
-        startDt.setDate(startDt.getDate() - 7);
-        break;
-      case "15d":
-        startDt.setDate(startDt.getDate() - 15);
-        break;
-      case "1m":
-        startDt.setMonth(startDt.getMonth() - 1);
-        break;
-      case "3m":
-        startDt.setMonth(startDt.getMonth() - 3);
-        break;
-      case "1y":
-        startDt.setFullYear(startDt.getFullYear() - 1);
-        break;
+  // 체크박스 선택 — 선택 가능한 행 기준
+  const selectedIds = ref([]);
+  const selectableAdmins = computed(() => admins.value.filter((it) => canSelect(it)));
+  const isAllSelected = computed(
+    () => selectableAdmins.value.length > 0 && selectableAdmins.value.every((it) => selectedIds.value.includes(it.id))
+  );
+  const isPartialSelected = computed(
+    () => selectedIds.value.length > 0 && !isAllSelected.value
+  );
+  const toggleAll = (checked) => {
+    if (checked) {
+      const ids = selectableAdmins.value.map((it) => it.id);
+      selectedIds.value = Array.from(new Set([...selectedIds.value, ...ids]));
+    } else {
+      const ids = new Set(selectableAdmins.value.map((it) => it.id));
+      selectedIds.value = selectedIds.value.filter((id) => !ids.has(id));
     }
-    startDate.value = toYMD(startDt);
-    endDate.value = end;
-    onSearch();
   };
 
-  // 보이는 페이지 번호 계산
+  // 보이는 페이지 번호
   const visiblePages = computed(() => {
     const pages = [];
     const maxVisible = 5;
     let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2));
     let end = Math.min(totalPages.value, start + maxVisible - 1);
-
-    if (end - start < maxVisible - 1) {
-      start = Math.max(1, end - maxVisible + 1);
-    }
-    for (let i = start; i <= end; i++) {
-      pages.push(i);
-    }
+    if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
+    for (let i = start; i <= end; i++) pages.push(i);
     return pages;
   });
 
   // 데이터 로드
-  const loadOnboards = async () => {
+  const loadAdmins = async () => {
     isLoading.value = true;
-
     const params = {
       page: currentPage.value,
       per_page: perPage.value,
     };
+    if (viewMode.value === "deleted") params.deleted = 1;
     if (searchQuery.value) {
       params.search = searchQuery.value;
       if (searchField.value) params.search_field = searchField.value;
     }
-    if (filterPartnership.value) params.partnership = filterPartnership.value;
+    if (filterRole.value) params.role = filterRole.value;
     if (filterStatus.value) params.status = filterStatus.value;
-    if (startDate.value) params.start_date = startDate.value;
-    if (endDate.value) params.end_date = endDate.value;
 
     const { data, error } = await get("/admin", { params });
-
     if (error) {
-      console.error("[OnboardList] 목록 로드 실패:", error);
-      onboards.value = [];
+      console.error("[AdminList] 목록 로드 실패:", error);
+      admins.value = [];
       totalCount.value = 0;
       totalPages.value = 0;
     } else if (data?.success && data?.data) {
-      onboards.value = data.data.items || [];
+      admins.value = data.data.items || [];
       totalCount.value = data.data.total || 0;
       totalPages.value = data.data.total_pages || 0;
     }
-
     isLoading.value = false;
   };
 
-  // 검색
   const onSearch = () => {
     currentPage.value = 1;
-    loadOnboards();
+    loadAdmins();
   };
 
-  // 검색 초기화
   const resetSearch = () => {
     searchField.value = "";
     searchQuery.value = "";
-    filterPartnership.value = "";
+    filterRole.value = "";
     filterStatus.value = "";
-    startDate.value = "";
-    endDate.value = "";
     currentPage.value = 1;
-    loadOnboards();
+    loadAdmins();
   };
 
-  // 페이지 변경
   const changePage = (page) => {
     if (page < 1 || page > totalPages.value) return;
     currentPage.value = page;
-    loadOnboards();
+    loadAdmins();
     window.scrollTo({ top: 0, behavior: "smooth" });
   };
 
   // 이동
-  const goToCreate = () => router.push("/site-manager/onboard/create");
-  const goToDetail = (id) => router.push(`/site-manager/onboard/detail/${id}`);
-  const goToEdit = (id) => router.push(`/site-manager/onboard/edit/${id}`);
+  const goToCreate = () => router.push("/site-manager/admin/create");
+  const goToDetail = (id) => router.push(`/site-manager/admin/detail/${id}`);
+  const goToEdit = (id) => router.push(`/site-manager/admin/edit/${id}`);
 
-  // 상태 라벨 / 뱃지 클래스
-  const getStatusLabel = (status) => (status === "Y" ? "사용중" : "미사용");
-  const getStatusBadgeClass = (status) =>
-    status === "Y" ? "admin--badge-active" : "admin--badge-ended";
+  // 라벨 / 뱃지
+  const getRoleLabel = (role) => {
+    if (role === "super_admin") return "슈퍼 관리자";
+    if (role === "admin") return "관리자";
+    return "-";
+  };
+  const getStatusLabel = (status) => {
+    if (status === "active") return "활성";
+    if (status === "inactive") return "휴면";
+    if (status === "suspended") return "정지";
+    return "-";
+  };
+  const getStatusBadgeClass = (status) => {
+    if (status === "active") return "admin--badge-active";
+    if (status === "inactive") return "admin--badge-ended";
+    if (status === "suspended") return "admin--badge-sus";
+    return "";
+  };
 
-  // 날짜 포맷
-  const formatDate = (dateString) => {
+  // 일시 포맷 (24시 표기)
+  const formatDateTime = (dateString) => {
     if (!dateString) return "-";
     const date = new Date(dateString.replace(" ", "T"));
     if (isNaN(date.getTime())) return dateString;
-    return date.toLocaleDateString("ko-KR", {
+    return date.toLocaleString("ko-KR", {
       year: "numeric",
       month: "2-digit",
       day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+      hour12: false,
     });
   };
 
+  // 알림 모달
+  const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
+  const showAlert = (message, title = "알림") => {
+    alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
+  };
+  const showConfirm = (message, onConfirm, title = "확인") => {
+    alertModal.value = { show: true, title, message, type: "confirm", onConfirm };
+  };
+  const closeAlertModal = () => { alertModal.value.show = false; };
+  const handleAlertConfirm = () => {
+    if (alertModal.value.onConfirm) alertModal.value.onConfirm();
+    closeAlertModal();
+  };
+  const handleAlertCancel = () => closeAlertModal();
+
+  // 선택 삭제
+  const bulkDelete = () => {
+    if (selectedIds.value.length === 0) return;
+    showConfirm(
+      `선택한 ${selectedIds.value.length}명의 관리자를 삭제하시겠습니까?`,
+      async () => {
+        isProcessing.value = true;
+        const ids = [...selectedIds.value];
+        let success = 0;
+        let failMessages = [];
+        for (const id of ids) {
+          const { data, error } = await del(`/admin/${id}`);
+          if (error || !data?.success) {
+            failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
+          } else {
+            success++;
+          }
+        }
+        isProcessing.value = false;
+        selectedIds.value = [];
+        await loadAdmins();
+
+        if (failMessages.length > 0) {
+          showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
+        } else {
+          showAlert(`${success}건 삭제되었습니다.`, "성공");
+        }
+      },
+      "선택 삭제"
+    );
+  };
+
+  // 선택 휴면/정지 — status 일괄 변경
+  const bulkSetStatus = (status) => {
+    if (selectedIds.value.length === 0) return;
+    const label = status === "active" ? "활성" : status === "inactive" ? "휴면" : status === "suspended" ? "정지" : status;
+    showConfirm(
+      `선택한 ${selectedIds.value.length}명을 '${label}' 상태로 변경하시겠습니까?`,
+      async () => {
+        isProcessing.value = true;
+        const ids = [...selectedIds.value];
+        let success = 0;
+        let failMessages = [];
+        for (const id of ids) {
+          const { data, error } = await put(`/admin/${id}`, { status });
+          if (error || !data?.success) {
+            failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
+          } else {
+            success++;
+          }
+        }
+        isProcessing.value = false;
+        selectedIds.value = [];
+        await loadAdmins();
+
+        if (failMessages.length > 0) {
+          showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
+        } else {
+          showAlert(`${success}명을 ${label} 상태로 변경했습니다.`, "성공");
+        }
+      },
+      `선택 ${label}`
+    );
+  };
+
+  // 선택 복구 — 삭제된 관리자만
+  const bulkRestore = () => {
+    if (selectedIds.value.length === 0) return;
+    showConfirm(
+      `선택한 ${selectedIds.value.length}명의 관리자를 복구하시겠습니까?`,
+      async () => {
+        isProcessing.value = true;
+        const ids = [...selectedIds.value];
+        let success = 0;
+        let failMessages = [];
+        for (const id of ids) {
+          const { data, error } = await post(`/admin/${id}/restore`, {});
+          if (error || !data?.success) {
+            failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
+          } else {
+            success++;
+          }
+        }
+        isProcessing.value = false;
+        selectedIds.value = [];
+        await loadAdmins();
+
+        if (failMessages.length > 0) {
+          showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
+        } else {
+          showAlert(`${success}명이 복구되었습니다.`, "성공");
+        }
+      },
+      "선택 복구"
+    );
+  };
+
+  // 선택 영구 삭제 — 되돌릴 수 없음
+  const bulkHardDelete = () => {
+    if (selectedIds.value.length === 0) return;
+    showConfirm(
+      `⚠️ 선택한 ${selectedIds.value.length}명의 관리자를 영구 삭제합니다.\n\n이 작업은 절대 되돌릴 수 없으며, 권한 정보와 토큰까지 모두 삭제됩니다.\n\n정말 진행하시겠습니까?`,
+      async () => {
+        isProcessing.value = true;
+        const ids = [...selectedIds.value];
+        let success = 0;
+        let failMessages = [];
+        for (const id of ids) {
+          const { data, error } = await del(`/admin/${id}/hard`);
+          if (error || !data?.success) {
+            failMessages.push(`ID ${id}: ${error?.message || data?.message || "실패"}`);
+          } else {
+            success++;
+          }
+        }
+        isProcessing.value = false;
+        selectedIds.value = [];
+        await loadAdmins();
+
+        if (failMessages.length > 0) {
+          showAlert(`${success}건 성공 / ${failMessages.length}건 실패\n\n${failMessages.join("\n")}`, "결과");
+        } else {
+          showAlert(`${success}명이 영구 삭제되었습니다.`, "성공");
+        }
+      },
+      "영구 삭제"
+    );
+  };
+
+  // CSV 내보내기 — 선택된 게 있으면 그것만, 없으면 전체 (현재 필터 반영)
+  const csvEscape = (val) => {
+    const s = String(val ?? "");
+    if (s.includes(",") || s.includes("\"") || s.includes("\n") || s.includes("\r")) {
+      return `"${s.replace(/"/g, '""')}"`;
+    }
+    return s;
+  };
+
+  const fileTimestamp = () => {
+    const d = new Date();
+    const p = (n) => String(n).padStart(2, "0");
+    return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`;
+  };
+
+  const handleExportCsv = async () => {
+    isExporting.value = true;
+    try {
+      let rows = [];
+      if (selectedIds.value.length > 0) {
+        // 선택된 것만 — 현재 페이지의 데이터에서 필터
+        rows = admins.value.filter((a) => selectedIds.value.includes(a.id));
+      } else {
+        // 전체 — 현재 필터 그대로 + per_page 크게 호출
+        const params = { page: 1, per_page: 10000 };
+        if (searchQuery.value) {
+          params.search = searchQuery.value;
+          if (searchField.value) params.search_field = searchField.value;
+        }
+        if (filterRole.value) params.role = filterRole.value;
+        if (filterStatus.value) params.status = filterStatus.value;
+
+        const { data, error } = await get("/admin", { params });
+        if (error || !data?.success) {
+          showAlert(error?.message || data?.message || "데이터를 가져오지 못했습니다.", "오류");
+          return;
+        }
+        rows = data.data.items || [];
+      }
+
+      if (rows.length === 0) {
+        showAlert("내보낼 데이터가 없습니다.", "알림");
+        return;
+      }
+
+      const headers = ["번호", "아이디", "이름", "핸드폰", "이메일", "권한", "최근 로그인", "상태"];
+      const lines = [headers.map(csvEscape).join(",")];
+      rows.forEach((row, index) => {
+        const cells = [
+          index + 1,
+          row.username || "",
+          row.name || "",
+          row.phone || "",
+          row.email || "",
+          getRoleLabel(row.role),
+          formatDateTime(row.last_login),
+          getStatusLabel(row.status),
+        ].map(csvEscape);
+        lines.push(cells.join(","));
+      });
+
+      // UTF-8 BOM (Excel 한글 깨짐 방지) + 줄바꿈
+      const csv = "" + lines.join("\r\n");
+      const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = `관리자_목록_${fileTimestamp()}.csv`;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+      URL.revokeObjectURL(url);
+    } catch (e) {
+      console.error("[CSV Export] 실패:", e);
+      showAlert("CSV 내보내기 중 오류가 발생했습니다.", "오류");
+    } finally {
+      isExporting.value = false;
+    }
+  };
+
   onMounted(() => {
-    loadOnboards();
+    loadAdmins();
   });
-</script>
+</script>

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

@@ -28,6 +28,8 @@ $routes->put('api/admin/(:num)', 'Api\AdminController::update/$1');
 $routes->delete('api/admin/(:num)', 'Api\AdminController::delete/$1');
 $routes->post('api/admin/(:num)/password', 'Api\AdminController::changePassword/$1');
 $routes->post('api/admin/(:num)/unlock', 'Api\AdminController::unlockAccount/$1');
+$routes->post('api/admin/(:num)/restore', 'Api\AdminController::restore/$1');
+$routes->delete('api/admin/(:num)/hard', 'Api\AdminController::hardDelete/$1');
 
 // Fishing Field (낚시분야)
 $routes->get('api/field/list', 'Api\FishingFieldController::index');

+ 387 - 109
backend/app/Controllers/Api/AdminController.php

@@ -8,8 +8,77 @@ class AdminController extends BaseApiController
 {
     protected $format = 'json';
 
+    private const ALLOWED_ROLES = ['super_admin', 'admin'];
+    private const ALLOWED_STATUSES = ['active', 'inactive', 'suspended'];
+
+    // 허용 메뉴 권한 (admin.vue의 menuItems id와 동일)
+    private const ALLOWED_PERMISSIONS = [
+        'admin', 'field', 'fishing', 'challenge', 'quest', 'item', 'species', 'user',
+    ];
+
+    /**
+     * 호출한 관리자가 슈퍼관리자인지 확인
+     */
+    private function isCallerSuperAdmin($authData): bool
+    {
+        $admin = $this->getDB()->table('admin_users')
+            ->select('role')
+            ->where('id', (int) $authData->admin_id)
+            ->get()->getRow();
+        return $admin && $admin->role === 'super_admin';
+    }
+
+    // TODO: 권한관리 시스템 구축 후, '관리자관리 권한'을 가진 admin만
+    // create/update/delete/changePassword/unlockAccount 가능하도록 가드 재추가
+
+    /**
+     * 관리자의 메뉴 권한 동기화 (DELETE + INSERT)
+     *  - super_admin은 row 박지 않음 (role 자체가 모든 권한)
+     *  - admin은 검증된 배열만 INSERT
+     */
+    private function syncPermissions(int $adminId, string $role, $permissions): void
+    {
+        $db = $this->getDB();
+
+        // 기존 권한 전부 제거
+        $db->table('admin_permissions')->where('admin_id', $adminId)->delete();
+
+        if ($role !== 'admin') return;
+        if (!is_array($permissions) || empty($permissions)) return;
+
+        // 허용 목록만 INSERT (중복 제거)
+        $clean = array_values(array_unique(array_filter(
+            $permissions,
+            fn($p) => is_string($p) && in_array($p, self::ALLOWED_PERMISSIONS, true)
+        )));
+        if (empty($clean)) return;
+
+        $rows = array_map(fn($p) => [
+            'admin_id'   => $adminId,
+            'permission' => $p,
+            'created_at' => date('Y-m-d H:i:s'),
+        ], $clean);
+        $db->table('admin_permissions')->insertBatch($rows);
+    }
+
+    /**
+     * 관리자의 메뉴 권한 배열 반환
+     *  - super_admin은 'all' 반환
+     *  - admin은 ['field', 'fishing', ...] 형태
+     */
+    private function getPermissions(int $adminId, string $role)
+    {
+        if ($role === 'super_admin') return 'all';
+
+        $rows = $this->getDB()->table('admin_permissions')
+            ->select('permission')
+            ->where('admin_id', $adminId)
+            ->get()->getResult();
+        return array_map(fn($r) => $r->permission, $rows);
+    }
+
     /**
-     * Get all admins (관리자 목록)
+     * Get all admins (관리자 목록) — 인증된 모두 가능
      * GET /api/admin
      */
     public function index()
@@ -20,60 +89,84 @@ class AdminController extends BaseApiController
         }
 
         try {
-            $page = $this->request->getGet('page') ?? 1;
-            $perPage = $this->request->getGet('per_page') ?? 10;
+            $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;
 
             $db = $this->getDB();
             $builder = $db->table('admin_users');
 
-            // 'admin' 계정 제외
-            $builder->where('username !=', 'admin');
-
-            // 검색 기능 - 아이디, 이름만 LIKE 검색
-            $search = $this->request->getGet('search');
-            if (!empty($search)) {
-                $builder->groupStart()
-                    ->like('username', $search)
-                    ->orLike('name', $search)
-                    ->groupEnd();
+            // 삭제된 계정만 / 활성만 분기 (deleted=1이면 삭제된 것만)
+            $showDeleted = $this->request->getGet('deleted') === '1';
+            $builder->where('deleted_YN', $showDeleted ? 'Y' : 'N');
+
+            // 검색
+            $search = trim((string) $this->request->getGet('search'));
+            $searchField = $this->request->getGet('search_field'); // username / name / email / ''
+            if ($search !== '') {
+                if ($searchField === 'username') {
+                    $builder->like('username', $search);
+                } elseif ($searchField === 'name') {
+                    $builder->like('name', $search);
+                } elseif ($searchField === 'email') {
+                    $builder->like('email', $search);
+                } else {
+                    $builder->groupStart()
+                        ->like('username', $search)
+                        ->orLike('name', $search)
+                        ->orLike('email', $search)
+                        ->groupEnd();
+                }
             }
 
             // 역할 필터
             $role = $this->request->getGet('role');
-            if (!empty($role)) {
+            if (!empty($role) && in_array($role, self::ALLOWED_ROLES, true)) {
                 $builder->where('role', $role);
             }
 
             // 상태 필터
             $status = $this->request->getGet('status');
-            if (!empty($status)) {
+            if (!empty($status) && in_array($status, self::ALLOWED_STATUSES, true)) {
                 $builder->where('status', $status);
             }
 
-            // 전체 개수
             $total = $builder->countAllResults(false);
 
-            // 비밀번호 제외하고 조회 (login_attempts 포함)
-            $builder->select('id, username, name, email, department, role, status, COALESCE(login_attempts, 0) as login_attempts, last_failed_login, created_at, updated_at');
-            $builder->orderBy('id', 'DESC');
-            $builder->limit($perPage, $offset);
-
-            // SQL 쿼리 로그
-            $sql = $builder->getCompiledSelect(false);
-            log_message('debug', 'AdminController SQL: ' . $sql);
-
-            $items = $builder->get()->getResult();
-
-            $result = [
-                'items' => $items,
-                'total' => $total,
-                'page' => (int)$page,
-                'per_page' => (int)$perPage,
-                'total_pages' => ceil($total / $perPage)
-            ];
+            $items = $builder
+                ->select('id, username, name, email, phone, role, status, COALESCE(login_attempts, 0) as login_attempts, last_failed_login, last_login, created_at, updated_at')
+                ->orderBy('id', 'DESC')
+                ->limit($perPage, $offset)
+                ->get()
+                ->getResult();
+
+            // permissions 일괄 조회 후 attach
+            if (!empty($items)) {
+                $ids = array_map(fn($i) => (int) $i->id, $items);
+                $rows = $db->table('admin_permissions')
+                    ->select('admin_id, permission')
+                    ->whereIn('admin_id', $ids)
+                    ->get()->getResult();
+                $permsByAdmin = [];
+                foreach ($rows as $r) {
+                    $permsByAdmin[$r->admin_id][] = $r->permission;
+                }
+                foreach ($items as $item) {
+                    $item->permissions = $item->role === 'super_admin'
+                        ? 'all'
+                        : ($permsByAdmin[$item->id] ?? []);
+                }
+            }
 
-            return $this->respondSuccess($result);
+            return $this->respondSuccess([
+                'items'       => $items,
+                'total'       => $total,
+                'page'        => $page,
+                'per_page'    => $perPage,
+                'total_pages' => (int) ceil($total / $perPage),
+            ]);
         } catch (\Exception $e) {
             log_message('error', 'AdminController index error: ' . $e->getMessage());
             return $this->respondError('관리자 목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
@@ -81,7 +174,7 @@ class AdminController extends BaseApiController
     }
 
     /**
-     * Get single admin (관리자 상세)
+     * Get single admin (관리자 상세) — 인증된 모두 가능
      * GET /api/admin/:id
      */
     public function show($id = null)
@@ -97,8 +190,9 @@ class AdminController extends BaseApiController
 
         try {
             $admin = $this->getDB()->table('admin_users')
-                ->select('id, username, name, email, department, role, status, login_attempts, last_failed_login, created_at, updated_at')
-                ->where('id', $id)
+                ->select('id, username, name, email, phone, role, status, login_attempts, last_failed_login, last_login, created_at, updated_at')
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
                 ->get()
                 ->getRow();
 
@@ -106,6 +200,8 @@ class AdminController extends BaseApiController
                 return $this->respondError('관리자를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
             }
 
+            $admin->permissions = $this->getPermissions((int) $id, $admin->role);
+
             return $this->respondSuccess($admin);
         } catch (\Exception $e) {
             log_message('error', 'AdminController show error: ' . $e->getMessage());
@@ -124,21 +220,20 @@ class AdminController extends BaseApiController
             return $auth;
         }
 
-        $username = $this->request->getGet('username');
+        $username = trim((string) $this->request->getGet('username'));
 
-        if (empty($username)) {
+        if ($username === '') {
             return $this->respondError('아이디를 입력하세요.');
         }
 
         try {
             $existing = $this->getDB()->table('admin_users')
                 ->where('username', $username)
+                ->where('deleted_YN', 'N')
                 ->get()
                 ->getRow();
 
-            return $this->respondSuccess([
-                'available' => !$existing
-            ]);
+            return $this->respondSuccess(['available' => !$existing]);
         } catch (\Exception $e) {
             log_message('error', 'AdminController checkUsername error: ' . $e->getMessage());
             return $this->respondError('중복 체크 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
@@ -146,7 +241,7 @@ class AdminController extends BaseApiController
     }
 
     /**
-     * Create new admin (관리자 생성)
+     * Create new admin (관리자 생성) — 슈퍼 관리자만
      * POST /api/admin
      */
     public function create()
@@ -155,9 +250,8 @@ class AdminController extends BaseApiController
         if ($auth instanceof ResponseInterface) {
             return $auth;
         }
-
         try {
-            $data = $this->request->getJSON(true);
+            $data = $this->request->getJSON(true) ?? [];
 
             // 필수 필드 검증
             $required = ['username', 'password', 'name', 'email'];
@@ -167,15 +261,43 @@ class AdminController extends BaseApiController
                 }
             }
 
-            // 중복 체크
+            $username = trim((string) $data['username']);
+
+            // role / status 검증
+            $role = $data['role'] ?? 'admin';
+            if (!in_array($role, self::ALLOWED_ROLES, true)) {
+                return $this->respondError('올바르지 않은 역할입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+            $status = $data['status'] ?? 'active';
+            if (!in_array($status, self::ALLOWED_STATUSES, true)) {
+                return $this->respondError('올바르지 않은 상태입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+            }
+
+            // 호출자가 슈퍼관리자가 아니면 super_admin 등록 차단
+            if ($role === 'super_admin' && !$this->isCallerSuperAdmin($auth)) {
+                return $this->respondError('슈퍼 관리자 권한 부여는 슈퍼 관리자만 가능합니다.', ResponseInterface::HTTP_FORBIDDEN);
+            }
+
+            // 일반 admin은 권한 1개 이상 필수
+            $permissions = $data['permissions'] ?? [];
+            if ($role === 'admin') {
+                if (!is_array($permissions) || empty($permissions)) {
+                    return $this->respondError('관리자에게 부여할 메뉴 권한을 1개 이상 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+                }
+            }
+
+            // 중복 체크 (삭제된 계정 제외 — 재사용 허용)
             $existing = $this->getDB()->table('admin_users')
-                ->where('username', $data['username'])
-                ->orWhere('email', $data['email'])
+                ->groupStart()
+                    ->where('username', $username)
+                    ->orWhere('email', $data['email'])
+                ->groupEnd()
+                ->where('deleted_YN', 'N')
                 ->get()
                 ->getRow();
 
             if ($existing) {
-                if ($existing->username === $data['username']) {
+                if ($existing->username === $username) {
                     return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST);
                 }
                 if ($existing->email === $data['email']) {
@@ -183,30 +305,33 @@ class AdminController extends BaseApiController
                 }
             }
 
-            // 비밀번호 해싱
             $insertData = [
-                'username' => $data['username'],
-                'password' => password_hash($data['password'], PASSWORD_DEFAULT),
-                'name' => $data['name'],
-                'email' => $data['email'],
-                'department' => $data['department'] ?? '',
-                'role' => $data['role'] ?? 'admin',
-                'status' => $data['status'] ?? 'active',
-                'created_at' => date('Y-m-d H:i:s'),
-                'updated_at' => date('Y-m-d H:i:s')
+                'username'            => $username,
+                'password'            => password_hash($data['password'], PASSWORD_DEFAULT),
+                'password_changed_at' => date('Y-m-d H:i:s'),
+                'name'                => trim((string) $data['name']),
+                'email'               => trim((string) $data['email']),
+                'phone'               => trim((string) ($data['phone'] ?? '')),
+                'role'                => $role,
+                'status'              => $status,
+                'login_attempts'      => 0,
+                'deleted_YN'          => 'N',
+                'created_at'          => date('Y-m-d H:i:s'),
+                'updated_at'          => date('Y-m-d H:i:s'),
             ];
 
-            $builder = $this->getDB()->table('admin_users');
-            $builder->insert($insertData);
-
+            $this->getDB()->table('admin_users')->insert($insertData);
             $insertId = $this->getDB()->insertID();
 
-            // 생성된 관리자 정보 조회 (비밀번호 제외)
+            // 권한 저장 (super_admin은 row 안 박음)
+            $this->syncPermissions((int) $insertId, $role, $permissions);
+
             $admin = $this->getDB()->table('admin_users')
-                ->select('id, username, name, email, department, role, status, login_attempts, last_failed_login, created_at, updated_at')
+                ->select('id, username, name, email, phone, role, status, login_attempts, last_failed_login, last_login, created_at, updated_at')
                 ->where('id', $insertId)
                 ->get()
                 ->getRow();
+            $admin->permissions = $this->getPermissions((int) $insertId, $admin->role);
 
             return $this->respondSuccess($admin, '관리자가 생성되었습니다.', ResponseInterface::HTTP_CREATED);
         } catch (\Exception $e) {
@@ -216,7 +341,7 @@ class AdminController extends BaseApiController
     }
 
     /**
-     * Update admin (관리자 수정)
+     * Update admin (관리자 수정) — 슈퍼 관리자만
      * PUT /api/admin/:id
      */
     public function update($id = null)
@@ -225,25 +350,34 @@ class AdminController extends BaseApiController
         if ($auth instanceof ResponseInterface) {
             return $auth;
         }
-
         if (empty($id)) {
             return $this->respondError('관리자 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
         }
 
         try {
-            // 기존 관리자 확인
-            $existing = $this->getDB()->table('admin_users')->where('id', $id)->get()->getRow();
+            $existing = $this->getDB()->table('admin_users')
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->get()->getRow();
             if (!$existing) {
                 return $this->respondError('관리자를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
             }
 
-            $data = $this->request->getJSON(true);
+            $data = $this->request->getJSON(true) ?? [];
+
+            // 일반 admin은 role/permissions 변경 불가
+            if (isset($data['role']) || array_key_exists('permissions', $data)) {
+                if (!$this->isCallerSuperAdmin($auth)) {
+                    return $this->respondError('권한 변경은 슈퍼 관리자만 가능합니다.', ResponseInterface::HTTP_FORBIDDEN);
+                }
+            }
 
-            // email 중복 체크 (자신 제외)
+            // 이메일 중복 체크 (자신 제외, 삭제된 계정 제외)
             if (!empty($data['email']) && $data['email'] !== $existing->email) {
                 $duplicate = $this->getDB()->table('admin_users')
                     ->where('email', $data['email'])
-                    ->where('id !=', $id)
+                    ->where('id !=', (int) $id)
+                    ->where('deleted_YN', 'N')
                     ->get()
                     ->getRow();
                 if ($duplicate) {
@@ -251,35 +385,54 @@ class AdminController extends BaseApiController
                 }
             }
 
-            $updateData = [
-                'updated_at' => date('Y-m-d H:i:s')
-            ];
+            $updateData = ['updated_at' => date('Y-m-d H:i:s')];
+
+            if (isset($data['name']))  $updateData['name']  = trim((string) $data['name']);
+            if (isset($data['email'])) $updateData['email'] = trim((string) $data['email']);
+            if (isset($data['phone'])) $updateData['phone'] = trim((string) $data['phone']);
 
-            if (isset($data['name'])) {
-                $updateData['name'] = $data['name'];
-            }
-            if (isset($data['email'])) {
-                $updateData['email'] = $data['email'];
-            }
-            if (isset($data['department'])) {
-                $updateData['department'] = $data['department'];
-            }
             if (isset($data['role'])) {
+                if (!in_array($data['role'], self::ALLOWED_ROLES, true)) {
+                    return $this->respondError('올바르지 않은 역할입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+                }
                 $updateData['role'] = $data['role'];
             }
             if (isset($data['status'])) {
+                if (!in_array($data['status'], self::ALLOWED_STATUSES, true)) {
+                    return $this->respondError('올바르지 않은 상태입니다.', ResponseInterface::HTTP_BAD_REQUEST);
+                }
                 $updateData['status'] = $data['status'];
             }
 
-            $builder = $this->getDB()->table('admin_users');
-            $builder->where('id', $id)->update($updateData);
+            $this->getDB()->table('admin_users')->where('id', (int) $id)->update($updateData);
+
+            // 권한 동기화: role 또는 permissions가 들어왔을 때만
+            $touchedRole = isset($data['role']);
+            $touchedPerms = array_key_exists('permissions', $data);
+            if ($touchedRole || $touchedPerms) {
+                $finalRole = $touchedRole ? $data['role'] : $existing->role;
+                $finalPermissions = $touchedPerms ? ($data['permissions'] ?? []) : [];
+
+                // role='admin'으로 바뀌었는데 권한 비어있으면 차단
+                if ($finalRole === 'admin' && $touchedPerms && empty($finalPermissions)) {
+                    return $this->respondError('관리자에게 부여할 메뉴 권한을 1개 이상 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST);
+                }
+
+                // role만 super_admin → admin으로 바꿨는데 permissions 안 보낸 경우도 차단
+                if ($touchedRole && $finalRole === 'admin' && !$touchedPerms) {
+                    return $this->respondError('일반 관리자로 변경 시 메뉴 권한을 함께 지정해야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+                }
+
+                $this->syncPermissions((int) $id, $finalRole, $finalPermissions);
+            }
 
-            // 업데이트된 관리자 정보 조회
             $admin = $this->getDB()->table('admin_users')
-                ->select('id, username, name, email, department, role, status, login_attempts, last_failed_login, created_at, updated_at')
-                ->where('id', $id)
+                ->select('id, username, name, email, phone, role, status, login_attempts, last_failed_login, last_login, created_at, updated_at')
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
                 ->get()
                 ->getRow();
+            $admin->permissions = $this->getPermissions((int) $id, $admin->role);
 
             return $this->respondSuccess($admin, '관리자 정보가 수정되었습니다.');
         } catch (\Exception $e) {
@@ -289,7 +442,7 @@ class AdminController extends BaseApiController
     }
 
     /**
-     * Delete admin (관리자 삭제)
+     * Delete admin (관리자 삭제) — 슈퍼 관리자만, 본인은 삭제 불가
      * DELETE /api/admin/:id
      */
     public function delete($id = null)
@@ -298,22 +451,36 @@ class AdminController extends BaseApiController
         if ($auth instanceof ResponseInterface) {
             return $auth;
         }
-
         if (empty($id)) {
             return $this->respondError('관리자 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
         }
 
         try {
-            $existing = $this->getDB()->table('admin_users')->where('id', $id)->get()->getRow();
+            $targetId = (int) $id;
+
+            // 본인 삭제 방지
+            if ((int) $auth->admin_id === $targetId) {
+                return $this->respondError('본인 계정은 삭제할 수 없습니다.', ResponseInterface::HTTP_FORBIDDEN);
+            }
+
+            $existing = $this->getDB()->table('admin_users')
+                ->where('id', $targetId)
+                ->where('deleted_YN', 'N')
+                ->get()->getRow();
             if (!$existing) {
                 return $this->respondError('관리자를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
             }
 
-            $builder = $this->getDB()->table('admin_users');
-            $builder->where('id', $id)->delete();
+            // soft delete — 데이터는 보존, 플래그만 변경
+            $this->getDB()->table('admin_users')
+                ->where('id', $targetId)
+                ->update([
+                    'deleted_YN' => 'Y',
+                    'updated_at' => date('Y-m-d H:i:s'),
+                ]);
 
-            // 해당 관리자의 토큰도 삭제
-            $this->getDB()->table('admin_tokens')->where('admin_id', $id)->delete();
+            // 토큰만 무효화 (admin_permissions는 복구 시를 위해 보존)
+            $this->getDB()->table('admin_tokens')->where('admin_id', $targetId)->delete();
 
             return $this->respondSuccess(null, '관리자가 삭제되었습니다.');
         } catch (\Exception $e) {
@@ -323,7 +490,7 @@ class AdminController extends BaseApiController
     }
 
     /**
-     * Change admin password (비밀번호 변경)
+     * Change admin password (비밀번호 변경) — 슈퍼 관리자만
      * POST /api/admin/:id/password
      */
     public function changePassword($id = null)
@@ -332,30 +499,32 @@ class AdminController extends BaseApiController
         if ($auth instanceof ResponseInterface) {
             return $auth;
         }
-
         if (empty($id)) {
             return $this->respondError('관리자 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
         }
 
         try {
-            $data = $this->request->getJSON(true);
+            $data = $this->request->getJSON(true) ?? [];
 
             if (empty($data['new_password'])) {
                 return $this->respondError('새 비밀번호가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
             }
 
-            $existing = $this->getDB()->table('admin_users')->where('id', $id)->get()->getRow();
+            $existing = $this->getDB()->table('admin_users')
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->get()->getRow();
             if (!$existing) {
                 return $this->respondError('관리자를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
             }
 
             $updateData = [
-                'password' => password_hash($data['new_password'], PASSWORD_DEFAULT),
+                'password'            => password_hash($data['new_password'], PASSWORD_DEFAULT),
                 'password_changed_at' => date('Y-m-d H:i:s'),
-                'updated_at' => date('Y-m-d H:i:s')
+                'updated_at'          => date('Y-m-d H:i:s'),
             ];
 
-            $this->getDB()->table('admin_users')->where('id', $id)->update($updateData);
+            $this->getDB()->table('admin_users')->where('id', (int) $id)->update($updateData);
 
             return $this->respondSuccess(null, '비밀번호가 변경되었습니다.');
         } catch (\Exception $e) {
@@ -365,7 +534,7 @@ class AdminController extends BaseApiController
     }
 
     /**
-     * Unlock admin account (계정 잠금 해제)
+     * Unlock admin account (계정 잠금 해제) — 슈퍼 관리자만
      * POST /api/admin/:id/unlock
      */
     public function unlockAccount($id = null)
@@ -374,24 +543,26 @@ class AdminController extends BaseApiController
         if ($auth instanceof ResponseInterface) {
             return $auth;
         }
-
         if (empty($id)) {
             return $this->respondError('관리자 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
         }
 
         try {
-            $existing = $this->getDB()->table('admin_users')->where('id', $id)->get()->getRow();
+            $existing = $this->getDB()->table('admin_users')
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'N')
+                ->get()->getRow();
             if (!$existing) {
                 return $this->respondError('관리자를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
             }
 
             $updateData = [
-                'login_attempts' => 0,
+                'login_attempts'    => 0,
                 'last_failed_login' => null,
-                'updated_at' => date('Y-m-d H:i:s')
+                'updated_at'        => date('Y-m-d H:i:s'),
             ];
 
-            $this->getDB()->table('admin_users')->where('id', $id)->update($updateData);
+            $this->getDB()->table('admin_users')->where('id', (int) $id)->update($updateData);
 
             return $this->respondSuccess(null, '계정 잠금이 해제되었습니다.');
         } catch (\Exception $e) {
@@ -399,4 +570,111 @@ class AdminController extends BaseApiController
             return $this->respondError('계정 잠금 해제 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
         }
     }
+
+    /**
+     * Restore deleted admin (삭제된 관리자 복구)
+     * POST /api/admin/:id/restore
+     */
+    public function restore($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();
+            $existing = $db->table('admin_users')
+                ->where('id', (int) $id)
+                ->where('deleted_YN', 'Y')
+                ->get()->getRow();
+            if (!$existing) {
+                return $this->respondError('삭제된 관리자를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            // 동일 아이디 충돌 검사
+            $dupeUsername = $db->table('admin_users')
+                ->where('username', $existing->username)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($dupeUsername > 0) {
+                return $this->respondError(
+                    "동일 아이디 '{$existing->username}'가 이미 사용 중이라 복구할 수 없습니다.",
+                    ResponseInterface::HTTP_CONFLICT
+                );
+            }
+
+            // 이메일 충돌 검사
+            $dupeEmail = $db->table('admin_users')
+                ->where('email', $existing->email)
+                ->where('deleted_YN', 'N')
+                ->countAllResults();
+            if ($dupeEmail > 0) {
+                return $this->respondError(
+                    "동일 이메일 '{$existing->email}'가 이미 사용 중이라 복구할 수 없습니다.",
+                    ResponseInterface::HTTP_CONFLICT
+                );
+            }
+
+            $db->table('admin_users')
+                ->where('id', (int) $id)
+                ->update([
+                    'deleted_YN' => 'N',
+                    'updated_at' => date('Y-m-d H:i:s'),
+                ]);
+
+            return $this->respondSuccess(null, '관리자가 복구되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'AdminController restore error: ' . $e->getMessage());
+            return $this->respondError('복구 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * Hard delete admin (영구 삭제 — 이미 soft 삭제된 계정만)
+     * DELETE /api/admin/:id/hard
+     */
+    public function hardDelete($id = null)
+    {
+        $auth = $this->requireAuth();
+        if ($auth instanceof ResponseInterface) {
+            return $auth;
+        }
+
+        if (empty($id)) {
+            return $this->respondError('관리자 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
+        }
+
+        try {
+            $targetId = (int) $id;
+
+            // 본인 영구 삭제 방지
+            if ((int) $auth->admin_id === $targetId) {
+                return $this->respondError('본인 계정은 영구 삭제할 수 없습니다.', ResponseInterface::HTTP_FORBIDDEN);
+            }
+
+            $db = $this->getDB();
+            $existing = $db->table('admin_users')
+                ->where('id', $targetId)
+                ->where('deleted_YN', 'Y')
+                ->get()->getRow();
+            if (!$existing) {
+                return $this->respondError('영구 삭제 대상이 없습니다. (이미 삭제된 계정만 영구 삭제 가능)', ResponseInterface::HTTP_NOT_FOUND);
+            }
+
+            // 권한 row 정리 (FK CASCADE 있어도 명시적으로)
+            $db->table('admin_permissions')->where('admin_id', $targetId)->delete();
+            $db->table('admin_tokens')->where('admin_id', $targetId)->delete();
+            $db->table('admin_users')->where('id', $targetId)->delete();
+
+            return $this->respondSuccess(null, '관리자가 영구 삭제되었습니다.');
+        } catch (\Exception $e) {
+            log_message('error', 'AdminController hardDelete error: ' . $e->getMessage());
+            return $this->respondError('영구 삭제 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
 }

+ 27 - 6
backend/app/Controllers/Api/AuthController.php

@@ -19,9 +19,12 @@ class AuthController extends BaseApiController
             return $this->respondError('아이디와 비밀번호를 입력하세요.');
         }
 
-        // Find admin user
+        // Find admin user (삭제된 계정 제외)
         $builder = $this->getDB()->table('admin_users');
-        $admin = $builder->where('username', $username)->get()->getRow();
+        $admin = $builder
+            ->where('username', $username)
+            ->where('deleted_YN', 'N')
+            ->get()->getRow();
 
         if (!$admin) {
             return $this->respondError('아이디 또는 비밀번호가 올바르지 않습니다.');
@@ -57,11 +60,12 @@ class AuthController extends BaseApiController
             return $this->respondError("아이디 또는 비밀번호가 올바르지 않습니다. (남은 시도 횟수: {$remainingAttempts}회)");
         }
 
-        // Reset login attempts on successful login
+        // Reset login attempts + last_login 업데이트
         $resetBuilder = $this->getDB()->table('admin_users');
         $resetBuilder->where('id', $admin->id)->update([
             'login_attempts' => 0,
-            'last_failed_login' => null
+            'last_failed_login' => null,
+            'last_login' => date('Y-m-d H:i:s'),
         ]);
 
         // Generate token
@@ -100,11 +104,26 @@ class AuthController extends BaseApiController
                 'id' => $admin->id,
                 'username' => $admin->username,
                 'name' => $admin->name,
-                'email' => $admin->email
+                'email' => $admin->email,
+                'role' => $admin->role,
+                'permissions' => $this->fetchPermissions((int) $admin->id, $admin->role),
             ]
         ], '로그인 성공');
     }
 
+    /**
+     * 관리자 권한 배열 반환 — super_admin은 'all', admin은 메뉴 id 배열
+     */
+    private function fetchPermissions(int $adminId, string $role)
+    {
+        if ($role === 'super_admin') return 'all';
+        $rows = $this->getDB()->table('admin_permissions')
+            ->select('permission')
+            ->where('admin_id', $adminId)
+            ->get()->getResult();
+        return array_map(fn($r) => $r->permission, $rows);
+    }
+
     /**
      * Logout
      */
@@ -140,7 +159,9 @@ class AuthController extends BaseApiController
                 'id' => $admin->id,
                 'username' => $admin->username,
                 'name' => $admin->name,
-                'email' => $admin->email
+                'email' => $admin->email,
+                'role' => $admin->role,
+                'permissions' => $this->fetchPermissions((int) $admin->id, $admin->role),
             ]
         ]);
     }

+ 495 - 4
db.vuerd.json

@@ -4,8 +4,8 @@
   "settings": {
     "width": 3000,
     "height": 3000,
-    "scrollTop": -1559,
-    "scrollLeft": -12,
+    "scrollTop": -1885,
+    "scrollLeft": -7.8075,
     "zoomLevel": 0.97,
     "show": 431,
     "database": 4,
@@ -41,7 +41,9 @@
       "0qOhpokdRsP9PKwViW3I4",
       "kx1Wu65aSaH1nPc3asZqQ",
       "M0_u-aSCZODbw1yxM1xbr",
-      "2sbVPAHfKmCEZ5M-oWOvb"
+      "2sbVPAHfKmCEZ5M-oWOvb",
+      "V5WmOU4HDHyTdabRWFSvP",
+      "JctLUX2uN_t9QicJcDQtX"
     ],
     "relationshipIds": [
       "02Rf0D1riQbaw0LqkaD6r",
@@ -51,7 +53,8 @@
       "6dWnTNRq3psvKvtiYAk_j",
       "dPC1hrjZp8SIR9PkoUaLL",
       "-W2zak31bgJwKqTSi4rfs",
-      "o4qoNtcaO_NiHs5Z6SF67"
+      "o4qoNtcaO_NiHs5Z6SF67",
+      "lNcSKAPsI2Ky7_Z_GsAw3"
     ],
     "indexIds": [],
     "memoIds": []
@@ -563,6 +566,86 @@
           "updateAt": 1780900507879,
           "createAt": 1780900301081
         }
+      },
+      "V5WmOU4HDHyTdabRWFSvP": {
+        "id": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "admin_users",
+        "comment": "관리자 계정",
+        "columnIds": [
+          "tHwBhjE5IaBDqoQPvXEEb",
+          "JJdYcenZrD4YJHiqBCywJ",
+          "hWB2V-262XhQKM1ljVAY2",
+          "qjt8ZZ2tbXoYEyyp7vmLz",
+          "CtnLhBcxDm3YGPXO9CoMi",
+          "xQM4xA8rj0i0GYK2mL3WG",
+          "OqoLCHhIEBo_pYhknmOlh",
+          "-ZnQrVUYdyIGWGLCl75Oo",
+          "ZJ0YLGXrTOLSQkGV2FyxH",
+          "EbTqbYe2UvqwjP41Fanl9",
+          "b73xNm8GiCCNseiw4KYqg",
+          "IdyrocYtGHvo6cWlPzmxL",
+          "Rpm5awlBls1Pf5hRCSBSg",
+          "Fn7LIz0095WVG2OSPhso8",
+          "b8mFE66Xzev7I3jF0ecrw"
+        ],
+        "seqColumnIds": [
+          "tHwBhjE5IaBDqoQPvXEEb",
+          "JJdYcenZrD4YJHiqBCywJ",
+          "hWB2V-262XhQKM1ljVAY2",
+          "qjt8ZZ2tbXoYEyyp7vmLz",
+          "CtnLhBcxDm3YGPXO9CoMi",
+          "xQM4xA8rj0i0GYK2mL3WG",
+          "OqoLCHhIEBo_pYhknmOlh",
+          "-ZnQrVUYdyIGWGLCl75Oo",
+          "ZJ0YLGXrTOLSQkGV2FyxH",
+          "EbTqbYe2UvqwjP41Fanl9",
+          "b73xNm8GiCCNseiw4KYqg",
+          "IdyrocYtGHvo6cWlPzmxL",
+          "Rpm5awlBls1Pf5hRCSBSg",
+          "Fn7LIz0095WVG2OSPhso8",
+          "b8mFE66Xzev7I3jF0ecrw"
+        ],
+        "ui": {
+          "x": 74.2268,
+          "y": 2170.1031,
+          "zIndex": 814,
+          "widthName": 68,
+          "widthComment": 65,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1781059600608,
+          "createAt": 1781054713939
+        }
+      },
+      "JctLUX2uN_t9QicJcDQtX": {
+        "id": "JctLUX2uN_t9QicJcDQtX",
+        "name": "admin_permissions",
+        "comment": "관리자 계정별 권한",
+        "columnIds": [
+          "PUcuKfQfvx7Rfl6W_Q8vl",
+          "1-0yaVOEbf9Jl3h7H_9oa",
+          "qKjvK4m0wrAQbE-bhXo7J",
+          "a2qr73LDzu7C-ORPDoj67"
+        ],
+        "seqColumnIds": [
+          "PUcuKfQfvx7Rfl6W_Q8vl",
+          "1-0yaVOEbf9Jl3h7H_9oa",
+          "qKjvK4m0wrAQbE-bhXo7J",
+          "a2qr73LDzu7C-ORPDoj67"
+        ],
+        "ui": {
+          "x": 576.2886,
+          "y": 2170.7956,
+          "zIndex": 972,
+          "widthName": 103,
+          "widthComment": 105,
+          "color": ""
+        },
+        "meta": {
+          "updateAt": 1781073459830,
+          "createAt": 1781072810111
+        }
       }
     },
     "tableColumnEntities": {
@@ -3425,6 +3508,386 @@
           "updateAt": 1780901292953,
           "createAt": 1780901258938
         }
+      },
+      "tHwBhjE5IaBDqoQPvXEEb": {
+        "id": "tHwBhjE5IaBDqoQPvXEEb",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781054776503,
+          "createAt": 1781054728798
+        }
+      },
+      "JJdYcenZrD4YJHiqBCywJ": {
+        "id": "JJdYcenZrD4YJHiqBCywJ",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "username",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057566399,
+          "createAt": 1781054729682
+        }
+      },
+      "hWB2V-262XhQKM1ljVAY2": {
+        "id": "hWB2V-262XhQKM1ljVAY2",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "password",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057567321,
+          "createAt": 1781054730102
+        }
+      },
+      "qjt8ZZ2tbXoYEyyp7vmLz": {
+        "id": "qjt8ZZ2tbXoYEyyp7vmLz",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "password_changed_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 118,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057597913,
+          "createAt": 1781054730604
+        }
+      },
+      "CtnLhBcxDm3YGPXO9CoMi": {
+        "id": "CtnLhBcxDm3YGPXO9CoMi",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "name",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057629413,
+          "createAt": 1781054731228
+        }
+      },
+      "xQM4xA8rj0i0GYK2mL3WG": {
+        "id": "xQM4xA8rj0i0GYK2mL3WG",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "email",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057626563,
+          "createAt": 1781054731782
+        }
+      },
+      "OqoLCHhIEBo_pYhknmOlh": {
+        "id": "OqoLCHhIEBo_pYhknmOlh",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "phone",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057625086,
+          "createAt": 1781054732232
+        }
+      },
+      "-ZnQrVUYdyIGWGLCl75Oo": {
+        "id": "-ZnQrVUYdyIGWGLCl75Oo",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "role",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "admin",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057623451,
+          "createAt": 1781054732720
+        }
+      },
+      "ZJ0YLGXrTOLSQkGV2FyxH": {
+        "id": "ZJ0YLGXrTOLSQkGV2FyxH",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "status",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "active",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057621861,
+          "createAt": 1781054733131
+        }
+      },
+      "EbTqbYe2UvqwjP41Fanl9": {
+        "id": "EbTqbYe2UvqwjP41Fanl9",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057594222,
+          "createAt": 1781054734444
+        }
+      },
+      "b73xNm8GiCCNseiw4KYqg": {
+        "id": "b73xNm8GiCCNseiw4KYqg",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "updated_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 62,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057616484,
+          "createAt": 1781054736355
+        }
+      },
+      "IdyrocYtGHvo6cWlPzmxL": {
+        "id": "IdyrocYtGHvo6cWlPzmxL",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "login_attempts",
+        "comment": "",
+        "dataType": "INT",
+        "default": "0",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 81,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057612576,
+          "createAt": 1781054764482
+        }
+      },
+      "Rpm5awlBls1Pf5hRCSBSg": {
+        "id": "Rpm5awlBls1Pf5hRCSBSg",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "last_failed_login",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 86,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781058369830,
+          "createAt": 1781054860670
+        }
+      },
+      "Fn7LIz0095WVG2OSPhso8": {
+        "id": "Fn7LIz0095WVG2OSPhso8",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "last_login",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 0,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781057602406,
+          "createAt": 1781054860917
+        }
+      },
+      "b8mFE66Xzev7I3jF0ecrw": {
+        "id": "b8mFE66Xzev7I3jF0ecrw",
+        "tableId": "V5WmOU4HDHyTdabRWFSvP",
+        "name": "deleted_YN",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "N",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 63,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781059609704,
+          "createAt": 1781059600608
+        }
+      },
+      "PUcuKfQfvx7Rfl6W_Q8vl": {
+        "id": "PUcuKfQfvx7Rfl6W_Q8vl",
+        "tableId": "JctLUX2uN_t9QicJcDQtX",
+        "name": "id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 10,
+        "ui": {
+          "keys": 1,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781072855373,
+          "createAt": 1781072838018
+        }
+      },
+      "1-0yaVOEbf9Jl3h7H_9oa": {
+        "id": "1-0yaVOEbf9Jl3h7H_9oa",
+        "tableId": "JctLUX2uN_t9QicJcDQtX",
+        "name": "admin_id",
+        "comment": "",
+        "dataType": "INT",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 2,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781072893420,
+          "createAt": 1781072883960
+        }
+      },
+      "qKjvK4m0wrAQbE-bhXo7J": {
+        "id": "qKjvK4m0wrAQbE-bhXo7J",
+        "tableId": "JctLUX2uN_t9QicJcDQtX",
+        "name": "permission",
+        "comment": "",
+        "dataType": "VARCHAR",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 60,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781072986051,
+          "createAt": 1781072896758
+        }
+      },
+      "a2qr73LDzu7C-ORPDoj67": {
+        "id": "a2qr73LDzu7C-ORPDoj67",
+        "tableId": "JctLUX2uN_t9QicJcDQtX",
+        "name": "created_at",
+        "comment": "",
+        "dataType": "TIMESTAMP",
+        "default": "",
+        "options": 8,
+        "ui": {
+          "keys": 0,
+          "widthName": 60,
+          "widthComment": 60,
+          "widthDataType": 65,
+          "widthDefault": 60
+        },
+        "meta": {
+          "updateAt": 1781073477446,
+          "createAt": 1781073459830
+        }
       }
     },
     "relationshipEntities": {
@@ -3735,6 +4198,34 @@
           "updateAt": 1780900865092,
           "createAt": 1780900865092
         }
+      },
+      "lNcSKAPsI2Ky7_Z_GsAw3": {
+        "id": "lNcSKAPsI2Ky7_Z_GsAw3",
+        "identification": false,
+        "relationshipType": 4,
+        "startRelationshipType": 2,
+        "start": {
+          "tableId": "V5WmOU4HDHyTdabRWFSvP",
+          "columnIds": [
+            "tHwBhjE5IaBDqoQPvXEEb"
+          ],
+          "x": 502.2268,
+          "y": 2378.1031,
+          "direction": 2
+        },
+        "end": {
+          "tableId": "JctLUX2uN_t9QicJcDQtX",
+          "columnIds": [
+            "1-0yaVOEbf9Jl3h7H_9oa"
+          ],
+          "x": 576.2886,
+          "y": 2246.7956,
+          "direction": 1
+        },
+        "meta": {
+          "updateAt": 1781072883961,
+          "createAt": 1781072883961
+        }
       }
     },
     "indexEntities": {},