Przeglądaj źródła

관리자 레이아웃 및 api 진행

송용우 1 miesiąc temu
rodzic
commit
90ff5886ba
40 zmienionych plików z 9658 dodań i 48 usunięć
  1. 36 0
      .htaccess
  2. 17 3
      app/app.vue
  3. 1485 1
      app/assets/scss/admin.scss
  4. 78 0
      app/components/admin/AdminAlertModal.vue
  5. 201 0
      app/components/admin/AdminModal.vue
  6. 146 0
      app/components/admin/PasswordModal.vue
  7. 126 0
      app/components/admin/SunEditor.vue
  8. 24 22
      app/components/footer.vue
  9. 134 0
      app/composables/useApi.js
  10. 230 0
      app/layouts/admin.vue
  11. 38 0
      app/middleware/auth.js
  12. 394 0
      app/pages/admin/admins/index.vue
  13. 364 0
      app/pages/admin/basic/popup/create.vue
  14. 402 0
      app/pages/admin/basic/popup/edit/[id].vue
  15. 262 0
      app/pages/admin/basic/popup/index.vue
  16. 315 0
      app/pages/admin/basic/site-info.vue
  17. 282 0
      app/pages/admin/board/event/create.vue
  18. 337 0
      app/pages/admin/board/event/edit/[id].vue
  19. 134 0
      app/pages/admin/board/event/index.vue
  20. 251 0
      app/pages/admin/board/ir/create.vue
  21. 304 0
      app/pages/admin/board/ir/edit/[id].vue
  22. 134 0
      app/pages/admin/board/ir/index.vue
  23. 251 0
      app/pages/admin/board/news/create.vue
  24. 304 0
      app/pages/admin/board/news/edit/[id].vue
  25. 134 0
      app/pages/admin/board/news/index.vue
  26. 129 0
      app/pages/admin/branch/list.vue
  27. 240 0
      app/pages/admin/branch/manager/create.vue
  28. 264 0
      app/pages/admin/branch/manager/edit/[id].vue
  29. 225 0
      app/pages/admin/branch/manager/index.vue
  30. 88 0
      app/pages/admin/dashboard.vue
  31. 65 22
      app/pages/admin/index.vue
  32. 229 0
      app/pages/admin/service/brochure.vue
  33. 228 0
      app/pages/admin/staff/advisor/create.vue
  34. 228 0
      app/pages/admin/staff/advisor/edit/[id].vue
  35. 223 0
      app/pages/admin/staff/advisor/index.vue
  36. 340 0
      app/pages/admin/staff/sales/create.vue
  37. 376 0
      app/pages/admin/staff/sales/edit/[id].vue
  38. 329 0
      app/pages/admin/staff/sales/index.vue
  39. 308 0
      package-lock.json
  40. 3 0
      package.json

+ 36 - 0
.htaccess

@@ -0,0 +1,36 @@
+  # CORS Headers - 모든 요청에 대해 CORS 헤더 추가
+  <IfModule mod_headers.c>
+      Header always set Access-Control-Allow-Origin "*"
+      Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
+      Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, Accept"
+      Header always set Access-Control-Max-Age "3600"
+  </IfModule>
+
+  <IfModule mod_rewrite.c>
+      RewriteEngine On
+
+      # OPTIONS 요청 처리 (Preflight)
+      RewriteCond %{REQUEST_METHOD} OPTIONS
+      RewriteRule ^(.*)$ $1 [R=200,L]
+
+      # 1. /writable/uploads/item/thumb/ 경로는 rewrite 안 함
+      RewriteCond %{REQUEST_URI} ^/writable/uploads/item/thumb/
+      RewriteRule ^ - [L]
+
+      # 2. API 요청은 CodeIgniter index.php로
+      RewriteCond %{REQUEST_URI} ^/api
+      RewriteRule ^api/(.*)$ index.php/api/$1 [L]
+
+      # 3. 관리자 API 요청
+      RewriteCond %{REQUEST_URI} ^/admin/api
+      RewriteRule ^admin/api/(.*)$ index.php/admin/api/$1 [L]
+
+      # 4. Nuxt 정적 파일 (_nuxt, assets 등)
+      RewriteCond %{REQUEST_FILENAME} -f
+      RewriteRule ^ - [L]
+
+      # 5. 나머지 모든 요청은 Nuxt index.html로
+      RewriteCond %{REQUEST_FILENAME} !-f
+      RewriteCond %{REQUEST_FILENAME} !-d
+      RewriteRule ^.*$ /index.html [L]
+  </IfModule>

+ 17 - 3
app/app.vue

@@ -1,10 +1,24 @@
 <template>
   <UApp>
-    <Header />
-    <NuxtPage />
-    <Footer />
+    <Header v-if="!isAdminPage" />
+    <NuxtLayout>
+      <NuxtPage />
+    </NuxtLayout>
+    <Footer v-if="!isAdminPage" />
   </UApp>
 </template>
+
+<script setup>
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+
+// admin 페이지 체크
+const isAdminPage = computed(() => {
+  return route.path.startsWith('/admin')
+})
+</script>
 <style setup>
   @import "tailwindcss";
   @import "@nuxt/ui";

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

@@ -3657,9 +3657,1493 @@ footer{
               line-height: 32px;            
             }
           }
-          
+
+        }
+      }
+    }
+  }
+}
+
+// ============================================
+// Admin Panel Dark Theme Styles
+// ============================================
+
+// Dark Theme Color Variables
+:root {
+  --admin-bg-primary: #0f0f0f;
+  --admin-bg-secondary: #1a1a1a;
+  --admin-bg-tertiary: #252525;
+  --admin-text-primary: #ffffff;
+  --admin-text-secondary: #b3b3b3;
+  --admin-text-muted: #666666;
+  --admin-border-color: #333333;
+  --admin-accent-primary: #bb0a30;
+  --admin-accent-hover: #990825;
+  --admin-success: #10b981;
+  --admin-warning: #f59e0b;
+  --admin-error: #ef4444;
+  --admin-shadow: rgba(0, 0, 0, 0.5);
+}
+
+// Admin Login Page
+.admin--login {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, var(--admin-bg-primary) 0%, var(--admin-bg-secondary) 100%);
+  font-family: 'AudiType', sans-serif;
+
+  .login--container {
+    width: 100%;
+    max-width: 460px;
+    padding: 20px;
+  }
+
+  .login--box {
+    background: var(--admin-bg-secondary);
+    border-radius: 12px;
+    padding: 48px 40px;
+    box-shadow: 0 8px 32px var(--admin-shadow);
+    border: 1px solid var(--admin-border-color);
+  }
+
+  .login--logo {
+    text-align: center;
+    margin-bottom: 40px;
+
+    h1 {
+      font-size: 48px;
+      font-weight: 700;
+      color: var(--admin-text-primary);
+      margin: 0 0 8px 0;
+      letter-spacing: 4px;
+    }
+
+    .subtitle {
+      font-size: 14px;
+      color: var(--admin-text-secondary);
+      margin: 0;
+    }
+  }
+
+  .login--error {
+    background: rgba(239, 68, 68, 0.1);
+    border: 1px solid var(--admin-error);
+    color: var(--admin-error);
+    padding: 12px 16px;
+    border-radius: 6px;
+    margin-bottom: 20px;
+    font-size: 14px;
+  }
+
+  .login--form {
+    .form--group {
+      margin-bottom: 20px;
+    }
+
+    .form--input {
+      width: 100%;
+      padding: 14px 16px;
+      background: var(--admin-bg-tertiary);
+      border: 1px solid var(--admin-border-color);
+      border-radius: 6px;
+      color: var(--admin-text-primary);
+      font-size: 14px;
+      transition: all 0.3s ease;
+      font-family: 'AudiType', sans-serif;
+
+      &:focus {
+        outline: none;
+        border-color: var(--admin-accent-primary);
+        box-shadow: 0 0 0 3px rgba(187, 10, 48, 0.1);
+      }
+
+      &::placeholder {
+        color: var(--admin-text-muted);
+      }
+
+      &:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+      }
+    }
+
+    .form--options {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 24px;
+
+      .checkbox--label {
+        display: flex;
+        align-items: center;
+        cursor: pointer;
+        font-size: 14px;
+        color: var(--admin-text-secondary);
+
+        input[type="checkbox"] {
+          margin-right: 8px;
+          cursor: pointer;
+        }
+      }
+
+      .forgot--password {
+        font-size: 14px;
+        color: var(--admin-accent-primary);
+        text-decoration: none;
+        transition: color 0.3s ease;
+
+        &:hover {
+          color: var(--admin-accent-hover);
+        }
+      }
+    }
+
+    .login--button {
+      width: 100%;
+      padding: 14px 16px;
+      background: var(--admin-accent-primary);
+      color: var(--admin-text-primary);
+      border: none;
+      border-radius: 6px;
+      font-size: 16px;
+      font-weight: 600;
+      cursor: pointer;
+      transition: all 0.3s ease;
+      font-family: 'AudiType', sans-serif;
+
+      &:hover:not(:disabled) {
+        background: var(--admin-accent-hover);
+        transform: translateY(-2px);
+        box-shadow: 0 4px 12px rgba(187, 10, 48, 0.3);
+      }
+
+      &:active:not(:disabled) {
+        transform: translateY(0);
+      }
+
+      &:disabled {
+        opacity: 0.6;
+        cursor: not-allowed;
+      }
+    }
+  }
+
+  .login--footer {
+    margin-top: 32px;
+    text-align: center;
+
+    p {
+      font-size: 12px;
+      color: var(--admin-text-muted);
+      margin: 0;
+    }
+  }
+}
+
+// Admin Layout
+.admin--layout {
+  min-height: 100vh;
+  background: var(--admin-bg-primary);
+  font-family: 'AudiType', sans-serif;
+}
+
+// Admin Header
+.admin--header {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 64px;
+  background: var(--admin-bg-secondary);
+  border-bottom: 1px solid var(--admin-border-color);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 32px;
+  z-index: 1000;
+
+  .admin--header-left {
+    display: flex;
+    align-items: center;
+  }
+
+  .admin--logo {
+    display: flex;
+    align-items: baseline;
+    gap: 12px;
+
+    h1 {
+      font-size: 28px;
+      font-weight: 700;
+      color: var(--admin-text-primary);
+      margin: 0;
+      letter-spacing: 2px;
+    }
+
+    .admin--logo-sub {
+      font-size: 16px;
+      color: var(--admin-text-secondary);
+      font-weight: 400;
+    }
+  }
+
+  .admin--header-right {
+    display: flex;
+    gap: 12px;
+  }
+
+  .admin--header-btn {
+    padding: 8px 20px;
+    background: var(--admin-bg-tertiary);
+    color: var(--admin-text-primary);
+    border: 1px solid var(--admin-border-color);
+    border-radius: 6px;
+    font-size: 14px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-family: 'AudiType', sans-serif;
+
+    &:hover {
+      background: var(--admin-bg-primary);
+      border-color: var(--admin-accent-primary);
+    }
+
+    &.admin--header-btn-logout {
+      background: var(--admin-accent-primary);
+      border-color: var(--admin-accent-primary);
+
+      &:hover {
+        background: var(--admin-accent-hover);
+        border-color: var(--admin-accent-hover);
+      }
+    }
+  }
+}
+
+// Admin Content Wrapper
+.admin--content-wrapper {
+  display: flex;
+  margin-top: 64px;
+  min-height: calc(100vh - 64px);
+}
+
+// Admin Sidebar
+.admin--sidebar {
+  width: 260px;
+  background: var(--admin-bg-secondary);
+  border-right: 1px solid var(--admin-border-color);
+  padding: 24px 0;
+  position: fixed;
+  left: 0;
+  top: 64px;
+  bottom: 0;
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: var(--admin-bg-primary);
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: var(--admin-border-color);
+    border-radius: 3px;
+
+    &:hover {
+      background: var(--admin-text-muted);
+    }
+  }
+}
+
+// Admin GNB
+.admin--gnb {
+  .admin--gnb-group {
+    margin-bottom: 8px;
+  }
+
+  .admin--gnb-title {
+    padding: 12px 24px;
+    font-size: 14px;
+    font-weight: 600;
+    color: var(--admin-text-secondary);
+    cursor: pointer;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    transition: all 0.3s ease;
+    user-select: none;
+
+    &:hover {
+      color: var(--admin-text-primary);
+      background: var(--admin-bg-primary);
+    }
+
+    .admin--gnb-arrow {
+      font-size: 10px;
+      transition: transform 0.3s ease;
+
+      &.is-open {
+        transform: rotate(-180deg);
+      }
+    }
+  }
+
+  .admin--gnb-submenu {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+  }
+
+  .admin--gnb-item {
+    &.is-active {
+      .admin--gnb-link {
+        color: var(--admin-accent-primary);
+        background: rgba(187, 10, 48, 0.1);
+        border-left: 3px solid var(--admin-accent-primary);
+      }
+    }
+  }
+
+  .admin--gnb-link {
+    display: block;
+    padding: 10px 24px 10px 40px;
+    font-size: 14px;
+    color: var(--admin-text-secondary);
+    text-decoration: none;
+    transition: all 0.3s ease;
+    border-left: 3px solid transparent;
+
+    &:hover {
+      color: var(--admin-text-primary);
+      background: var(--admin-bg-primary);
+    }
+  }
+}
+
+// Submenu Animation
+.admin--submenu-enter-active,
+.admin--submenu-leave-active {
+  transition: all 0.3s ease;
+  overflow: hidden;
+}
+
+.admin--submenu-enter-from,
+.admin--submenu-leave-to {
+  opacity: 0;
+  max-height: 0;
+}
+
+.admin--submenu-enter-to,
+.admin--submenu-leave-from {
+  opacity: 1;
+  max-height: 500px;
+}
+
+// Admin Main Content
+.admin--main {
+  flex: 1;
+  margin-left: 260px;
+  padding: 32px;
+  background: var(--admin-bg-primary);
+}
+
+// Admin Page Header
+.admin--page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 32px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid var(--admin-border-color);
+}
+
+.admin--page-title {
+  font-size: 28px;
+  font-weight: 700;
+  color: var(--admin-text-primary);
+  margin: 0;
+}
+
+// Admin Breadcrumb
+.admin--breadcrumb {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 14px;
+
+  .admin--breadcrumb-link {
+    color: var(--admin-text-secondary);
+    text-decoration: none;
+    transition: color 0.3s ease;
+
+    &:hover {
+      color: var(--admin-accent-primary);
+    }
+  }
+
+  .admin--breadcrumb-current {
+    color: var(--admin-text-primary);
+    font-weight: 500;
+  }
+
+  .admin--breadcrumb-separator {
+    color: var(--admin-text-muted);
+  }
+}
+
+// Admin Page Content
+.admin--page-content {
+  background: var(--admin-bg-secondary);
+  border-radius: 8px;
+  padding: 24px;
+  border: 1px solid var(--admin-border-color);
+}
+
+// Admin Dashboard
+.admin--dashboard {
+  .admin--dashboard-welcome {
+    margin-bottom: 32px;
+
+    h3 {
+      font-size: 24px;
+      color: var(--admin-text-primary);
+      margin: 0 0 8px 0;
+    }
+
+    p {
+      font-size: 14px;
+      color: var(--admin-text-secondary);
+      margin: 0;
+    }
+  }
+
+  .admin--dashboard-stats {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+    gap: 20px;
+    margin-bottom: 32px;
+  }
+
+  .admin--stat-card {
+    background: var(--admin-bg-tertiary);
+    border: 1px solid var(--admin-border-color);
+    border-radius: 8px;
+    padding: 24px;
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    transition: all 0.3s ease;
+
+    &:hover {
+      border-color: var(--admin-accent-primary);
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px var(--admin-shadow);
+    }
+
+    .admin--stat-icon {
+      font-size: 36px;
+    }
+
+    .admin--stat-content {
+      flex: 1;
+
+      h4 {
+        font-size: 14px;
+        color: var(--admin-text-secondary);
+        margin: 0 0 8px 0;
+        font-weight: 500;
+      }
+
+      .admin--stat-number {
+        font-size: 28px;
+        color: var(--admin-text-primary);
+        margin: 0;
+        font-weight: 700;
+      }
+    }
+  }
+
+  .admin--dashboard-recent {
+    h4 {
+      font-size: 18px;
+      color: var(--admin-text-primary);
+      margin: 0 0 16px 0;
+    }
+
+    .admin--recent-list {
+      background: var(--admin-bg-tertiary);
+      border: 1px solid var(--admin-border-color);
+      border-radius: 8px;
+      padding: 24px;
+
+      .admin--no-data {
+        text-align: center;
+        color: var(--admin-text-muted);
+        margin: 0;
+      }
+    }
+  }
+}
+
+// Admin Form Styles
+.admin--form {
+  max-width: 800px;
+
+  .admin--form-group {
+    margin-bottom: 24px;
+  }
+
+  .admin--form-label {
+    display: block;
+    font-size: 14px;
+    font-weight: 500;
+    color: var(--admin-text-primary);
+    margin-bottom: 8px;
+
+    .admin--required {
+      color: var(--admin-error);
+      margin-left: 4px;
+    }
+  }
+
+  .admin--form-input,
+  .admin--form-textarea,
+  .admin--form-select {
+    width: 100%;
+    padding: 12px 16px;
+    background: var(--admin-bg-tertiary);
+    border: 1px solid var(--admin-border-color);
+    border-radius: 6px;
+    color: var(--admin-text-primary);
+    font-size: 14px;
+    font-family: 'AudiType', sans-serif;
+    transition: all 0.3s ease;
+
+    &:focus {
+      outline: none;
+      border-color: var(--admin-accent-primary);
+      box-shadow: 0 0 0 3px rgba(187, 10, 48, 0.1);
+    }
+
+    &::placeholder {
+      color: var(--admin-text-muted);
+    }
+
+    &:disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+  }
+
+  .admin--form-textarea {
+    min-height: 120px;
+    resize: vertical;
+  }
+
+  .admin--form-select {
+    cursor: pointer;
+  }
+
+  // 다중 입력 필드
+  .admin--multi-input-wrapper {
+    .admin--multi-input-item {
+      display: flex;
+      gap: 12px;
+      margin-bottom: 12px;
+      align-items: flex-start;
+
+      .admin--sender-row {
+        display: flex;
+        gap: 10px;
+        flex: 1;
+      }
+
+      .admin--form-input {
+        flex: 1;
+      }
+
+      .admin--btn-remove {
+        padding: 12px 20px;
+        background: var(--admin-error);
+        color: var(--admin-text-primary);
+        border: none;
+        border-radius: 6px;
+        font-size: 14px;
+        font-weight: 500;
+        cursor: pointer;
+        transition: all 0.3s ease;
+        white-space: nowrap;
+        font-family: 'AudiType', sans-serif;
+
+        &:hover {
+          background: #dc2626;
+        }
+      }
+    }
+
+    .admin--btn-add {
+      padding: 10px 20px;
+      background: var(--admin-bg-tertiary);
+      color: var(--admin-text-secondary);
+      border: 1px dashed var(--admin-border-color);
+      border-radius: 6px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: all 0.3s ease;
+      font-family: 'AudiType', sans-serif;
+
+      &:hover {
+        background: var(--admin-bg-primary);
+        border-color: var(--admin-accent-primary);
+        color: var(--admin-text-primary);
+      }
+    }
+  }
+
+  // 폼 액션 버튼
+  .admin--form-actions {
+    display: flex;
+    gap: 12px;
+    margin-top: 32px;
+    padding-top: 24px;
+    border-top: 1px solid var(--admin-border-color);
+  }
+
+  .admin--btn {
+    padding: 12px 32px;
+    border: none;
+    border-radius: 6px;
+    font-size: 14px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-family: 'AudiType', sans-serif;
+
+    &:disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+  }
+
+  .admin--btn-primary {
+    background: var(--admin-accent-primary);
+    color: var(--admin-text-primary);
+
+    &:hover:not(:disabled) {
+      background: var(--admin-accent-hover);
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px rgba(187, 10, 48, 0.3);
+    }
+  }
+
+  .admin--btn-secondary {
+    background: var(--admin-bg-tertiary);
+    color: var(--admin-text-primary);
+    border: 1px solid var(--admin-border-color);
+
+    &:hover:not(:disabled) {
+      background: var(--admin-bg-primary);
+      border-color: var(--admin-accent-primary);
+    }
+  }
+
+  // 알림 메시지
+  .admin--alert {
+    padding: 12px 16px;
+    border-radius: 6px;
+    margin-top: 20px;
+    font-size: 14px;
+  }
+
+  .admin--alert-success {
+    background: rgba(16, 185, 129, 0.1);
+    border: 1px solid var(--admin-success);
+    color: var(--admin-success);
+  }
+
+  .admin--alert-error {
+    background: rgba(239, 68, 68, 0.1);
+    border: 1px solid var(--admin-error);
+    color: var(--admin-error);
+  }
+
+  .admin--alert-warning {
+    background: rgba(245, 158, 11, 0.1);
+    border: 1px solid var(--admin-warning);
+    color: var(--admin-warning);
+  }
+}
+
+// 로딩
+.admin--loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 60px 20px;
+  color: var(--admin-text-secondary);
+  font-size: 16px;
+}
+
+// 검색 박스
+.admin--search-box {
+  background: var(--admin-bg-secondary);
+  border: 1px solid var(--admin-border-color);
+  border-radius: 8px;
+  padding: 20px;
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 20px;
+
+  .admin--search-form {
+    display: flex;
+    gap: 12px;
+    flex: 1;
+  }
+
+  .admin--search-select {
+    width: 140px;
+  }
+
+  .admin--search-input {
+    flex: 1;
+    max-width: 400px;
+  }
+
+  .admin--search-actions {
+    display: flex;
+    gap: 12px;
+  }
+}
+
+// 테이블
+.admin--table-wrapper {
+  background: var(--admin-bg-secondary);
+  border: 1px solid var(--admin-border-color);
+  border-radius: 8px;
+  overflow-x: auto;
+}
+
+.admin--table {
+  width: 100%;
+  border-collapse: collapse;
+
+  thead {
+    background: var(--admin-bg-tertiary);
+    border-bottom: 1px solid var(--admin-border-color);
+
+    th {
+      padding: 14px 16px;
+      text-align: left;
+      font-size: 14px;
+      font-weight: 600;
+      color: var(--admin-text-primary);
+      white-space: nowrap;
+    }
+  }
+
+  tbody {
+    tr {
+      border-bottom: 1px solid var(--admin-border-color);
+      transition: background 0.3s ease;
+
+      &:hover {
+        background: var(--admin-bg-tertiary);
+      }
+
+      &:last-child {
+        border-bottom: none;
+      }
+    }
+
+    td {
+      padding: 14px 16px;
+      font-size: 14px;
+      color: var(--admin-text-secondary);
+      vertical-align: middle;
+    }
+
+    .admin--table-title {
+      color: var(--admin-text-primary);
+      font-weight: 500;
+      max-width: 300px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .admin--table-loading,
+    .admin--table-empty {
+      text-align: center;
+      padding: 60px 20px;
+      color: var(--admin-text-muted);
+    }
+  }
+
+  .admin--table-actions {
+    display: flex;
+    gap: 8px;
+  }
+}
+
+// 배지
+.admin--badge {
+  display: inline-block;
+  padding: 4px 12px;
+  border-radius: 12px;
+  font-size: 12px;
+  font-weight: 600;
+  white-space: nowrap;
+
+  &.admin--badge-html {
+    background: rgba(59, 130, 246, 0.1);
+    color: #3b82f6;
+  }
+
+  &.admin--badge-image {
+    background: rgba(16, 185, 129, 0.1);
+    color: #10b981;
+  }
+
+  &.admin--badge-active {
+    background: rgba(16, 185, 129, 0.1);
+    color: var(--admin-success);
+  }
+
+  &.admin--badge-scheduled {
+    background: rgba(245, 158, 11, 0.1);
+    color: var(--admin-warning);
+  }
+
+  &.admin--badge-ended {
+    background: rgba(107, 114, 128, 0.1);
+    color: #6b7280;
+  }
+}
+
+// 작은 버튼
+.admin--btn-small {
+  padding: 6px 14px;
+  border: none;
+  border-radius: 4px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  font-family: 'AudiType', sans-serif;
+  white-space: nowrap;
+
+  &.admin--btn-small-primary {
+    background: var(--admin-accent-primary);
+    color: var(--admin-text-primary);
+
+    &:hover {
+      background: var(--admin-accent-hover);
+    }
+  }
+
+  &.admin--btn-small-danger {
+    background: var(--admin-error);
+    color: var(--admin-text-primary);
+
+    &:hover {
+      background: #dc2626;
+    }
+  }
+}
+
+// 페이지네이션
+.admin--pagination {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 8px;
+  margin-top: 24px;
+
+  .admin--pagination-btn {
+    min-width: 36px;
+    height: 36px;
+    padding: 0 12px;
+    background: var(--admin-bg-secondary);
+    border: 1px solid var(--admin-border-color);
+    border-radius: 6px;
+    color: var(--admin-text-secondary);
+    font-size: 14px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-family: 'AudiType', sans-serif;
+
+    &:hover:not(:disabled) {
+      background: var(--admin-bg-tertiary);
+      border-color: var(--admin-accent-primary);
+      color: var(--admin-text-primary);
+    }
+
+    &.is-active {
+      background: var(--admin-accent-primary);
+      border-color: var(--admin-accent-primary);
+      color: var(--admin-text-primary);
+    }
+
+    &:disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+  }
+}
+
+// 라디오 그룹
+.admin--radio-group {
+  display: flex;
+  gap: 20px;
+  flex-wrap: wrap;
+}
+
+.admin--radio-label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-size: 14px;
+  color: var(--admin-text-secondary);
+  transition: color 0.3s ease;
+
+  input[type="radio"] {
+    cursor: pointer;
+    margin: 0;
+  }
+
+  &:hover {
+    color: var(--admin-text-primary);
+  }
+}
+
+// 날짜 범위
+.admin--date-range {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+
+  .admin--date-separator {
+    color: var(--admin-text-secondary);
+    font-weight: 500;
+  }
+}
+
+// 사이즈 그룹
+.admin--size-group {
+  display: flex;
+  gap: 20px;
+
+  .admin--size-item {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+
+    label {
+      font-size: 14px;
+      color: var(--admin-text-secondary);
+      min-width: 40px;
+    }
+
+    .admin--form-input {
+      max-width: 120px;
+    }
+
+    span {
+      font-size: 14px;
+      color: var(--admin-text-secondary);
+    }
+  }
+}
+
+// 파일 입력
+.admin--form-file {
+  width: 100%;
+  padding: 12px 16px;
+  background: var(--admin-bg-tertiary);
+  border: 1px solid var(--admin-border-color);
+  border-radius: 6px;
+  color: var(--admin-text-primary);
+  font-size: 14px;
+  font-family: 'AudiType', sans-serif;
+  cursor: pointer;
+
+  &::-webkit-file-upload-button {
+    padding: 8px 16px;
+    background: var(--admin-accent-primary);
+    color: var(--admin-text-primary);
+    border: none;
+    border-radius: 4px;
+    font-size: 13px;
+    font-weight: 500;
+    cursor: pointer;
+    margin-right: 12px;
+    font-family: 'AudiType', sans-serif;
+  }
+}
+
+// 이미지 미리보기
+.admin--image-preview {
+  margin-top: 16px;
+  position: relative;
+  display: inline-block;
+  max-width: 400px;
+
+  img {
+    max-width: 100%;
+    border-radius: 8px;
+    border: 1px solid var(--admin-border-color);
+    display: block;
+  }
+
+  .admin--btn-remove-image {
+    position: absolute;
+    top: 12px;
+    right: 12px;
+    padding: 8px 16px;
+    background: var(--admin-error);
+    color: var(--admin-text-primary);
+    border: none;
+    border-radius: 4px;
+    font-size: 13px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-family: 'AudiType', sans-serif;
+
+    &:hover {
+      background: #dc2626;
+    }
+  }
+}
+
+// 비밀번호 입력 래퍼
+.admin--password-input-wrapper {
+  position: relative;
+
+  .admin--form-input {
+    padding-right: 50px;
+  }
+
+  .admin--password-toggle {
+    position: absolute;
+    right: 12px;
+    top: 50%;
+    transform: translateY(-50%);
+    background: none;
+    border: none;
+    font-size: 20px;
+    cursor: pointer;
+    padding: 4px;
+    line-height: 1;
+    transition: opacity 0.3s ease;
+
+    &:hover {
+      opacity: 0.7;
+    }
+  }
+}
+
+// 폼 도움말
+.admin--form-help {
+  margin: 8px 0 0 0;
+  font-size: 13px;
+  color: var(--admin-text-muted);
+  font-style: italic;
+}
+
+// 검색 박스 (큰 버전 - 여러 필터)
+.admin--search-box-large {
+  .admin--search-filters {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+
+    .admin--filter-row {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      flex-wrap: wrap;
+
+      .admin--filter-label {
+        font-size: 14px;
+        color: var(--admin-text-secondary);
+        min-width: 60px;
+      }
+
+      .admin--form-select,
+      .admin--form-input {
+        flex: 1;
+        min-width: 150px;
+      }
+    }
+  }
+}
+
+// 테이블 사진
+.admin--table-photo {
+  width: 50px;
+  height: 50px;
+  border-radius: 4px;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--admin-bg-tertiary);
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  .admin--table-photo-empty {
+    font-size: 11px;
+    color: var(--admin-text-muted);
+    text-align: center;
+  }
+}
+
+// 테이블 액션 (세로 배치)
+.admin--table-actions-col {
+  flex-direction: column;
+  gap: 6px;
+
+  .admin--btn-small {
+    width: 100%;
+  }
+}
+
+// 작은 버튼 (secondary 추가)
+.admin--btn-small-secondary {
+  background: var(--admin-bg-tertiary);
+  color: var(--admin-text-secondary);
+  border: 1px solid var(--admin-border-color);
+
+  &:hover {
+    background: var(--admin-bg-primary);
+    border-color: var(--admin-accent-primary);
+    color: var(--admin-text-primary);
+  }
+}
+
+// Responsive
+@media (max-width: 1024px) {
+  .admin--sidebar {
+    width: 220px;
+  }
+
+  .admin--main {
+    margin-left: 220px;
+  }
+}
+
+@media (max-width: 768px) {
+  .admin--header {
+    padding: 0 16px;
+  }
+
+  .admin--sidebar {
+    transform: translateX(-100%);
+    transition: transform 0.3s ease;
+
+    &.is-open {
+      transform: translateX(0);
+    }
+  }
+
+  .admin--main {
+    margin-left: 0;
+    padding: 16px;
+  }
+
+  .admin--page-header {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 12px;
+  }
+
+  .admin--form {
+    .admin--form-actions {
+      flex-direction: column;
+
+      .admin--btn {
+        width: 100%;
+      }
+    }
+
+    .admin--multi-input-wrapper {
+      .admin--multi-input-item {
+        flex-direction: column;
+
+        .admin--btn-remove {
+          width: 100%;
         }
       }
     }
   }
 }
+
+// File Attachment Styles
+.admin--form-file-hidden {
+  display: none;
+}
+
+.admin--file-list {
+  margin-bottom: 12px;
+  border: 1px solid var(--admin-border-color);
+  border-radius: 4px;
+  background-color: var(--admin-bg-secondary);
+}
+
+.admin--file-item {
+  display: flex;
+  align-items: center;
+  padding: 12px 16px;
+  border-bottom: 1px solid var(--admin-border-color);
+
+  &:last-child {
+    border-bottom: none;
+  }
+
+  &:hover {
+    background-color: rgba(255, 255, 255, 0.03);
+  }
+}
+
+.admin--file-name {
+  flex: 1;
+  color: var(--admin-text-primary);
+  font-size: 14px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-right: 8px;
+
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+.admin--file-size {
+  color: var(--admin-text-secondary);
+  font-size: 13px;
+  margin-right: 12px;
+  white-space: nowrap;
+}
+
+.admin--btn-remove-file {
+  padding: 4px 12px;
+  background-color: transparent;
+  color: var(--admin-danger-color);
+  border: 1px solid var(--admin-danger-color);
+  border-radius: 4px;
+  font-size: 13px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  white-space: nowrap;
+
+  &:hover {
+    background-color: var(--admin-danger-color);
+    color: white;
+  }
+}
+
+// Checkbox Group Styles
+.admin--checkbox-group {
+  display: flex;
+  gap: 20px;
+}
+
+.admin--checkbox-label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: var(--admin-text-primary);
+  font-size: 14px;
+  cursor: pointer;
+
+  input[type="checkbox"] {
+    width: 18px;
+    height: 18px;
+    cursor: pointer;
+    accent-color: var(--admin-accent-primary);
+  }
+
+  span {
+    user-select: none;
+  }
+
+  &:hover {
+    opacity: 0.8;
+  }
+}
+
+// Admin Footer
+.admin--footer {
+  margin-top: 64px;
+  padding: 24px 0;
+  border-top: 1px solid var(--admin-border-color);
+  text-align: center;
+
+  p {
+    color: var(--admin-text-muted);
+    font-size: 14px;
+    margin: 0;
+  }
+}
+
+// IMPORTANT: Force display admin header and footer
+.admin--header {
+  display: flex !important;
+  min-height: 64px !important;
+  background: #1a1a1a !important;
+  visibility: visible !important;
+}
+
+.admin--footer {
+  display: block !important;
+  min-height: 60px !important;
+  background: #1a1a1a !important;
+  visibility: visible !important;
+}
+
+// Alert Modal Styles
+.admin--modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.7);
+  z-index: 9999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.admin--alert-modal {
+  background: #2d2d2d;
+  padding: 0;
+  border-radius: 8px;
+  min-width: 400px;
+  max-width: 500px;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+}
+
+.admin--alert-modal .admin--modal-header {
+  padding: 20px;
+  border-bottom: 1px solid #404040;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  h4 {
+    margin: 0;
+    font-size: 18px;
+    font-weight: 600;
+    color: #ffffff;
+  }
+}
+
+.admin--alert-modal .admin--modal-close {
+  background: none;
+  border: none;
+  font-size: 24px;
+  cursor: pointer;
+  color: #999;
+  line-height: 1;
+  transition: color 0.2s;
+
+  &:hover {
+    color: #fff;
+  }
+}
+
+.admin--alert-modal .admin--modal-body {
+  padding: 30px 20px;
+
+  .admin--alert-content p {
+    margin: 0;
+    font-size: 15px;
+    line-height: 1.6;
+    color: #e0e0e0;
+  }
+}
+
+.admin--alert-modal .admin--modal-footer {
+  padding: 15px 20px;
+  border-top: 1px solid #404040;
+  display: flex;
+  gap: 10px;
+  justify-content: flex-end;
+}
+
+.admin--alert-modal .admin--btn-secondary {
+  padding: 8px 20px;
+  border: 1px solid #404040;
+  background: #252525;
+  color: #e0e0e0;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  transition: all 0.2s;
+
+  &:hover {
+    background: #2d2d2d;
+  }
+}
+
+.admin--alert-modal .admin--btn-primary {
+  padding: 8px 20px;
+  border: none;
+  background: var(--admin-accent-primary);
+  color: white;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  transition: all 0.2s;
+
+  &:hover {
+    background: var(--admin-accent-hover);
+  }
+}
+
+// Modal Animation
+@keyframes slideUp {
+  from {
+    opacity: 0;
+    transform: translateY(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.modal-fade-enter-active {
+  transition: opacity 0.3s ease;
+
+  .admin--alert-modal {
+    animation: slideUp 0.3s ease-out;
+  }
+}
+
+.modal-fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+
+.modal-fade-enter-from,
+.modal-fade-leave-to {
+  opacity: 0;
+}

+ 78 - 0
app/components/admin/AdminAlertModal.vue

@@ -0,0 +1,78 @@
+<template>
+  <Transition name="modal-fade">
+    <div class="admin--modal-overlay" @click.self="handleClose">
+      <div class="admin--modal admin--modal-sm admin--alert-modal">
+        <div class="admin--modal-header">
+          <h4>{{ title }}</h4>
+          <button
+            v-if="!hideClose"
+            @click="handleClose"
+            class="admin--modal-close"
+          >&times;</button>
+        </div>
+
+        <div class="admin--modal-body">
+          <div class="admin--alert-content">
+            <p>{{ message }}</p>
+          </div>
+        </div>
+
+        <div class="admin--modal-footer">
+          <button
+            v-if="type === 'confirm'"
+            @click="handleCancel"
+            class="admin--btn-secondary"
+          >
+            취소
+          </button>
+          <button
+            @click="handleConfirm"
+            class="admin--btn-primary"
+          >
+            확인
+          </button>
+        </div>
+      </div>
+    </div>
+  </Transition>
+</template>
+
+<script setup>
+const props = defineProps({
+  title: {
+    type: String,
+    default: '알림'
+  },
+  message: {
+    type: String,
+    required: true
+  },
+  type: {
+    type: String,
+    default: 'alert', // 'alert' or 'confirm'
+    validator: (value) => ['alert', 'confirm'].includes(value)
+  },
+  hideClose: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(['confirm', 'cancel', 'close'])
+
+const handleConfirm = () => {
+  emit('confirm')
+  emit('close')
+}
+
+const handleCancel = () => {
+  emit('cancel')
+  emit('close')
+}
+
+const handleClose = () => {
+  if (!props.hideClose) {
+    emit('close')
+  }
+}
+</script>

+ 201 - 0
app/components/admin/AdminModal.vue

@@ -0,0 +1,201 @@
+<template>
+  <div class="admin--modal-overlay" @click.self="close">
+    <div class="admin--modal">
+      <div class="admin--modal-header">
+        <h4>{{ isEdit ? '관리자 수정' : '관리자 추가' }}</h4>
+        <button @click="close" class="admin--modal-close">&times;</button>
+      </div>
+
+      <div class="admin--modal-body">
+        <form @submit.prevent="save">
+          <div class="admin--form-group">
+            <label class="admin--label">아이디 *</label>
+            <input
+              v-model="formData.username"
+              type="text"
+              class="admin--input"
+              required
+              :disabled="isEdit"
+              placeholder="영문, 숫자 조합"
+            />
+          </div>
+
+          <div class="admin--form-group">
+            <label class="admin--label">이름 *</label>
+            <input
+              v-model="formData.name"
+              type="text"
+              class="admin--input"
+              required
+              placeholder="관리자 이름"
+            />
+          </div>
+
+          <div class="admin--form-group">
+            <label class="admin--label">이메일 *</label>
+            <input
+              v-model="formData.email"
+              type="email"
+              class="admin--input"
+              required
+              placeholder="example@email.com"
+            />
+          </div>
+
+          <div class="admin--form-group" v-if="!isEdit">
+            <label class="admin--label">비밀번호 *</label>
+            <input
+              v-model="formData.password"
+              type="password"
+              class="admin--input"
+              :required="!isEdit"
+              placeholder="최소 8자 이상"
+              minlength="8"
+            />
+          </div>
+
+          <div class="admin--form-group" v-if="!isEdit">
+            <label class="admin--label">비밀번호 확인 *</label>
+            <input
+              v-model="formData.password_confirm"
+              type="password"
+              class="admin--input"
+              :required="!isEdit"
+              placeholder="비밀번호 재입력"
+              minlength="8"
+            />
+          </div>
+
+          <div class="admin--form-group">
+            <label class="admin--label">역할 *</label>
+            <select v-model="formData.role" class="admin--select" required>
+              <option value="admin">일반 관리자</option>
+              <option value="super_admin">슈퍼 관리자</option>
+            </select>
+          </div>
+
+          <div class="admin--form-group">
+            <label class="admin--label">상태 *</label>
+            <select v-model="formData.status" class="admin--select" required>
+              <option value="active">활성</option>
+              <option value="inactive">비활성</option>
+            </select>
+          </div>
+
+          <div class="admin--modal-footer">
+            <button type="button" @click="close" class="admin--btn-secondary">
+              취소
+            </button>
+            <button type="submit" class="admin--btn-primary" :disabled="saving">
+              {{ saving ? '저장 중...' : '저장' }}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+
+const props = defineProps({
+  admin: {
+    type: Object,
+    default: null
+  }
+})
+
+const emit = defineEmits(['close', 'saved'])
+
+const { post, put } = useApi()
+
+const isEdit = computed(() => !!props.admin)
+const saving = ref(false)
+
+const formData = ref({
+  username: '',
+  name: '',
+  email: '',
+  password: '',
+  password_confirm: '',
+  role: 'admin',
+  status: 'active'
+})
+
+// props.admin이 변경되면 formData 업데이트
+watch(() => props.admin, (newAdmin) => {
+  if (newAdmin) {
+    formData.value = {
+      username: newAdmin.username || '',
+      name: newAdmin.name || '',
+      email: newAdmin.email || '',
+      password: '',
+      password_confirm: '',
+      role: newAdmin.role || 'admin',
+      status: newAdmin.status || 'active'
+    }
+  }
+}, { immediate: true })
+
+const close = () => {
+  emit('close')
+}
+
+const save = async () => {
+  // 비밀번호 확인 (신규 생성 시)
+  if (!isEdit.value) {
+    if (formData.value.password !== formData.value.password_confirm) {
+      emit('saved', '비밀번호가 일치하지 않습니다.')
+      return
+    }
+    if (formData.value.password.length < 8) {
+      emit('saved', '비밀번호는 최소 8자 이상이어야 합니다.')
+      return
+    }
+  }
+
+  saving.value = true
+
+  try {
+    let result
+
+    if (isEdit.value) {
+      // 수정
+      const updateData = {
+        name: formData.value.name,
+        email: formData.value.email,
+        role: formData.value.role,
+        status: formData.value.status
+      }
+      result = await put(`/admin/${props.admin.id}`, updateData)
+    } else {
+      // 생성
+      const createData = {
+        username: formData.value.username,
+        name: formData.value.name,
+        email: formData.value.email,
+        password: formData.value.password,
+        role: formData.value.role,
+        status: formData.value.status
+      }
+      result = await post('/admin', createData)
+    }
+
+    const { data, error } = result
+
+    if (error) {
+      console.error('[AdminModal] 저장 실패:', error)
+      const errorMessage = error.response?.data?.message || '저장에 실패했습니다.'
+      emit('saved', errorMessage)
+      return
+    }
+
+    if (data?.success) {
+      emit('saved', data.message || '저장되었습니다.')
+    }
+  } finally {
+    saving.value = false
+  }
+}
+</script>

+ 146 - 0
app/components/admin/PasswordModal.vue

@@ -0,0 +1,146 @@
+<template>
+  <div class="admin--modal-overlay" @click.self="close">
+    <div class="admin--modal admin--modal-sm">
+      <div class="admin--modal-header">
+        <h4>비밀번호 변경</h4>
+        <button @click="close" class="admin--modal-close">&times;</button>
+      </div>
+
+      <div class="admin--modal-body">
+        <div class="admin--info-box">
+          <p><strong>관리자:</strong> {{ admin.name }} ({{ admin.username }})</p>
+        </div>
+
+        <form @submit.prevent="save">
+          <div class="admin--form-group" v-if="isCurrentUser">
+            <label class="admin--label">현재 비밀번호</label>
+            <input
+              v-model="formData.current_password"
+              type="password"
+              class="admin--input"
+              placeholder="현재 비밀번호 입력"
+            />
+            <small class="admin--help-text">본인 계정의 경우 현재 비밀번호를 입력해주세요.</small>
+          </div>
+
+          <div class="admin--form-group">
+            <label class="admin--label">새 비밀번호 *</label>
+            <input
+              v-model="formData.new_password"
+              type="password"
+              class="admin--input"
+              required
+              placeholder="최소 8자 이상"
+              minlength="8"
+            />
+          </div>
+
+          <div class="admin--form-group">
+            <label class="admin--label">새 비밀번호 확인 *</label>
+            <input
+              v-model="formData.new_password_confirm"
+              type="password"
+              class="admin--input"
+              required
+              placeholder="새 비밀번호 재입력"
+              minlength="8"
+            />
+          </div>
+
+          <div class="admin--modal-footer">
+            <button type="button" @click="close" class="admin--btn-secondary">
+              취소
+            </button>
+            <button type="submit" class="admin--btn-primary" :disabled="saving">
+              {{ saving ? '변경 중...' : '변경' }}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+
+const props = defineProps({
+  admin: {
+    type: Object,
+    required: true
+  }
+})
+
+const emit = defineEmits(['close', 'saved'])
+
+const { post } = useApi()
+
+const saving = ref(false)
+
+// 현재 로그인한 관리자 ID
+const currentAdminId = computed(() => {
+  if (typeof window === 'undefined') return null
+  const user = localStorage.getItem('admin_user')
+  if (!user) return null
+  try {
+    return JSON.parse(user).id
+  } catch {
+    return null
+  }
+})
+
+const isCurrentUser = computed(() => {
+  return currentAdminId.value === props.admin.id
+})
+
+const formData = ref({
+  current_password: '',
+  new_password: '',
+  new_password_confirm: ''
+})
+
+const close = () => {
+  emit('close')
+}
+
+const save = async () => {
+  // 비밀번호 확인
+  if (formData.value.new_password !== formData.value.new_password_confirm) {
+    emit('saved', '새 비밀번호가 일치하지 않습니다.')
+    return
+  }
+
+  if (formData.value.new_password.length < 8) {
+    emit('saved', '비밀번호는 최소 8자 이상이어야 합니다.')
+    return
+  }
+
+  saving.value = true
+
+  try {
+    const requestData = {
+      new_password: formData.value.new_password
+    }
+
+    // 본인 계정인 경우 현재 비밀번호 포함
+    if (isCurrentUser.value && formData.value.current_password) {
+      requestData.current_password = formData.value.current_password
+    }
+
+    const { data, error } = await post(`/admin/${props.admin.id}/password`, requestData)
+
+    if (error) {
+      console.error('[PasswordModal] 변경 실패:', error)
+      const errorMessage = error.response?.data?.message || '비밀번호 변경에 실패했습니다.'
+      emit('saved', errorMessage)
+      return
+    }
+
+    if (data?.success) {
+      emit('saved', '비밀번호가 변경되었습니다.')
+    }
+  } finally {
+    saving.value = false
+  }
+}
+</script>

+ 126 - 0
app/components/admin/SunEditor.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="admin--suneditor-wrapper">
+    <div ref="editorElement" class="admin--suneditor"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
+import suneditor from 'suneditor'
+import 'suneditor/dist/css/suneditor.min.css'
+import plugins from 'suneditor/src/plugins'
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    default: ''
+  },
+  height: {
+    type: String,
+    default: '400px'
+  },
+  placeholder: {
+    type: String,
+    default: '내용을 입력하세요...'
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const editorElement = ref(null)
+let editorInstance = null
+
+onMounted(() => {
+  if (editorElement.value) {
+    editorInstance = suneditor.create(editorElement.value, {
+      plugins: plugins,
+      height: props.height,
+      buttonList: [
+        ['undo', 'redo'],
+        ['font', 'fontSize', 'formatBlock'],
+        ['bold', 'underline', 'italic', 'strike', 'subscript', 'superscript'],
+        ['fontColor', 'hiliteColor'],
+        ['removeFormat'],
+        ['outdent', 'indent'],
+        ['align', 'horizontalRule', 'list', 'table'],
+        ['link', 'image', 'video'],
+        ['fullScreen', 'showBlocks', 'codeView'],
+        ['preview', 'print']
+      ],
+      font: ['Arial', 'Courier New', 'Georgia', 'Tahoma', 'Trebuchet MS', 'Verdana', 'AudiType'],
+      formats: ['p', 'div', 'blockquote', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+      colorList: [
+        '#ff0000', '#ff5e00', '#ffe400', '#abf200', '#00d8ff', '#0055ff', '#6600ff', '#ff00dd',
+        '#000000', '#ffffff', '#888888', '#cccccc'
+      ],
+      imageResizing: true,
+      imageHeightShow: true,
+      imageWidth: '100%',
+      imageUploadUrl: '/api/upload/image',
+      placeholder: props.placeholder
+    })
+
+    // 초기값 설정
+    if (props.modelValue) {
+      editorInstance.setContents(props.modelValue)
+    }
+
+    // 내용 변경 이벤트
+    editorInstance.onChange = (contents) => {
+      emit('update:modelValue', contents)
+    }
+  }
+})
+
+// modelValue 변경 감지
+watch(() => props.modelValue, (newValue) => {
+  if (editorInstance && editorInstance.getContents() !== newValue) {
+    editorInstance.setContents(newValue || '')
+  }
+})
+
+onBeforeUnmount(() => {
+  if (editorInstance) {
+    editorInstance.destroy()
+  }
+})
+</script>
+
+<style scoped>
+.admin--suneditor-wrapper {
+  width: 100%;
+}
+
+.admin--suneditor-wrapper :deep(.sun-editor) {
+  background: var(--admin-bg-tertiary);
+  border: 1px solid var(--admin-border-color);
+  border-radius: 6px;
+}
+
+.admin--suneditor-wrapper :deep(.se-toolbar) {
+  background: var(--admin-bg-secondary);
+  border-bottom: 1px solid var(--admin-border-color);
+}
+
+.admin--suneditor-wrapper :deep(.se-btn) {
+  color: var(--admin-text-secondary);
+}
+
+.admin--suneditor-wrapper :deep(.se-btn:hover) {
+  background: var(--admin-bg-tertiary);
+  color: var(--admin-text-primary);
+}
+
+.admin--suneditor-wrapper :deep(.se-wrapper) {
+  background: var(--admin-bg-tertiary);
+  color: var(--admin-text-primary);
+}
+
+.admin--suneditor-wrapper :deep(.se-wrapper-inner) {
+  background: var(--admin-bg-tertiary);
+}
+
+.admin--suneditor-wrapper :deep(.se-placeholder) {
+  color: var(--admin-text-muted);
+}
+</style>

+ 24 - 22
app/components/footer.vue

@@ -110,28 +110,30 @@
           </li>
         </ul>
         <ul>
-            <li>
-                <h2>Company</h2>
-                <ul>
-                    <li><NuxtLink to="/">About Gojin Motors</NuxtLink></li>
-                    <li><NuxtLink to="/">CEO</NuxtLink></li>
-                    <li><NuxtLink to="/">History</NuxtLink></li>
-                    <li><NuxtLink to="/">IR</NuxtLink></li>
-                    <li><NuxtLink to="/">Showroom</NuxtLink></li>
-                    <li><NuxtLink to="/">Service Center</NuxtLink></li>
-                    <li><NuxtLink to="/">Career</NuxtLink></li>
-                    <li><NuxtLink to="/">Sales Advisor</NuxtLink></li>
-                    <li><NuxtLink to="/">견적요청</NuxtLink></li>
-                    <li><NuxtLink to="/">시승요청</NuxtLink></li>
-                    <li><NuxtLink to="/">e-카탈로그</NuxtLink></li>
-                    <li><NuxtLink to="/">Event</NuxtLink></li>
-                    <li><NuxtLink to="/">Audi News</NuxtLink></li>
-                </ul>
-            </li>
-            <li>
-                <h2>Used Car(AAP)</h2>
-                <li><NuxtLink to="/">Used Car(AAP)</NuxtLink></li>
-            </li>
+          <li>
+            <h2>Company</h2>
+            <ul>
+              <li><NuxtLink to="/">About Gojin Motors</NuxtLink></li>
+              <li><NuxtLink to="/">CEO</NuxtLink></li>
+              <li><NuxtLink to="/">History</NuxtLink></li>
+              <li><NuxtLink to="/">IR</NuxtLink></li>
+              <li><NuxtLink to="/">Showroom</NuxtLink></li>
+              <li><NuxtLink to="/">Service Center</NuxtLink></li>
+              <li><NuxtLink to="/">Career</NuxtLink></li>
+              <li><NuxtLink to="/">Sales Advisor</NuxtLink></li>
+              <li><NuxtLink to="/">견적요청</NuxtLink></li>
+              <li><NuxtLink to="/">시승요청</NuxtLink></li>
+              <li><NuxtLink to="/">e-카탈로그</NuxtLink></li>
+              <li><NuxtLink to="/">Event</NuxtLink></li>
+              <li><NuxtLink to="/">Audi News</NuxtLink></li>
+            </ul>
+          </li>
+          <li>
+            <h2>Used Car(AAP)</h2>
+            <ul>
+              <li><NuxtLink to="/">Used Car(AAP)</NuxtLink></li>
+            </ul>
+          </li>
         </ul>
       </div>
       <div class="footer--menu--wrap">뿌터</div>

+ 134 - 0
app/composables/useApi.js

@@ -0,0 +1,134 @@
+import axios from 'axios'
+
+// API Base URL (환경변수 또는 기본값)
+const API_BASE_URL = process.env.NUXT_PUBLIC_API_BASE || 'http://gojinaudi.mycafe24.com/api'
+
+// Axios 인스턴스 생성
+const apiClient = axios.create({
+  baseURL: API_BASE_URL,
+  timeout: 30000,
+  headers: {
+    'Content-Type': 'application/json'
+  }
+})
+
+// Request Interceptor (토큰 자동 추가)
+apiClient.interceptors.request.use(
+  (config) => {
+    if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') {
+      const token = localStorage.getItem('admin_token')
+      if (token) {
+        config.headers.Authorization = `Bearer ${token}`
+        console.log('[useApi] Request with token:', config.url, token.substring(0, 20) + '...')
+      } else {
+        console.log('[useApi] Request without token:', config.url)
+      }
+    }
+    return config
+  },
+  (error) => {
+    return Promise.reject(error)
+  }
+)
+
+// Response Interceptor (에러 처리)
+apiClient.interceptors.response.use(
+  (response) => {
+    console.log('[useApi] Response:', response.config.url, response.status)
+    return response
+  },
+  (error) => {
+    const status = error.response?.status
+    const url = error.config?.url
+
+    console.log('[useApi] Error:', {
+      url,
+      status,
+      message: error.message,
+      hasToken: !!localStorage.getItem('admin_token')
+    })
+
+    // 401 에러만 토큰 삭제 및 로그아웃 처리
+    if (status === 401) {
+      console.log('[useApi] 401 Unauthorized - 토큰 삭제 및 로그아웃')
+      if (typeof window !== 'undefined') {
+        localStorage.removeItem('admin_token')
+        localStorage.removeItem('admin_user')
+        window.location.href = '/admin'
+      }
+    } else {
+      console.log('[useApi] 401이 아닌 에러 - 토큰 유지')
+    }
+
+    return Promise.reject(error)
+  }
+)
+
+export const useApi = () => {
+  // GET 요청
+  const get = async (url, params = {}) => {
+    try {
+      const response = await apiClient.get(url, { params })
+      // CodeIgniter API 응답 구조: { success, data, message }
+      return { data: response.data.data, error: null }
+    } catch (error) {
+      return { data: null, error: error.response?.data || error.message }
+    }
+  }
+
+  // POST 요청
+  const post = async (url, data = {}) => {
+    try {
+      const response = await apiClient.post(url, data)
+      // CodeIgniter API 응답 구조: { success, data, message }
+      return { data: response.data.data, error: null }
+    } catch (error) {
+      return { data: null, error: error.response?.data || error.message }
+    }
+  }
+
+  // PUT 요청
+  const put = async (url, data = {}) => {
+    try {
+      const response = await apiClient.put(url, data)
+      // CodeIgniter API 응답 구조: { success, data, message }
+      return { data: response.data.data, error: null }
+    } catch (error) {
+      return { data: null, error: error.response?.data || error.message }
+    }
+  }
+
+  // DELETE 요청
+  const del = async (url) => {
+    try {
+      const response = await apiClient.delete(url)
+      // CodeIgniter API 응답 구조: { success, data, message }
+      return { data: response.data.data, error: null }
+    } catch (error) {
+      return { data: null, error: error.response?.data || error.message }
+    }
+  }
+
+  // 파일 업로드
+  const upload = async (url, formData) => {
+    try {
+      const response = await apiClient.post(url, formData, {
+        headers: {
+          'Content-Type': 'multipart/form-data'
+        }
+      })
+      // CodeIgniter API 응답 구조: { success, data, message }
+      return { data: response.data.data, error: null }
+    } catch (error) {
+      return { data: null, error: error.response?.data || error.message }
+    }
+  }
+
+  return {
+    get,
+    post,
+    put,
+    del,
+    upload
+  }
+}

+ 230 - 0
app/layouts/admin.vue

@@ -0,0 +1,230 @@
+<template>
+  <div class="admin--layout">
+    <!-- Header -->
+    <header class="admin--header">
+      <div class="admin--header-left">
+        <div class="admin--logo">
+          <h1>AUDI</h1>
+          <span class="admin--logo-sub">고진모터스</span>
+        </div>
+      </div>
+      <div class="admin--header-right">
+        <button class="admin--header-btn" @click="goToProfile">
+          정보수정
+        </button>
+        <button
+          type="button"
+          class="admin--header-btn admin--header-btn-logout"
+          @click.prevent="handleLogout"
+        >
+          로그아웃
+        </button>
+      </div>
+    </header>
+
+    <!-- Main Content Area -->
+    <div class="admin--content-wrapper">
+      <!-- Sidebar GNB -->
+      <aside class="admin--sidebar">
+        <nav class="admin--gnb">
+          <div
+            v-for="menu in menuItems"
+            :key="menu.id"
+            class="admin--gnb-group"
+          >
+            <div
+              class="admin--gnb-title"
+              @click="toggleMenu(menu.id)"
+            >
+              {{ menu.title }}
+              <span class="admin--gnb-arrow" :class="{ 'is-open': openMenus.includes(menu.id) }">
+                ▼
+              </span>
+            </div>
+            <transition name="admin--submenu">
+              <ul v-show="openMenus.includes(menu.id)" class="admin--gnb-submenu">
+                <li
+                  v-for="submenu in menu.children"
+                  :key="submenu.path"
+                  class="admin--gnb-item"
+                  :class="{ 'is-active': isActiveRoute(submenu.path) }"
+                >
+                  <NuxtLink :to="submenu.path" class="admin--gnb-link">
+                    {{ submenu.title }}
+                  </NuxtLink>
+                </li>
+              </ul>
+            </transition>
+          </div>
+        </nav>
+      </aside>
+
+      <!-- Content Area -->
+      <main class="admin--main">
+        <!-- Breadcrumb & Title -->
+        <div class="admin--page-header">
+          <h2 class="admin--page-title">{{ pageTitle }}</h2>
+          <div class="admin--breadcrumb">
+            <span v-for="(crumb, index) in breadcrumbs" :key="index">
+              <NuxtLink v-if="crumb.path" :to="crumb.path" class="admin--breadcrumb-link">
+                {{ crumb.title }}
+              </NuxtLink>
+              <span v-else class="admin--breadcrumb-current">{{ crumb.title }}</span>
+              <span v-if="index < breadcrumbs.length - 1" class="admin--breadcrumb-separator">/</span>
+            </span>
+          </div>
+        </div>
+
+        <!-- Page Content -->
+        <div class="admin--page-content">
+          <slot />
+        </div>
+
+        <!-- Admin Footer -->
+        <footer class="admin--footer">
+          <p>&copy; {{ new Date().getFullYear() }} Audi 고진모터스. All rights reserved.</p>
+        </footer>
+      </main>
+    </div>
+
+    <!-- 로그아웃 확인 모달 -->
+    <AdminAlertModal
+      v-if="showLogoutModal"
+      title="로그아웃"
+      message="로그아웃 하시겠습니까?"
+      type="confirm"
+      @confirm="confirmLogout"
+      @cancel="closeLogoutModal"
+      @close="closeLogoutModal"
+    />
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import AdminAlertModal from '~/components/admin/AdminAlertModal.vue'
+
+const route = useRoute()
+const router = useRouter()
+
+// 메뉴 열림 상태 관리
+const openMenus = ref(['basic', 'branch', 'staff', 'service', 'board', 'system'])
+
+// GNB 메뉴 구조
+const menuItems = ref([
+  {
+    id: 'basic',
+    title: '기본정보관리',
+    children: [
+      { title: '사이트 정보', path: '/admin/basic/site-info' },
+      { title: '팝업관리', path: '/admin/basic/popup' }
+    ]
+  },
+  {
+    id: 'branch',
+    title: '지점장관리',
+    children: [
+      { title: '지점목록', path: '/admin/branch/list' },
+      { title: '지점장목록', path: '/admin/branch/manager' }
+    ]
+  },
+  {
+    id: 'staff',
+    title: '사원관리',
+    children: [
+      { title: '영업사원관리', path: '/admin/staff/sales' },
+      { title: '어드바이저등록', path: '/admin/staff/advisor' }
+    ]
+  },
+  {
+    id: 'service',
+    title: '서비스관리',
+    children: [
+      { title: '브로셔요청', path: '/admin/service/brochure' }
+    ]
+  },
+  {
+    id: 'board',
+    title: '게시판관리',
+    children: [
+      { title: '이벤트', path: '/admin/board/event' },
+      { title: '뉴스', path: '/admin/board/news' },
+      { title: 'IR', path: '/admin/board/ir' }
+    ]
+  },
+  {
+    id: 'system',
+    title: '시스템관리',
+    children: [
+      { title: '관리자관리', path: '/admin/admins' }
+    ]
+  }
+])
+
+// 메뉴 토글
+const toggleMenu = (menuId) => {
+  const index = openMenus.value.indexOf(menuId)
+  if (index > -1) {
+    openMenus.value.splice(index, 1)
+  } else {
+    openMenus.value.push(menuId)
+  }
+}
+
+// 현재 활성 라우트 체크
+const isActiveRoute = (path) => {
+  return route.path === path
+}
+
+// 페이지 타이틀 계산
+const pageTitle = computed(() => {
+  for (const menu of menuItems.value) {
+    const found = menu.children.find(child => child.path === route.path)
+    if (found) return found.title
+  }
+  return '대시보드'
+})
+
+// Breadcrumb 계산
+const breadcrumbs = computed(() => {
+  const crumbs = [{ title: 'Home', path: '/admin/dashboard' }]
+
+  for (const menu of menuItems.value) {
+    const found = menu.children.find(child => child.path === route.path)
+    if (found) {
+      crumbs.push({ title: menu.title, path: null })
+      crumbs.push({ title: found.title, path: null })
+      break
+    }
+  }
+
+  return crumbs
+})
+
+// 로그아웃 모달
+const showLogoutModal = ref(false)
+
+const handleLogout = () => {
+  console.log('[Logout] 로그아웃 버튼 클릭')
+  showLogoutModal.value = true
+  console.log('[Logout] showLogoutModal:', showLogoutModal.value)
+}
+
+const confirmLogout = () => {
+  console.log('[Logout] 로그아웃 확인')
+  localStorage.removeItem('admin_token')
+  localStorage.removeItem('admin_user')
+  router.push('/admin')
+}
+
+const closeLogoutModal = () => {
+  console.log('[Logout] 모달 닫기')
+  showLogoutModal.value = false
+}
+
+// 정보수정
+const goToProfile = () => {
+  router.push('/admin/profile')
+}
+</script>

+ 38 - 0
app/middleware/auth.js

@@ -0,0 +1,38 @@
+export default defineNuxtRouteMiddleware((to) => {
+  // SSR에서는 middleware 실행 안 함 (클라이언트에서만)
+  if (import.meta.server) {
+    return
+  }
+
+  // Admin 페이지가 아니면 middleware 실행 안 함
+  if (!to.path.startsWith('/admin')) {
+    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 === '/admin' || to.path === '/admin/') {
+    // 이미 로그인된 경우 대시보드로
+    if (token) {
+      console.log('[Auth] 이미 로그인됨, dashboard로 이동')
+      return navigateTo('/admin/dashboard')
+    }
+    console.log('[Auth] 로그인 페이지 접근 허용')
+    return
+  }
+
+  // Admin 페이지 접근 시 토큰 체크
+  if (!token) {
+    console.log('[Auth] 토큰 없음, 로그인 페이지로 이동')
+    return navigateTo('/admin')
+  }
+
+  console.log('[Auth] 토큰 확인됨, 페이지 접근 허용')
+})

+ 394 - 0
app/pages/admin/admins/index.vue

@@ -0,0 +1,394 @@
+<template>
+  <div class="admin--admins">
+    <div class="admin--page-header">
+      <h3>관리자 관리</h3>
+      <button @click="openCreateModal" class="admin--btn-primary">
+        <span>+</span> 관리자 추가
+      </button>
+    </div>
+
+    <!-- 검색 및 필터 -->
+    <div class="admin--search-area">
+      <div class="admin--search-box">
+        <input
+          v-model="searchQuery"
+          type="text"
+          placeholder="아이디, 이름, 이메일로 검색"
+          @keyup.enter="loadAdmins"
+          class="admin--input"
+        />
+        <button @click="loadAdmins" class="admin--btn-search">검색</button>
+      </div>
+
+      <div class="admin--filters">
+        <select v-model="filterRole" @change="loadAdmins" class="admin--select">
+          <option value="">전체 역할</option>
+          <option value="super_admin">슈퍼 관리자</option>
+          <option value="admin">일반 관리자</option>
+        </select>
+
+        <select v-model="filterStatus" @change="loadAdmins" class="admin--select">
+          <option value="">전체 상태</option>
+          <option value="active">활성</option>
+          <option value="inactive">비활성</option>
+        </select>
+      </div>
+    </div>
+
+    <!-- 관리자 목록 테이블 -->
+    <div class="admin--table-container">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>ID</th>
+            <th>아이디</th>
+            <th>이름</th>
+            <th>이메일</th>
+            <th>역할</th>
+            <th>상태</th>
+            <th>생성일</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="loading">
+            <td colspan="8" class="admin--loading">로딩 중...</td>
+          </tr>
+          <tr v-else-if="admins.length === 0">
+            <td colspan="8" class="admin--no-data">관리자가 없습니다.</td>
+          </tr>
+          <tr v-else v-for="admin in admins" :key="admin.id">
+            <td>{{ admin.id }}</td>
+            <td>{{ admin.username }}</td>
+            <td>{{ admin.name }}</td>
+            <td>{{ admin.email }}</td>
+            <td>
+              <span :class="['admin--badge', getRoleBadgeClass(admin.role)]">
+                {{ getRoleLabel(admin.role) }}
+              </span>
+            </td>
+            <td>
+              <span :class="['admin--badge', getStatusBadgeClass(admin.status)]">
+                {{ getStatusLabel(admin.status) }}
+              </span>
+            </td>
+            <td>{{ formatDate(admin.created_at) }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button @click="openEditModal(admin)" class="admin--btn-sm admin--btn-edit">
+                  수정
+                </button>
+                <button @click="openPasswordModal(admin)" class="admin--btn-sm admin--btn-warning">
+                  비밀번호
+                </button>
+                <button
+                  @click="confirmDeleteAdmin(admin)"
+                  class="admin--btn-sm admin--btn-delete"
+                  :disabled="admin.id === currentAdminId"
+                >
+                  삭제
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div class="admin--pagination" v-if="totalPages > 1">
+      <button
+        @click="changePage(currentPage - 1)"
+        :disabled="currentPage === 1"
+        class="admin--btn-page"
+      >
+        이전
+      </button>
+      <span class="admin--page-info">
+        {{ currentPage }} / {{ totalPages }}
+      </span>
+      <button
+        @click="changePage(currentPage + 1)"
+        :disabled="currentPage === totalPages"
+        class="admin--btn-page"
+      >
+        다음
+      </button>
+    </div>
+
+    <!-- 관리자 추가/수정 모달 -->
+    <AdminModal
+      v-if="showModal"
+      :admin="selectedAdmin"
+      @close="closeModal"
+      @saved="handleSaved"
+    />
+
+    <!-- 비밀번호 변경 모달 -->
+    <PasswordModal
+      v-if="showPasswordModal"
+      :admin="selectedAdmin"
+      @close="closePasswordModal"
+      @saved="handlePasswordChanged"
+    />
+
+    <!-- 알림 모달 -->
+    <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, onMounted, computed } from 'vue'
+import AdminAlertModal from '~/components/admin/AdminAlertModal.vue'
+import AdminModal from '~/components/admin/AdminModal.vue'
+import PasswordModal from '~/components/admin/PasswordModal.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const { get, del } = useApi()
+
+// 현재 로그인한 관리자 ID
+const currentAdminId = computed(() => {
+  if (typeof window === 'undefined') return null
+  const user = localStorage.getItem('admin_user')
+  if (!user) return null
+  try {
+    return JSON.parse(user).id
+  } catch {
+    return null
+  }
+})
+
+// 데이터
+const admins = ref([])
+const loading = ref(false)
+const searchQuery = ref('')
+const filterRole = ref('')
+const filterStatus = ref('')
+
+// 페이지네이션
+const currentPage = ref(1)
+const totalPages = ref(1)
+const perPage = ref(10)
+
+// 모달
+const showModal = ref(false)
+const showPasswordModal = ref(false)
+const selectedAdmin = ref(null)
+
+// 알림 모달
+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 loadAdmins = async () => {
+  loading.value = true
+  try {
+    const params = {
+      page: currentPage.value,
+      per_page: perPage.value
+    }
+
+    if (searchQuery.value) {
+      params.search = searchQuery.value
+    }
+    if (filterRole.value) {
+      params.role = filterRole.value
+    }
+    if (filterStatus.value) {
+      params.status = filterStatus.value
+    }
+
+    const { data, error } = await get('/admin', { params })
+
+    if (error) {
+      console.error('[Admins] 목록 로드 실패:', error)
+      return
+    }
+
+    if (data) {
+      admins.value = data.items || []
+      totalPages.value = data.total_pages || 1
+      console.log('[Admins] 목록 로드 성공:', data)
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+// 페이지 변경
+const changePage = (page) => {
+  if (page < 1 || page > totalPages.value) return
+  currentPage.value = page
+  loadAdmins()
+}
+
+// 모달 열기/닫기
+const openCreateModal = () => {
+  selectedAdmin.value = null
+  showModal.value = true
+}
+
+const openEditModal = (admin) => {
+  selectedAdmin.value = { ...admin }
+  showModal.value = true
+}
+
+const closeModal = () => {
+  showModal.value = false
+  selectedAdmin.value = null
+}
+
+const handleSaved = (message) => {
+  closeModal()
+  loadAdmins()
+  if (message) {
+    showAlert(message, '성공')
+  }
+}
+
+// 비밀번호 모달
+const openPasswordModal = (admin) => {
+  selectedAdmin.value = { ...admin }
+  showPasswordModal.value = true
+}
+
+const closePasswordModal = () => {
+  showPasswordModal.value = false
+  selectedAdmin.value = null
+}
+
+const handlePasswordChanged = (message) => {
+  closePasswordModal()
+  if (message) {
+    showAlert(message, '성공')
+  }
+}
+
+// 관리자 삭제 확인
+const confirmDeleteAdmin = (admin) => {
+  if (admin.id === currentAdminId.value) {
+    showAlert('본인 계정은 삭제할 수 없습니다.', '경고')
+    return
+  }
+
+  showConfirm(
+    `${admin.name} (${admin.username}) 관리자를 삭제하시겠습니까?`,
+    () => deleteAdmin(admin),
+    '관리자 삭제'
+  )
+}
+
+// 관리자 삭제
+const deleteAdmin = async (admin) => {
+  const { data, error } = await del(`/admin/${admin.id}`)
+
+  if (error) {
+    showAlert('관리자 삭제에 실패했습니다.', '오류')
+    console.error('[Admins] 삭제 실패:', error)
+    return
+  }
+
+  if (data?.success) {
+    showAlert('관리자가 삭제되었습니다.', '성공')
+    loadAdmins()
+  }
+}
+
+// 유틸리티 함수
+const getRoleLabel = (role) => {
+  const labels = {
+    super_admin: '슈퍼 관리자',
+    admin: '일반 관리자'
+  }
+  return labels[role] || role
+}
+
+const getRoleBadgeClass = (role) => {
+  return role === 'super_admin' ? 'admin--badge-danger' : 'admin--badge-primary'
+}
+
+const getStatusLabel = (status) => {
+  const labels = {
+    active: '활성',
+    inactive: '비활성'
+  }
+  return labels[status] || status
+}
+
+const getStatusBadgeClass = (status) => {
+  return status === 'active' ? 'admin--badge-success' : 'admin--badge-secondary'
+}
+
+const formatDate = (dateString) => {
+  if (!dateString) return '-'
+  const date = new Date(dateString)
+  return date.toLocaleString('ko-KR', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit'
+  })
+}
+
+onMounted(() => {
+  loadAdmins()
+})
+</script>

+ 364 - 0
app/pages/admin/basic/popup/create.vue

@@ -0,0 +1,364 @@
+<template>
+  <div class="admin--popup-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 형태 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">형태 <span class="admin--required">*</span></label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.type"
+              type="radio"
+              value="html"
+              name="type"
+            >
+            <span>HTML</span>
+          </label>
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.type"
+              type="radio"
+              value="image"
+              name="type"
+            >
+            <span>단일페이지</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="팝업 제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 시작일/종료일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">시작일/종료일 <span class="admin--required">*</span></label>
+        <div class="admin--date-range">
+          <input
+            v-model="formData.start_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+          <span class="admin--date-separator">~</span>
+          <input
+            v-model="formData.end_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+        </div>
+      </div>
+
+      <!-- 팝업창 사이즈 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">팝업창 사이즈 <span class="admin--required">*</span></label>
+        <div class="admin--size-group">
+          <div class="admin--size-item">
+            <label>가로</label>
+            <input
+              v-model.number="formData.width"
+              type="number"
+              class="admin--form-input"
+              placeholder="800"
+              min="100"
+              required
+            >
+            <span>px</span>
+          </div>
+          <div class="admin--size-item">
+            <label>세로</label>
+            <input
+              v-model.number="formData.height"
+              type="number"
+              class="admin--form-input"
+              placeholder="600"
+              min="100"
+              required
+            >
+            <span>px</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 팝업창 위치 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">팝업창 위치 <span class="admin--required">*</span></label>
+        <div class="admin--size-group">
+          <div class="admin--size-item">
+            <label>TOP</label>
+            <input
+              v-model.number="formData.position_top"
+              type="number"
+              class="admin--form-input"
+              placeholder="100"
+              min="0"
+              required
+            >
+            <span>px</span>
+          </div>
+          <div class="admin--size-item">
+            <label>LEFT</label>
+            <input
+              v-model.number="formData.position_left"
+              type="number"
+              class="admin--form-input"
+              placeholder="100"
+              min="0"
+              required
+            >
+            <span>px</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 쿠키설정 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">쿠키설정</label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.cookie_type"
+              type="radio"
+              value="today"
+              name="cookie_type"
+            >
+            <span>오늘 하루 창 띄우지 않음</span>
+          </label>
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.cookie_type"
+              type="radio"
+              value="forever"
+              name="cookie_type"
+            >
+            <span>다시는 창을 띄우지 않음</span>
+          </label>
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.cookie_type"
+              type="radio"
+              value="none"
+              name="cookie_type"
+            >
+            <span>사용 안 함</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 출력내용 (HTML) -->
+      <div v-if="formData.type === 'html'" class="admin--form-group">
+        <label class="admin--form-label">출력내용 <span class="admin--required">*</span></label>
+        <SunEditor
+          v-model="formData.content"
+          height="400px"
+          placeholder="팝업 내용을 입력하세요"
+        />
+      </div>
+
+      <!-- 출력내용 (이미지) -->
+      <div v-if="formData.type === 'image'" class="admin--form-group">
+        <label class="admin--form-label">이미지 첨부 <span class="admin--required">*</span></label>
+        <input
+          type="file"
+          accept="image/*"
+          class="admin--form-file"
+          @change="handleImageUpload"
+        >
+        <div v-if="imagePreview" class="admin--image-preview">
+          <img :src="imagePreview" alt="미리보기">
+          <button
+            type="button"
+            class="admin--btn-remove-image"
+            @click="removeImage"
+          >
+            삭제
+          </button>
+        </div>
+      </div>
+
+      <!-- 링크 URL (단일페이지일 경우) -->
+      <div v-if="formData.type === 'image'" class="admin--form-group">
+        <label class="admin--form-label">링크 URL</label>
+        <input
+          v-model="formData.link_url"
+          type="url"
+          class="admin--form-input"
+          placeholder="https://example.com"
+        >
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { post, upload } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const imagePreview = ref(null)
+const imageFile = ref(null)
+
+const formData = ref({
+  type: 'html',
+  title: '',
+  start_date: '',
+  end_date: '',
+  width: 800,
+  height: 600,
+  position_top: 100,
+  position_left: 100,
+  cookie_type: 'none',
+  content: '',
+  image_url: '',
+  link_url: ''
+})
+
+// 이미지 업로드
+const handleImageUpload = (event) => {
+  const file = event.target.files[0]
+  if (!file) return
+
+  if (!file.type.startsWith('image/')) {
+    alert('이미지 파일만 업로드 가능합니다.')
+    return
+  }
+
+  imageFile.value = file
+
+  // 미리보기
+  const reader = new FileReader()
+  reader.onload = (e) => {
+    imagePreview.value = e.target.result
+  }
+  reader.readAsDataURL(file)
+}
+
+// 이미지 삭제
+const removeImage = () => {
+  imagePreview.value = null
+  imageFile.value = null
+  formData.value.image_url = ''
+}
+
+// 폼 제출
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  // 유효성 검사
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.start_date || !formData.value.end_date) {
+    errorMessage.value = '시작일과 종료일을 선택하세요.'
+    return
+  }
+
+  if (formData.value.type === 'html' && !formData.value.content) {
+    errorMessage.value = '출력내용을 입력하세요.'
+    return
+  }
+
+  if (formData.value.type === 'image' && !imageFile.value && !formData.value.image_url) {
+    errorMessage.value = '이미지를 첨부하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let imageUrl = formData.value.image_url
+
+    // 이미지 업로드 (단일페이지인 경우)
+    if (formData.value.type === 'image' && imageFile.value) {
+      const formDataImage = new FormData()
+      formDataImage.append('image', imageFile.value)
+
+      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+
+      if (uploadError) {
+        errorMessage.value = '이미지 업로드에 실패했습니다.'
+        isSaving.value = false
+        return
+      }
+
+      imageUrl = uploadData.url
+    }
+
+    // 팝업 등록
+    const submitData = {
+      ...formData.value,
+      image_url: imageUrl
+    }
+
+    const { data, error } = await post('/basic/popup', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = '팝업이 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/basic/popup')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+// 목록으로 이동
+const goToList = () => {
+  router.push('/admin/basic/popup')
+}
+</script>

+ 402 - 0
app/pages/admin/basic/popup/edit/[id].vue

@@ -0,0 +1,402 @@
+<template>
+  <div class="admin--popup-form">
+    <div v-if="isLoading" class="admin--loading">
+      데이터를 불러오는 중...
+    </div>
+
+    <form v-else @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 형태 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">형태 <span class="admin--required">*</span></label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.type"
+              type="radio"
+              value="html"
+              name="type"
+            >
+            <span>HTML</span>
+          </label>
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.type"
+              type="radio"
+              value="image"
+              name="type"
+            >
+            <span>단일페이지</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="팝업 제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 시작일/종료일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">시작일/종료일 <span class="admin--required">*</span></label>
+        <div class="admin--date-range">
+          <input
+            v-model="formData.start_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+          <span class="admin--date-separator">~</span>
+          <input
+            v-model="formData.end_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+        </div>
+      </div>
+
+      <!-- 팝업창 사이즈 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">팝업창 사이즈 <span class="admin--required">*</span></label>
+        <div class="admin--size-group">
+          <div class="admin--size-item">
+            <label>가로</label>
+            <input
+              v-model.number="formData.width"
+              type="number"
+              class="admin--form-input"
+              placeholder="800"
+              min="100"
+              required
+            >
+            <span>px</span>
+          </div>
+          <div class="admin--size-item">
+            <label>세로</label>
+            <input
+              v-model.number="formData.height"
+              type="number"
+              class="admin--form-input"
+              placeholder="600"
+              min="100"
+              required
+            >
+            <span>px</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 팝업창 위치 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">팝업창 위치 <span class="admin--required">*</span></label>
+        <div class="admin--size-group">
+          <div class="admin--size-item">
+            <label>TOP</label>
+            <input
+              v-model.number="formData.position_top"
+              type="number"
+              class="admin--form-input"
+              placeholder="100"
+              min="0"
+              required
+            >
+            <span>px</span>
+          </div>
+          <div class="admin--size-item">
+            <label>LEFT</label>
+            <input
+              v-model.number="formData.position_left"
+              type="number"
+              class="admin--form-input"
+              placeholder="100"
+              min="0"
+              required
+            >
+            <span>px</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 쿠키설정 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">쿠키설정</label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.cookie_type"
+              type="radio"
+              value="today"
+              name="cookie_type"
+            >
+            <span>오늘 하루 창 띄우지 않음</span>
+          </label>
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.cookie_type"
+              type="radio"
+              value="forever"
+              name="cookie_type"
+            >
+            <span>다시는 창을 띄우지 않음</span>
+          </label>
+          <label class="admin--radio-label">
+            <input
+              v-model="formData.cookie_type"
+              type="radio"
+              value="none"
+              name="cookie_type"
+            >
+            <span>사용 안 함</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 출력내용 (HTML) -->
+      <div v-if="formData.type === 'html'" class="admin--form-group">
+        <label class="admin--form-label">출력내용 <span class="admin--required">*</span></label>
+        <SunEditor
+          v-model="formData.content"
+          height="400px"
+          placeholder="팝업 내용을 입력하세요"
+        />
+      </div>
+
+      <!-- 출력내용 (이미지) -->
+      <div v-if="formData.type === 'image'" class="admin--form-group">
+        <label class="admin--form-label">이미지 첨부 <span class="admin--required">*</span></label>
+        <input
+          type="file"
+          accept="image/*"
+          class="admin--form-file"
+          @change="handleImageUpload"
+        >
+        <div v-if="imagePreview || formData.image_url" class="admin--image-preview">
+          <img :src="imagePreview || formData.image_url" alt="미리보기">
+          <button
+            type="button"
+            class="admin--btn-remove-image"
+            @click="removeImage"
+          >
+            삭제
+          </button>
+        </div>
+      </div>
+
+      <!-- 링크 URL (단일페이지일 경우) -->
+      <div v-if="formData.type === 'image'" class="admin--form-group">
+        <label class="admin--form-label">링크 URL</label>
+        <input
+          v-model="formData.link_url"
+          type="url"
+          class="admin--form-input"
+          placeholder="https://example.com"
+        >
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const route = useRoute()
+const router = useRouter()
+const { get, put, upload } = useApi()
+
+const isLoading = ref(true)
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const imagePreview = ref(null)
+const imageFile = ref(null)
+
+const formData = ref({
+  type: 'html',
+  title: '',
+  start_date: '',
+  end_date: '',
+  width: 800,
+  height: 600,
+  position_top: 100,
+  position_left: 100,
+  cookie_type: 'none',
+  content: '',
+  image_url: '',
+  link_url: ''
+})
+
+// 데이터 로드
+const loadPopup = async () => {
+  isLoading.value = true
+
+  const id = route.params.id
+  const { data, error } = await get(`/basic/popup/${id}`)
+
+  if (data) {
+    formData.value = {
+      type: data.type || 'html',
+      title: data.title || '',
+      start_date: data.start_date || '',
+      end_date: data.end_date || '',
+      width: data.width || 800,
+      height: data.height || 600,
+      position_top: data.position_top || 100,
+      position_left: data.position_left || 100,
+      cookie_type: data.cookie_type || 'none',
+      content: data.content || '',
+      image_url: data.image_url || '',
+      link_url: data.link_url || ''
+    }
+  }
+
+  isLoading.value = false
+}
+
+// 이미지 업로드
+const handleImageUpload = (event) => {
+  const file = event.target.files[0]
+  if (!file) return
+
+  if (!file.type.startsWith('image/')) {
+    alert('이미지 파일만 업로드 가능합니다.')
+    return
+  }
+
+  imageFile.value = file
+
+  // 미리보기
+  const reader = new FileReader()
+  reader.onload = (e) => {
+    imagePreview.value = e.target.result
+  }
+  reader.readAsDataURL(file)
+}
+
+// 이미지 삭제
+const removeImage = () => {
+  imagePreview.value = null
+  imageFile.value = null
+  formData.value.image_url = ''
+}
+
+// 폼 제출
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  // 유효성 검사
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.start_date || !formData.value.end_date) {
+    errorMessage.value = '시작일과 종료일을 선택하세요.'
+    return
+  }
+
+  if (formData.value.type === 'html' && !formData.value.content) {
+    errorMessage.value = '출력내용을 입력하세요.'
+    return
+  }
+
+  if (formData.value.type === 'image' && !imageFile.value && !formData.value.image_url) {
+    errorMessage.value = '이미지를 첨부하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let imageUrl = formData.value.image_url
+
+    // 이미지 업로드 (새로운 이미지가 있는 경우)
+    if (formData.value.type === 'image' && imageFile.value) {
+      const formDataImage = new FormData()
+      formDataImage.append('image', imageFile.value)
+
+      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+
+      if (uploadError) {
+        errorMessage.value = '이미지 업로드에 실패했습니다.'
+        isSaving.value = false
+        return
+      }
+
+      imageUrl = uploadData.url
+    }
+
+    // 팝업 수정
+    const submitData = {
+      ...formData.value,
+      image_url: imageUrl
+    }
+
+    const id = route.params.id
+    const { data, error } = await put(`/basic/popup/${id}`, submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '수정에 실패했습니다.'
+    } else {
+      successMessage.value = '팝업이 수정되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/basic/popup')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+// 목록으로 이동
+const goToList = () => {
+  router.push('/admin/basic/popup')
+}
+
+onMounted(() => {
+  loadPopup()
+})
+</script>

+ 262 - 0
app/pages/admin/basic/popup/index.vue

@@ -0,0 +1,262 @@
+<template>
+  <div class="admin--popup-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="searchType" class="admin--form-select admin--search-select">
+          <option value="title">제목</option>
+          <option value="status">상태</option>
+        </select>
+        <input
+          v-model="searchKeyword"
+          type="text"
+          class="admin--form-input admin--search-input"
+          placeholder="검색어를 입력하세요"
+          @keyup.enter="handleSearch"
+        >
+        <button class="admin--btn admin--btn-primary" @click="handleSearch">
+          검색
+        </button>
+        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+          초기화
+        </button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+          + 팝업 등록
+        </button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>출력형태</th>
+            <th>제목</th>
+            <th>시작일</th>
+            <th>종료일</th>
+            <th>상태</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="7" class="admin--table-loading">
+              데이터를 불러오는 중...
+            </td>
+          </tr>
+          <tr v-else-if="!popups || popups.length === 0">
+            <td colspan="7" class="admin--table-empty">
+              등록된 팝업이 없습니다.
+            </td>
+          </tr>
+          <tr v-else v-for="(popup, index) in popups" :key="popup.id">
+            <td>{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td>
+              <span class="admin--badge" :class="`admin--badge-${popup.type}`">
+                {{ popup.type === 'html' ? 'HTML' : '단일페이지' }}
+              </span>
+            </td>
+            <td class="admin--table-title">{{ popup.title }}</td>
+            <td>{{ formatDate(popup.start_date) }}</td>
+            <td>{{ formatDate(popup.end_date) }}</td>
+            <td>
+              <span class="admin--badge" :class="getStatusClass(popup)">
+                {{ getStatusText(popup) }}
+              </span>
+            </td>
+            <td>
+              <div class="admin--table-actions">
+                <button
+                  class="admin--btn-small admin--btn-small-primary"
+                  @click="goToEdit(popup.id)"
+                >
+                  수정
+                </button>
+                <button
+                  class="admin--btn-small admin--btn-small-danger"
+                  @click="handleDelete(popup.id)"
+                >
+                  삭제
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+      >
+        이전
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+      >
+        다음
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, del } = useApi()
+
+const isLoading = ref(false)
+const popups = ref([])
+const searchType = ref('title')
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+// 보이는 페이지 번호 계산
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+
+  if (end - start < maxVisible - 1) {
+    start = Math.max(1, end - maxVisible + 1)
+  }
+
+  for (let i = start; i <= end; i++) {
+    pages.push(i)
+  }
+
+  return pages
+})
+
+// 데이터 로드
+const loadPopups = async () => {
+  isLoading.value = true
+
+  const params = {
+    page: currentPage.value,
+    per_page: perPage.value
+  }
+
+  if (searchKeyword.value) {
+    params.search_type = searchType.value
+    params.search_keyword = searchKeyword.value
+  }
+
+  const { data, error } = await get('/basic/popup', params)
+
+  if (data) {
+    popups.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+
+  isLoading.value = false
+}
+
+// 검색
+const handleSearch = () => {
+  currentPage.value = 1
+  loadPopups()
+}
+
+// 초기화
+const handleReset = () => {
+  searchType.value = 'title'
+  searchKeyword.value = ''
+  currentPage.value = 1
+  loadPopups()
+}
+
+// 페이지 변경
+const changePage = (page) => {
+  if (page < 1 || page > totalPages.value) return
+  currentPage.value = page
+  loadPopups()
+}
+
+// 등록 페이지로 이동
+const goToCreate = () => {
+  router.push('/admin/basic/popup/create')
+}
+
+// 수정 페이지로 이동
+const goToEdit = (id) => {
+  router.push(`/admin/basic/popup/edit/${id}`)
+}
+
+// 삭제
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+
+  const { error } = await del(`/basic/popup/${id}`)
+
+  if (error) {
+    alert('삭제에 실패했습니다.')
+  } else {
+    alert('삭제되었습니다.')
+    loadPopups()
+  }
+}
+
+// 날짜 포맷
+const formatDate = (dateString) => {
+  if (!dateString) return '-'
+  const date = new Date(dateString)
+  return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`
+}
+
+// 상태 클래스
+const getStatusClass = (popup) => {
+  const now = new Date()
+  const start = new Date(popup.start_date)
+  const end = new Date(popup.end_date)
+
+  if (now < start) return 'admin--badge-scheduled'
+  if (now > end) return 'admin--badge-ended'
+  return 'admin--badge-active'
+}
+
+// 상태 텍스트
+const getStatusText = (popup) => {
+  const now = new Date()
+  const start = new Date(popup.start_date)
+  const end = new Date(popup.end_date)
+
+  if (now < start) return '예정'
+  if (now > end) return '종료'
+  return '진행중'
+}
+
+onMounted(() => {
+  loadPopups()
+})
+</script>

+ 315 - 0
app/pages/admin/basic/site-info.vue

@@ -0,0 +1,315 @@
+<template>
+  <div class="admin--site-info">
+    <div v-if="isLoading" class="admin--loading">
+      데이터를 불러오는 중...
+    </div>
+
+    <form v-else @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 사이트명 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">사이트명 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.site_name"
+          type="text"
+          class="admin--form-input"
+          placeholder="사이트명을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 사이트 URL -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">사이트 URL <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.site_url"
+          type="url"
+          class="admin--form-input"
+          placeholder="https://example.com"
+          required
+        >
+      </div>
+
+      <!-- 대표명 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">대표명 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.ceo_name"
+          type="text"
+          class="admin--form-input"
+          placeholder="대표명을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 대표 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">대표 이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.ceo_email"
+          type="email"
+          class="admin--form-input"
+          placeholder="email@example.com"
+          required
+        >
+      </div>
+
+      <!-- 전화번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">전화번호 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5678"
+          required
+        >
+      </div>
+
+      <!-- FAX번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">FAX번호</label>
+        <input
+          v-model="formData.fax"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5679"
+        >
+      </div>
+
+      <!-- 회사주소 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">회사주소 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.address"
+          type="text"
+          class="admin--form-input"
+          placeholder="회사 주소를 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- SMS발신번호 (다중) -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">SMS발신번호</label>
+        <div class="admin--multi-input-wrapper">
+          <div
+            v-for="(item, index) in formData.sms_sender_numbers"
+            :key="index"
+            class="admin--multi-input-item"
+          >
+            <div class="admin--sender-row">
+              <input
+                v-model="formData.sms_sender_numbers[index].name"
+                type="text"
+                class="admin--form-input"
+                placeholder="지점명"
+                style="flex: 1; margin-right: 10px;"
+              >
+              <input
+                v-model="formData.sms_sender_numbers[index].number"
+                type="tel"
+                class="admin--form-input"
+                placeholder="010-1234-5678"
+                style="flex: 1;"
+              >
+            </div>
+            <button
+              v-if="formData.sms_sender_numbers.length > 1"
+              type="button"
+              class="admin--btn-remove"
+              @click="removeSenderNumber(index)"
+            >
+              삭제
+            </button>
+          </div>
+          <button
+            type="button"
+            class="admin--btn-add"
+            @click="addSenderNumber"
+          >
+            + 발신번호 추가
+          </button>
+        </div>
+      </div>
+
+      <!-- SMS수신번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">SMS수신번호</label>
+        <input
+          v-model="formData.sms_receiver_number"
+          type="tel"
+          class="admin--form-input"
+          placeholder="010-9876-5432"
+        >
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '저장' }}
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const { get, post } = useApi()
+
+const isLoading = ref(true)
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const siteInfoId = ref(null)
+
+const formData = ref({
+  site_name: '',
+  site_url: '',
+  ceo_name: '',
+  ceo_email: '',
+  phone: '',
+  fax: '',
+  address: '',
+  sms_sender_numbers: [{ name: '', number: '' }],
+  sms_receiver_number: ''
+})
+
+// 데이터 로드
+const loadSiteInfo = async () => {
+  isLoading.value = true
+
+  const { data } = await get('/basic/site-info')
+
+  if (data && data.id) {
+    siteInfoId.value = data.id
+
+    // SMS 발신번호 데이터 변환 (기존 문자열 배열 → 객체 배열)
+    let senderNumbers = [{ name: '', number: '' }]
+    if (data.sms_sender_numbers && data.sms_sender_numbers.length > 0) {
+      senderNumbers = data.sms_sender_numbers.map(item => {
+        // 이미 객체 형태인 경우
+        if (typeof item === 'object' && item.name !== undefined) {
+          return item
+        }
+        // 문자열인 경우 (기존 데이터)
+        return { name: '', number: item }
+      })
+    }
+
+    formData.value = {
+      site_name: data.site_name || '',
+      site_url: data.site_url || '',
+      ceo_name: data.ceo_name || '',
+      ceo_email: data.ceo_email || '',
+      phone: data.phone || '',
+      fax: data.fax || '',
+      address: data.address || '',
+      sms_sender_numbers: senderNumbers,
+      sms_receiver_number: data.sms_receiver_number || ''
+    }
+  }
+
+  isLoading.value = false
+}
+
+// 발신번호 추가
+const addSenderNumber = () => {
+  formData.value.sms_sender_numbers.push({ name: '', number: '' })
+}
+
+// 발신번호 삭제
+const removeSenderNumber = (index) => {
+  formData.value.sms_sender_numbers.splice(index, 1)
+}
+
+// 폼 제출
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+  isSaving.value = true
+
+  try {
+    // 빈 발신번호 제거 (지점명 또는 번호가 비어있지 않은 것만)
+    const cleanedSenderNumbers = formData.value.sms_sender_numbers.filter(
+      sender => (sender.name && sender.name.trim() !== '') || (sender.number && sender.number.trim() !== '')
+    )
+
+    const submitData = {
+      ...formData.value,
+      sms_sender_numbers: cleanedSenderNumbers
+    }
+
+    // POST로 통일 (등록/수정 모두)
+    const result = await post('/basic/site-info', submitData)
+
+    if (result.error) {
+      errorMessage.value = result.error.message || '저장에 실패했습니다.'
+    } else {
+      successMessage.value = '사이트 정보가 저장되었습니다.'
+
+      // 저장된 데이터로 업데이트
+      if (result.data) {
+        siteInfoId.value = result.data.id
+
+        // SMS 발신번호 데이터 변환 (기존 문자열 배열 → 객체 배열)
+        let senderNumbers = [{ name: '', number: '' }]
+        if (result.data.sms_sender_numbers && result.data.sms_sender_numbers.length > 0) {
+          senderNumbers = result.data.sms_sender_numbers.map(item => {
+            // 이미 객체 형태인 경우
+            if (typeof item === 'object' && item.name !== undefined) {
+              return item
+            }
+            // 문자열인 경우 (기존 데이터)
+            return { name: '', number: item }
+          })
+        }
+
+        formData.value = {
+          site_name: result.data.site_name || '',
+          site_url: result.data.site_url || '',
+          ceo_name: result.data.ceo_name || '',
+          ceo_email: result.data.ceo_email || '',
+          phone: result.data.phone || '',
+          fax: result.data.fax || '',
+          address: result.data.address || '',
+          sms_sender_numbers: senderNumbers,
+          sms_receiver_number: result.data.sms_receiver_number || ''
+        }
+      }
+
+      // 3초 후 메시지 자동 제거
+      setTimeout(() => {
+        successMessage.value = ''
+      }, 3000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+onMounted(() => {
+  loadSiteInfo()
+})
+</script>

+ 282 - 0
app/pages/admin/board/event/create.vue

@@ -0,0 +1,282 @@
+<template>
+  <div class="admin--event-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 카테고리 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">카테고리 <span class="admin--required">*</span></label>
+        <select v-model="formData.category" class="admin--form-select" required>
+          <option value="">카테고리를 선택하세요</option>
+          <option value="진행중">진행중</option>
+          <option value="완료">완료</option>
+        </select>
+      </div>
+
+      <!-- 댓글허용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">댓글허용</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.allow_comment" type="checkbox">
+            <span>댓글 허용</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 공지 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">공지</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.is_notice" type="checkbox">
+            <span>공지글로 등록</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="이메일을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 기간 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">기간 <span class="admin--required">*</span></label>
+        <div class="admin--date-range">
+          <input
+            v-model="formData.start_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+          <span class="admin--date-separator">~</span>
+          <input
+            v-model="formData.end_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+        </div>
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 내용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">내용 <span class="admin--required">*</span></label>
+        <SunEditor v-model="formData.content" />
+      </div>
+
+      <!-- 파일첨부 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">파일첨부</label>
+        <div class="admin--file-list">
+          <div v-for="(file, index) in attachedFiles" :key="index" class="admin--file-item">
+            <span class="admin--file-name">{{ file.name }}</span>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+        <input
+          ref="fileInput"
+          type="file"
+          multiple
+          class="admin--form-file-hidden"
+          @change="handleFileAdd"
+        >
+        <button type="button" class="admin--btn admin--btn-secondary" @click="triggerFileInput">
+          파일 추가
+        </button>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { post, upload } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const attachedFiles = ref([])
+const fileInput = ref(null)
+
+const formData = ref({
+  category: '',
+  allow_comment: false,
+  is_notice: false,
+  name: '고진',
+  email: 'admin@admin.kr',
+  start_date: '',
+  end_date: '',
+  title: '',
+  content: '',
+  file_urls: []
+})
+
+const triggerFileInput = () => {
+  fileInput.value?.click()
+}
+
+const handleFileAdd = (event) => {
+  const files = Array.from(event.target.files)
+  attachedFiles.value.push(...files)
+  event.target.value = ''
+}
+
+const removeFile = (index) => {
+  attachedFiles.value.splice(index, 1)
+}
+
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 Bytes'
+  const k = 1024
+  const sizes = ['Bytes', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.category) {
+    errorMessage.value = '카테고리를 선택하세요.'
+    return
+  }
+
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.content) {
+    errorMessage.value = '내용을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.start_date || !formData.value.end_date) {
+    errorMessage.value = '기간을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let fileUrls = []
+
+    // 파일 업로드
+    if (attachedFiles.value.length > 0) {
+      for (const file of attachedFiles.value) {
+        const formDataFile = new FormData()
+        formDataFile.append('file', file)
+
+        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+
+        if (uploadError) {
+          errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
+          isSaving.value = false
+          return
+        }
+
+        fileUrls.push({
+          name: file.name,
+          url: uploadData.url,
+          size: file.size
+        })
+      }
+    }
+
+    const submitData = {
+      ...formData.value,
+      file_urls: fileUrls
+    }
+
+    const { data, error } = await post('/board/event', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = '이벤트가 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/board/event')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/board/event')
+}
+</script>

+ 337 - 0
app/pages/admin/board/event/edit/[id].vue

@@ -0,0 +1,337 @@
+<template>
+  <div class="admin--event-form">
+    <div v-if="isLoading" class="admin--loading">
+      데이터를 불러오는 중...
+    </div>
+
+    <form v-else @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 카테고리 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">카테고리 <span class="admin--required">*</span></label>
+        <select v-model="formData.category" class="admin--form-select" required>
+          <option value="">카테고리를 선택하세요</option>
+          <option value="진행중">진행중</option>
+          <option value="완료">완료</option>
+        </select>
+      </div>
+
+      <!-- 댓글허용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">댓글허용</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.allow_comment" type="checkbox">
+            <span>댓글 허용</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 공지 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">공지</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.is_notice" type="checkbox">
+            <span>공지글로 등록</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="이메일을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 기간 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">기간 <span class="admin--required">*</span></label>
+        <div class="admin--date-range">
+          <input
+            v-model="formData.start_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+          <span class="admin--date-separator">~</span>
+          <input
+            v-model="formData.end_date"
+            type="date"
+            class="admin--form-input"
+            required
+          >
+        </div>
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 내용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">내용 <span class="admin--required">*</span></label>
+        <SunEditor v-model="formData.content" />
+      </div>
+
+      <!-- 기존 첨부파일 -->
+      <div v-if="existingFiles.length > 0" class="admin--form-group">
+        <label class="admin--form-label">기존 첨부파일</label>
+        <div class="admin--file-list">
+          <div v-for="(file, index) in existingFiles" :key="'existing-' + index" class="admin--file-item">
+            <a :href="file.url" target="_blank" class="admin--file-name">{{ file.name }}</a>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeExistingFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 새 파일첨부 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">파일첨부</label>
+        <div v-if="attachedFiles.length > 0" class="admin--file-list">
+          <div v-for="(file, index) in attachedFiles" :key="'new-' + index" class="admin--file-item">
+            <span class="admin--file-name">{{ file.name }}</span>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+        <input
+          ref="fileInput"
+          type="file"
+          multiple
+          class="admin--form-file-hidden"
+          @change="handleFileAdd"
+        >
+        <button type="button" class="admin--btn admin--btn-secondary" @click="triggerFileInput">
+          파일 추가
+        </button>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const route = useRoute()
+const router = useRouter()
+const { get, put, upload } = useApi()
+
+const isLoading = ref(true)
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const attachedFiles = ref([])
+const existingFiles = ref([])
+const fileInput = ref(null)
+
+const formData = ref({
+  category: '',
+  allow_comment: false,
+  is_notice: false,
+  name: '',
+  email: '',
+  start_date: '',
+  end_date: '',
+  title: '',
+  content: '',
+  file_urls: []
+})
+
+const loadEvent = async () => {
+  isLoading.value = true
+
+  const id = route.params.id
+  const { data, error } = await get(`/board/event/${id}`)
+
+  if (data) {
+    formData.value = {
+      category: data.category || '',
+      allow_comment: data.allow_comment || false,
+      is_notice: data.is_notice || false,
+      name: data.name || '',
+      email: data.email || '',
+      start_date: data.start_date || '',
+      end_date: data.end_date || '',
+      title: data.title || '',
+      content: data.content || '',
+      file_urls: data.file_urls || []
+    }
+    existingFiles.value = data.file_urls || []
+  }
+
+  isLoading.value = false
+}
+
+const triggerFileInput = () => {
+  fileInput.value?.click()
+}
+
+const handleFileAdd = (event) => {
+  const files = Array.from(event.target.files)
+  attachedFiles.value.push(...files)
+  event.target.value = ''
+}
+
+const removeFile = (index) => {
+  attachedFiles.value.splice(index, 1)
+}
+
+const removeExistingFile = (index) => {
+  existingFiles.value.splice(index, 1)
+}
+
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 Bytes'
+  const k = 1024
+  const sizes = ['Bytes', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.category) {
+    errorMessage.value = '카테고리를 선택하세요.'
+    return
+  }
+
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.content) {
+    errorMessage.value = '내용을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.start_date || !formData.value.end_date) {
+    errorMessage.value = '기간을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let fileUrls = [...existingFiles.value]
+
+    // 새 파일 업로드
+    if (attachedFiles.value.length > 0) {
+      for (const file of attachedFiles.value) {
+        const formDataFile = new FormData()
+        formDataFile.append('file', file)
+
+        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+
+        if (uploadError) {
+          errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
+          isSaving.value = false
+          return
+        }
+
+        fileUrls.push({
+          name: file.name,
+          url: uploadData.url,
+          size: file.size
+        })
+      }
+    }
+
+    const submitData = {
+      ...formData.value,
+      file_urls: fileUrls
+    }
+
+    const id = route.params.id
+    const { data, error } = await put(`/board/event/${id}`, submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '수정에 실패했습니다.'
+    } else {
+      successMessage.value = '이벤트가 수정되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/board/event')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/board/event')
+}
+
+onMounted(() => {
+  loadEvent()
+})
+</script>

+ 134 - 0
app/pages/admin/board/event/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="admin--board-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="searchType" class="admin--form-select admin--search-select">
+          <option value="title">제목</option>
+          <option value="name">이름</option>
+          <option value="content">내용</option>
+        </select>
+        <input
+          v-model="searchKeyword"
+          type="text"
+          class="admin--form-input admin--search-input"
+          placeholder="검색어를 입력하세요"
+          @keyup.enter="handleSearch"
+        >
+        <button class="admin--btn admin--btn-primary" @click="handleSearch">검색</button>
+        <button class="admin--btn admin--btn-secondary" @click="handleReset">초기화</button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-primary" @click="goToCreate">+ 등록</button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>제목</th>
+            <th>이름</th>
+            <th>등록일</th>
+            <th>조회</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
+          </tr>
+          <tr v-else-if="!posts || posts.length === 0">
+            <td colspan="6" class="admin--table-empty">등록된 게시물이 없습니다.</td>
+          </tr>
+          <tr v-else v-for="(post, index) in posts" :key="post.id">
+            <td>{{ post.is_notice ? '공지' : totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td class="admin--table-title">{{ post.title }}</td>
+            <td>{{ post.name }}</td>
+            <td>{{ formatDate(post.created_at) }}</td>
+            <td>{{ post.views }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button class="admin--btn-small admin--btn-small-primary" @click="goToEdit(post.id)">수정</button>
+                <button class="admin--btn-small admin--btn-small-danger" @click="handleDelete(post.id)">삭제</button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button class="admin--pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)">이전</button>
+      <button v-for="page in visiblePages" :key="page" class="admin--pagination-btn" :class="{ 'is-active': page === currentPage }" @click="changePage(page)">{{ page }}</button>
+      <button class="admin--pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)">다음</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({ layout: 'admin', middleware: ['auth'] })
+
+const router = useRouter()
+const { get, del } = useApi()
+
+const isLoading = ref(false)
+const posts = ref([])
+const searchType = ref('title')
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+  if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1)
+  for (let i = start; i <= end; i++) pages.push(i)
+  return pages
+})
+
+const loadPosts = async () => {
+  isLoading.value = true
+  const params = { page: currentPage.value, per_page: perPage.value }
+  if (searchKeyword.value) {
+    params.search_type = searchType.value
+    params.search_keyword = searchKeyword.value
+  }
+  const { data } = await get('/board/event', params)
+  if (data) {
+    posts.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+  isLoading.value = false
+}
+
+const handleSearch = () => { currentPage.value = 1; loadPosts() }
+const handleReset = () => { searchType.value = 'title'; searchKeyword.value = ''; currentPage.value = 1; loadPosts() }
+const changePage = (page) => { if (page < 1 || page > totalPages.value) return; currentPage.value = page; loadPosts() }
+const goToCreate = () => router.push('/admin/board/event/create')
+const goToEdit = (id) => router.push(`/admin/board/event/edit/${id}`)
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+  const { error } = await del(`/board/event/${id}`)
+  if (error) alert('삭제에 실패했습니다.')
+  else { alert('삭제되었습니다.'); loadPosts() }
+}
+const formatDate = (dateString) => {
+  if (!dateString) return '-'
+  const date = new Date(dateString)
+  return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`
+}
+
+onMounted(() => loadPosts())
+</script>

+ 251 - 0
app/pages/admin/board/ir/create.vue

@@ -0,0 +1,251 @@
+<template>
+  <div class="admin--ir-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 댓글허용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">댓글허용</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.allow_comment" type="checkbox">
+            <span>댓글 허용</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 공지 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">공지</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.is_notice" type="checkbox">
+            <span>공지글로 등록</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="이메일을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- URL -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">URL</label>
+        <input
+          v-model="formData.url"
+          type="url"
+          class="admin--form-input"
+          placeholder="https://example.com"
+        >
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 내용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">내용 <span class="admin--required">*</span></label>
+        <SunEditor v-model="formData.content" />
+      </div>
+
+      <!-- 파일첨부 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">파일첨부</label>
+        <div class="admin--file-list">
+          <div v-for="(file, index) in attachedFiles" :key="index" class="admin--file-item">
+            <span class="admin--file-name">{{ file.name }}</span>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+        <input
+          ref="fileInput"
+          type="file"
+          multiple
+          class="admin--form-file-hidden"
+          @change="handleFileAdd"
+        >
+        <button type="button" class="admin--btn admin--btn-secondary" @click="triggerFileInput">
+          파일 추가
+        </button>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { post, upload } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const attachedFiles = ref([])
+const fileInput = ref(null)
+
+const formData = ref({
+  allow_comment: false,
+  is_notice: false,
+  name: '고진',
+  email: 'admin@admin.kr',
+  url: '',
+  title: '',
+  content: '',
+  file_urls: []
+})
+
+const triggerFileInput = () => {
+  fileInput.value?.click()
+}
+
+const handleFileAdd = (event) => {
+  const files = Array.from(event.target.files)
+  attachedFiles.value.push(...files)
+  event.target.value = ''
+}
+
+const removeFile = (index) => {
+  attachedFiles.value.splice(index, 1)
+}
+
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 Bytes'
+  const k = 1024
+  const sizes = ['Bytes', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.content) {
+    errorMessage.value = '내용을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let fileUrls = []
+
+    // 파일 업로드
+    if (attachedFiles.value.length > 0) {
+      for (const file of attachedFiles.value) {
+        const formDataFile = new FormData()
+        formDataFile.append('file', file)
+
+        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+
+        if (uploadError) {
+          errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
+          isSaving.value = false
+          return
+        }
+
+        fileUrls.push({
+          name: file.name,
+          url: uploadData.url,
+          size: file.size
+        })
+      }
+    }
+
+    const submitData = {
+      ...formData.value,
+      file_urls: fileUrls
+    }
+
+    const { data, error } = await post('/board/ir', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = 'IR 자료가 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/board/ir')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/board/ir')
+}
+</script>

+ 304 - 0
app/pages/admin/board/ir/edit/[id].vue

@@ -0,0 +1,304 @@
+<template>
+  <div class="admin--ir-form">
+    <div v-if="isLoading" class="admin--loading">
+      데이터를 불러오는 중...
+    </div>
+
+    <form v-else @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 댓글허용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">댓글허용</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.allow_comment" type="checkbox">
+            <span>댓글 허용</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 공지 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">공지</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.is_notice" type="checkbox">
+            <span>공지글로 등록</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="이메일을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- URL -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">URL</label>
+        <input
+          v-model="formData.url"
+          type="url"
+          class="admin--form-input"
+          placeholder="https://example.com"
+        >
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 내용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">내용 <span class="admin--required">*</span></label>
+        <SunEditor v-model="formData.content" />
+      </div>
+
+      <!-- 기존 첨부파일 -->
+      <div v-if="existingFiles.length > 0" class="admin--form-group">
+        <label class="admin--form-label">기존 첨부파일</label>
+        <div class="admin--file-list">
+          <div v-for="(file, index) in existingFiles" :key="'existing-' + index" class="admin--file-item">
+            <a :href="file.url" target="_blank" class="admin--file-name">{{ file.name }}</a>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeExistingFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 새 파일첨부 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">파일첨부</label>
+        <div v-if="attachedFiles.length > 0" class="admin--file-list">
+          <div v-for="(file, index) in attachedFiles" :key="'new-' + index" class="admin--file-item">
+            <span class="admin--file-name">{{ file.name }}</span>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+        <input
+          ref="fileInput"
+          type="file"
+          multiple
+          class="admin--form-file-hidden"
+          @change="handleFileAdd"
+        >
+        <button type="button" class="admin--btn admin--btn-secondary" @click="triggerFileInput">
+          파일 추가
+        </button>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const route = useRoute()
+const router = useRouter()
+const { get, put, upload } = useApi()
+
+const isLoading = ref(true)
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const attachedFiles = ref([])
+const existingFiles = ref([])
+const fileInput = ref(null)
+
+const formData = ref({
+  allow_comment: false,
+  is_notice: false,
+  name: '',
+  email: '',
+  url: '',
+  title: '',
+  content: '',
+  file_urls: []
+})
+
+const loadIR = async () => {
+  isLoading.value = true
+
+  const id = route.params.id
+  const { data, error } = await get(`/board/ir/${id}`)
+
+  if (data) {
+    formData.value = {
+      allow_comment: data.allow_comment || false,
+      is_notice: data.is_notice || false,
+      name: data.name || '',
+      email: data.email || '',
+      url: data.url || '',
+      title: data.title || '',
+      content: data.content || '',
+      file_urls: data.file_urls || []
+    }
+    existingFiles.value = data.file_urls || []
+  }
+
+  isLoading.value = false
+}
+
+const triggerFileInput = () => {
+  fileInput.value?.click()
+}
+
+const handleFileAdd = (event) => {
+  const files = Array.from(event.target.files)
+  attachedFiles.value.push(...files)
+  event.target.value = ''
+}
+
+const removeFile = (index) => {
+  attachedFiles.value.splice(index, 1)
+}
+
+const removeExistingFile = (index) => {
+  existingFiles.value.splice(index, 1)
+}
+
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 Bytes'
+  const k = 1024
+  const sizes = ['Bytes', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.content) {
+    errorMessage.value = '내용을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let fileUrls = [...existingFiles.value]
+
+    // 새 파일 업로드
+    if (attachedFiles.value.length > 0) {
+      for (const file of attachedFiles.value) {
+        const formDataFile = new FormData()
+        formDataFile.append('file', file)
+
+        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+
+        if (uploadError) {
+          errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
+          isSaving.value = false
+          return
+        }
+
+        fileUrls.push({
+          name: file.name,
+          url: uploadData.url,
+          size: file.size
+        })
+      }
+    }
+
+    const submitData = {
+      ...formData.value,
+      file_urls: fileUrls
+    }
+
+    const id = route.params.id
+    const { data, error } = await put(`/board/ir/${id}`, submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '수정에 실패했습니다.'
+    } else {
+      successMessage.value = 'IR 자료가 수정되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/board/ir')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/board/ir')
+}
+
+onMounted(() => {
+  loadIR()
+})
+</script>

+ 134 - 0
app/pages/admin/board/ir/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="admin--board-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="searchType" class="admin--form-select admin--search-select">
+          <option value="title">제목</option>
+          <option value="name">이름</option>
+          <option value="content">내용</option>
+        </select>
+        <input
+          v-model="searchKeyword"
+          type="text"
+          class="admin--form-input admin--search-input"
+          placeholder="검색어를 입력하세요"
+          @keyup.enter="handleSearch"
+        >
+        <button class="admin--btn admin--btn-primary" @click="handleSearch">검색</button>
+        <button class="admin--btn admin--btn-secondary" @click="handleReset">초기화</button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-primary" @click="goToCreate">+ 등록</button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>제목</th>
+            <th>이름</th>
+            <th>등록일</th>
+            <th>조회</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
+          </tr>
+          <tr v-else-if="!posts || posts.length === 0">
+            <td colspan="6" class="admin--table-empty">등록된 게시물이 없습니다.</td>
+          </tr>
+          <tr v-else v-for="(post, index) in posts" :key="post.id">
+            <td>{{ post.is_notice ? '공지' : totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td class="admin--table-title">{{ post.title }}</td>
+            <td>{{ post.name }}</td>
+            <td>{{ formatDate(post.created_at) }}</td>
+            <td>{{ post.views }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button class="admin--btn-small admin--btn-small-primary" @click="goToEdit(post.id)">수정</button>
+                <button class="admin--btn-small admin--btn-small-danger" @click="handleDelete(post.id)">삭제</button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button class="admin--pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)">이전</button>
+      <button v-for="page in visiblePages" :key="page" class="admin--pagination-btn" :class="{ 'is-active': page === currentPage }" @click="changePage(page)">{{ page }}</button>
+      <button class="admin--pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)">다음</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({ layout: 'admin', middleware: ['auth'] })
+
+const router = useRouter()
+const { get, del } = useApi()
+
+const isLoading = ref(false)
+const posts = ref([])
+const searchType = ref('title')
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+  if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1)
+  for (let i = start; i <= end; i++) pages.push(i)
+  return pages
+})
+
+const loadPosts = async () => {
+  isLoading.value = true
+  const params = { page: currentPage.value, per_page: perPage.value }
+  if (searchKeyword.value) {
+    params.search_type = searchType.value
+    params.search_keyword = searchKeyword.value
+  }
+  const { data } = await get('/board/ir', params)
+  if (data) {
+    posts.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+  isLoading.value = false
+}
+
+const handleSearch = () => { currentPage.value = 1; loadPosts() }
+const handleReset = () => { searchType.value = 'title'; searchKeyword.value = ''; currentPage.value = 1; loadPosts() }
+const changePage = (page) => { if (page < 1 || page > totalPages.value) return; currentPage.value = page; loadPosts() }
+const goToCreate = () => router.push('/admin/board/ir/create')
+const goToEdit = (id) => router.push(`/admin/board/ir/edit/${id}`)
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+  const { error } = await del(`/board/ir/${id}`)
+  if (error) alert('삭제에 실패했습니다.')
+  else { alert('삭제되었습니다.'); loadPosts() }
+}
+const formatDate = (dateString) => {
+  if (!dateString) return '-'
+  const date = new Date(dateString)
+  return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`
+}
+
+onMounted(() => loadPosts())
+</script>

+ 251 - 0
app/pages/admin/board/news/create.vue

@@ -0,0 +1,251 @@
+<template>
+  <div class="admin--news-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 댓글허용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">댓글허용</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.allow_comment" type="checkbox">
+            <span>댓글 허용</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 공지 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">공지</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.is_notice" type="checkbox">
+            <span>공지글로 등록</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="이메일을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- URL -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">URL</label>
+        <input
+          v-model="formData.url"
+          type="url"
+          class="admin--form-input"
+          placeholder="https://example.com"
+        >
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 내용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">내용 <span class="admin--required">*</span></label>
+        <SunEditor v-model="formData.content" />
+      </div>
+
+      <!-- 파일첨부 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">파일첨부</label>
+        <div class="admin--file-list">
+          <div v-for="(file, index) in attachedFiles" :key="index" class="admin--file-item">
+            <span class="admin--file-name">{{ file.name }}</span>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+        <input
+          ref="fileInput"
+          type="file"
+          multiple
+          class="admin--form-file-hidden"
+          @change="handleFileAdd"
+        >
+        <button type="button" class="admin--btn admin--btn-secondary" @click="triggerFileInput">
+          파일 추가
+        </button>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { post, upload } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const attachedFiles = ref([])
+const fileInput = ref(null)
+
+const formData = ref({
+  allow_comment: false,
+  is_notice: false,
+  name: '고진',
+  email: 'admin@admin.kr',
+  url: '',
+  title: '',
+  content: '',
+  file_urls: []
+})
+
+const triggerFileInput = () => {
+  fileInput.value?.click()
+}
+
+const handleFileAdd = (event) => {
+  const files = Array.from(event.target.files)
+  attachedFiles.value.push(...files)
+  event.target.value = ''
+}
+
+const removeFile = (index) => {
+  attachedFiles.value.splice(index, 1)
+}
+
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 Bytes'
+  const k = 1024
+  const sizes = ['Bytes', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.content) {
+    errorMessage.value = '내용을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let fileUrls = []
+
+    // 파일 업로드
+    if (attachedFiles.value.length > 0) {
+      for (const file of attachedFiles.value) {
+        const formDataFile = new FormData()
+        formDataFile.append('file', file)
+
+        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+
+        if (uploadError) {
+          errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
+          isSaving.value = false
+          return
+        }
+
+        fileUrls.push({
+          name: file.name,
+          url: uploadData.url,
+          size: file.size
+        })
+      }
+    }
+
+    const submitData = {
+      ...formData.value,
+      file_urls: fileUrls
+    }
+
+    const { data, error } = await post('/board/news', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = '뉴스가 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/board/news')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/board/news')
+}
+</script>

+ 304 - 0
app/pages/admin/board/news/edit/[id].vue

@@ -0,0 +1,304 @@
+<template>
+  <div class="admin--news-form">
+    <div v-if="isLoading" class="admin--loading">
+      데이터를 불러오는 중...
+    </div>
+
+    <form v-else @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 댓글허용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">댓글허용</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.allow_comment" type="checkbox">
+            <span>댓글 허용</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 공지 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">공지</label>
+        <div class="admin--checkbox-group">
+          <label class="admin--checkbox-label">
+            <input v-model="formData.is_notice" type="checkbox">
+            <span>공지글로 등록</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="이메일을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- URL -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">URL</label>
+        <input
+          v-model="formData.url"
+          type="url"
+          class="admin--form-input"
+          placeholder="https://example.com"
+        >
+      </div>
+
+      <!-- 제목 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">제목 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.title"
+          type="text"
+          class="admin--form-input"
+          placeholder="제목을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 내용 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">내용 <span class="admin--required">*</span></label>
+        <SunEditor v-model="formData.content" />
+      </div>
+
+      <!-- 기존 첨부파일 -->
+      <div v-if="existingFiles.length > 0" class="admin--form-group">
+        <label class="admin--form-label">기존 첨부파일</label>
+        <div class="admin--file-list">
+          <div v-for="(file, index) in existingFiles" :key="'existing-' + index" class="admin--file-item">
+            <a :href="file.url" target="_blank" class="admin--file-name">{{ file.name }}</a>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeExistingFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 새 파일첨부 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">파일첨부</label>
+        <div v-if="attachedFiles.length > 0" class="admin--file-list">
+          <div v-for="(file, index) in attachedFiles" :key="'new-' + index" class="admin--file-item">
+            <span class="admin--file-name">{{ file.name }}</span>
+            <span class="admin--file-size">({{ formatFileSize(file.size) }})</span>
+            <button type="button" class="admin--btn-remove-file" @click="removeFile(index)">
+              삭제
+            </button>
+          </div>
+        </div>
+        <input
+          ref="fileInput"
+          type="file"
+          multiple
+          class="admin--form-file-hidden"
+          @change="handleFileAdd"
+        >
+        <button type="button" class="admin--btn admin--btn-secondary" @click="triggerFileInput">
+          파일 추가
+        </button>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import SunEditor from '~/components/admin/SunEditor.vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const route = useRoute()
+const router = useRouter()
+const { get, put, upload } = useApi()
+
+const isLoading = ref(true)
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const attachedFiles = ref([])
+const existingFiles = ref([])
+const fileInput = ref(null)
+
+const formData = ref({
+  allow_comment: false,
+  is_notice: false,
+  name: '',
+  email: '',
+  url: '',
+  title: '',
+  content: '',
+  file_urls: []
+})
+
+const loadNews = async () => {
+  isLoading.value = true
+
+  const id = route.params.id
+  const { data, error } = await get(`/board/news/${id}`)
+
+  if (data) {
+    formData.value = {
+      allow_comment: data.allow_comment || false,
+      is_notice: data.is_notice || false,
+      name: data.name || '',
+      email: data.email || '',
+      url: data.url || '',
+      title: data.title || '',
+      content: data.content || '',
+      file_urls: data.file_urls || []
+    }
+    existingFiles.value = data.file_urls || []
+  }
+
+  isLoading.value = false
+}
+
+const triggerFileInput = () => {
+  fileInput.value?.click()
+}
+
+const handleFileAdd = (event) => {
+  const files = Array.from(event.target.files)
+  attachedFiles.value.push(...files)
+  event.target.value = ''
+}
+
+const removeFile = (index) => {
+  attachedFiles.value.splice(index, 1)
+}
+
+const removeExistingFile = (index) => {
+  existingFiles.value.splice(index, 1)
+}
+
+const formatFileSize = (bytes) => {
+  if (bytes === 0) return '0 Bytes'
+  const k = 1024
+  const sizes = ['Bytes', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.title) {
+    errorMessage.value = '제목을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.content) {
+    errorMessage.value = '내용을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let fileUrls = [...existingFiles.value]
+
+    // 새 파일 업로드
+    if (attachedFiles.value.length > 0) {
+      for (const file of attachedFiles.value) {
+        const formDataFile = new FormData()
+        formDataFile.append('file', file)
+
+        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+
+        if (uploadError) {
+          errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
+          isSaving.value = false
+          return
+        }
+
+        fileUrls.push({
+          name: file.name,
+          url: uploadData.url,
+          size: file.size
+        })
+      }
+    }
+
+    const submitData = {
+      ...formData.value,
+      file_urls: fileUrls
+    }
+
+    const id = route.params.id
+    const { data, error } = await put(`/board/news/${id}`, submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '수정에 실패했습니다.'
+    } else {
+      successMessage.value = '뉴스가 수정되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/board/news')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/board/news')
+}
+
+onMounted(() => {
+  loadNews()
+})
+</script>

+ 134 - 0
app/pages/admin/board/news/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="admin--board-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="searchType" class="admin--form-select admin--search-select">
+          <option value="title">제목</option>
+          <option value="name">이름</option>
+          <option value="content">내용</option>
+        </select>
+        <input
+          v-model="searchKeyword"
+          type="text"
+          class="admin--form-input admin--search-input"
+          placeholder="검색어를 입력하세요"
+          @keyup.enter="handleSearch"
+        >
+        <button class="admin--btn admin--btn-primary" @click="handleSearch">검색</button>
+        <button class="admin--btn admin--btn-secondary" @click="handleReset">초기화</button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-primary" @click="goToCreate">+ 등록</button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>제목</th>
+            <th>이름</th>
+            <th>등록일</th>
+            <th>조회</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
+          </tr>
+          <tr v-else-if="!posts || posts.length === 0">
+            <td colspan="6" class="admin--table-empty">등록된 게시물이 없습니다.</td>
+          </tr>
+          <tr v-else v-for="(post, index) in posts" :key="post.id">
+            <td>{{ post.is_notice ? '공지' : totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td class="admin--table-title">{{ post.title }}</td>
+            <td>{{ post.name }}</td>
+            <td>{{ formatDate(post.created_at) }}</td>
+            <td>{{ post.views }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button class="admin--btn-small admin--btn-small-primary" @click="goToEdit(post.id)">수정</button>
+                <button class="admin--btn-small admin--btn-small-danger" @click="handleDelete(post.id)">삭제</button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button class="admin--pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)">이전</button>
+      <button v-for="page in visiblePages" :key="page" class="admin--pagination-btn" :class="{ 'is-active': page === currentPage }" @click="changePage(page)">{{ page }}</button>
+      <button class="admin--pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)">다음</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({ layout: 'admin', middleware: ['auth'] })
+
+const router = useRouter()
+const { get, del } = useApi()
+
+const isLoading = ref(false)
+const posts = ref([])
+const searchType = ref('title')
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+  if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1)
+  for (let i = start; i <= end; i++) pages.push(i)
+  return pages
+})
+
+const loadPosts = async () => {
+  isLoading.value = true
+  const params = { page: currentPage.value, per_page: perPage.value }
+  if (searchKeyword.value) {
+    params.search_type = searchType.value
+    params.search_keyword = searchKeyword.value
+  }
+  const { data } = await get('/board/news', params)
+  if (data) {
+    posts.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+  isLoading.value = false
+}
+
+const handleSearch = () => { currentPage.value = 1; loadPosts() }
+const handleReset = () => { searchType.value = 'title'; searchKeyword.value = ''; currentPage.value = 1; loadPosts() }
+const changePage = (page) => { if (page < 1 || page > totalPages.value) return; currentPage.value = page; loadPosts() }
+const goToCreate = () => router.push('/admin/board/news/create')
+const goToEdit = (id) => router.push(`/admin/board/news/edit/${id}`)
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+  const { error } = await del(`/board/news/${id}`)
+  if (error) alert('삭제에 실패했습니다.')
+  else { alert('삭제되었습니다.'); loadPosts() }
+}
+const formatDate = (dateString) => {
+  if (!dateString) return '-'
+  const date = new Date(dateString)
+  return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`
+}
+
+onMounted(() => loadPosts())
+</script>

+ 129 - 0
app/pages/admin/branch/list.vue

@@ -0,0 +1,129 @@
+<template>
+  <div class="admin--branch-list">
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>지점명</th>
+            <th>대표번호</th>
+            <th>주소</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="4" class="admin--table-loading">
+              데이터를 불러오는 중...
+            </td>
+          </tr>
+          <tr v-else-if="!branches || branches.length === 0">
+            <td colspan="4" class="admin--table-empty">
+              등록된 지점이 없습니다.
+            </td>
+          </tr>
+          <tr v-else v-for="(branch, index) in branches" :key="branch.id">
+            <td>{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td class="admin--table-title">{{ branch.name }}</td>
+            <td>{{ branch.phone }}</td>
+            <td>{{ branch.address }}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+      >
+        이전
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+      >
+        다음
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const { get } = useApi()
+
+const isLoading = ref(false)
+const branches = ref([])
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+// 보이는 페이지 번호 계산
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+
+  if (end - start < maxVisible - 1) {
+    start = Math.max(1, end - maxVisible + 1)
+  }
+
+  for (let i = start; i <= end; i++) {
+    pages.push(i)
+  }
+
+  return pages
+})
+
+// 데이터 로드
+const loadBranches = async () => {
+  isLoading.value = true
+
+  const params = {
+    page: currentPage.value,
+    per_page: perPage.value
+  }
+
+  const { data, error } = await get('/branch/list', params)
+
+  if (data) {
+    branches.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+
+  isLoading.value = false
+}
+
+// 페이지 변경
+const changePage = (page) => {
+  if (page < 1 || page > totalPages.value) return
+  currentPage.value = page
+  loadBranches()
+}
+
+onMounted(() => {
+  loadBranches()
+})
+</script>

+ 240 - 0
app/pages/admin/branch/manager/create.vue

@@ -0,0 +1,240 @@
+<template>
+  <div class="admin--manager-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 지점명 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">지점명 <span class="admin--required">*</span></label>
+        <select
+          v-model="formData.branch_id"
+          class="admin--form-select"
+          required
+        >
+          <option value="">지점을 선택하세요</option>
+          <option
+            v-for="branch in branches"
+            :key="branch.id"
+            :value="branch.id"
+          >
+            {{ branch.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 아이디 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">아이디 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.user_id"
+          type="text"
+          class="admin--form-input"
+          placeholder="아이디를 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 비밀번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">비밀번호 <span class="admin--required">*</span></label>
+        <div class="admin--password-input-wrapper">
+          <input
+            v-model="formData.password"
+            :type="showPassword ? 'text' : 'password'"
+            class="admin--form-input"
+            placeholder="비밀번호를 입력하세요"
+            required
+          >
+          <button
+            type="button"
+            class="admin--password-toggle"
+            @click="showPassword = !showPassword"
+          >
+            {{ showPassword ? '👁️' : '👁️‍🗨️' }}
+          </button>
+        </div>
+      </div>
+
+      <!-- 비밀번호 확인 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">비밀번호 확인 <span class="admin--required">*</span></label>
+        <div class="admin--password-input-wrapper">
+          <input
+            v-model="formData.password_confirm"
+            :type="showPasswordConfirm ? 'text' : 'password'"
+            class="admin--form-input"
+            placeholder="비밀번호를 다시 입력하세요"
+            required
+          >
+          <button
+            type="button"
+            class="admin--password-toggle"
+            @click="showPasswordConfirm = !showPasswordConfirm"
+          >
+            {{ showPasswordConfirm ? '👁️' : '👁️‍🗨️' }}
+          </button>
+        </div>
+      </div>
+
+      <!-- 관리자명 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">관리자명 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="email@example.com"
+          required
+        >
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, post } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const showPassword = ref(false)
+const showPasswordConfirm = ref(false)
+const branches = ref([])
+
+const formData = ref({
+  branch_id: '',
+  user_id: '',
+  password: '',
+  password_confirm: '',
+  name: '',
+  email: ''
+})
+
+// 지점 목록 로드
+const loadBranches = async () => {
+  const { data, error } = await get('/branch/list', { per_page: 1000 })
+
+  if (data) {
+    branches.value = data.items || []
+  }
+}
+
+// 폼 제출
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  // 유효성 검사
+  if (!formData.value.branch_id) {
+    errorMessage.value = '지점을 선택하세요.'
+    return
+  }
+
+  if (!formData.value.user_id) {
+    errorMessage.value = '아이디를 입력하세요.'
+    return
+  }
+
+  if (!formData.value.password) {
+    errorMessage.value = '비밀번호를 입력하세요.'
+    return
+  }
+
+  if (formData.value.password !== formData.value.password_confirm) {
+    errorMessage.value = '비밀번호가 일치하지 않습니다.'
+    return
+  }
+
+  if (!formData.value.name) {
+    errorMessage.value = '관리자명을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.email) {
+    errorMessage.value = '이메일을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    const submitData = {
+      branch_id: formData.value.branch_id,
+      user_id: formData.value.user_id,
+      password: formData.value.password,
+      name: formData.value.name,
+      email: formData.value.email
+    }
+
+    const { data, error } = await post('/branch/manager', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = '지점장이 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/branch/manager')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+// 목록으로 이동
+const goToList = () => {
+  router.push('/admin/branch/manager')
+}
+
+onMounted(() => {
+  loadBranches()
+})
+</script>

+ 264 - 0
app/pages/admin/branch/manager/edit/[id].vue

@@ -0,0 +1,264 @@
+<template>
+  <div class="admin--manager-form">
+    <div v-if="isLoading" class="admin--loading">
+      데이터를 불러오는 중...
+    </div>
+
+    <form v-else @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 지점명 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">지점명 <span class="admin--required">*</span></label>
+        <select
+          v-model="formData.branch_id"
+          class="admin--form-select"
+          required
+        >
+          <option value="">지점을 선택하세요</option>
+          <option
+            v-for="branch in branches"
+            :key="branch.id"
+            :value="branch.id"
+          >
+            {{ branch.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 아이디 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">아이디 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.user_id"
+          type="text"
+          class="admin--form-input"
+          placeholder="아이디를 입력하세요"
+          required
+          disabled
+        >
+        <p class="admin--form-help">아이디는 수정할 수 없습니다.</p>
+      </div>
+
+      <!-- 비밀번호 (선택사항) -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">비밀번호</label>
+        <div class="admin--password-input-wrapper">
+          <input
+            v-model="formData.password"
+            :type="showPassword ? 'text' : 'password'"
+            class="admin--form-input"
+            placeholder="변경할 비밀번호를 입력하세요"
+          >
+          <button
+            type="button"
+            class="admin--password-toggle"
+            @click="showPassword = !showPassword"
+          >
+            {{ showPassword ? '👁️' : '👁️‍🗨️' }}
+          </button>
+        </div>
+        <p class="admin--form-help">비밀번호를 변경하지 않으려면 비워두세요.</p>
+      </div>
+
+      <!-- 비밀번호 확인 -->
+      <div v-if="formData.password" class="admin--form-group">
+        <label class="admin--form-label">비밀번호 확인 <span class="admin--required">*</span></label>
+        <div class="admin--password-input-wrapper">
+          <input
+            v-model="formData.password_confirm"
+            :type="showPasswordConfirm ? 'text' : 'password'"
+            class="admin--form-input"
+            placeholder="비밀번호를 다시 입력하세요"
+            required
+          >
+          <button
+            type="button"
+            class="admin--password-toggle"
+            @click="showPasswordConfirm = !showPasswordConfirm"
+          >
+            {{ showPasswordConfirm ? '👁️' : '👁️‍🗨️' }}
+          </button>
+        </div>
+      </div>
+
+      <!-- 관리자명 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">관리자명 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="email@example.com"
+          required
+        >
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const route = useRoute()
+const router = useRouter()
+const { get, put } = useApi()
+
+const isLoading = ref(true)
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const showPassword = ref(false)
+const showPasswordConfirm = ref(false)
+const branches = ref([])
+
+const formData = ref({
+  branch_id: '',
+  user_id: '',
+  password: '',
+  password_confirm: '',
+  name: '',
+  email: ''
+})
+
+// 지점 목록 로드
+const loadBranches = async () => {
+  const { data, error } = await get('/branch/list', { per_page: 1000 })
+
+  if (data) {
+    branches.value = data.items || []
+  }
+}
+
+// 데이터 로드
+const loadManager = async () => {
+  isLoading.value = true
+
+  const id = route.params.id
+  const { data, error } = await get(`/branch/manager/${id}`)
+
+  if (data) {
+    formData.value = {
+      branch_id: data.branch_id || '',
+      user_id: data.user_id || '',
+      password: '',
+      password_confirm: '',
+      name: data.name || '',
+      email: data.email || ''
+    }
+  }
+
+  isLoading.value = false
+}
+
+// 폼 제출
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  // 유효성 검사
+  if (!formData.value.branch_id) {
+    errorMessage.value = '지점을 선택하세요.'
+    return
+  }
+
+  if (formData.value.password && formData.value.password !== formData.value.password_confirm) {
+    errorMessage.value = '비밀번호가 일치하지 않습니다.'
+    return
+  }
+
+  if (!formData.value.name) {
+    errorMessage.value = '관리자명을 입력하세요.'
+    return
+  }
+
+  if (!formData.value.email) {
+    errorMessage.value = '이메일을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    const submitData = {
+      branch_id: formData.value.branch_id,
+      name: formData.value.name,
+      email: formData.value.email
+    }
+
+    // 비밀번호가 입력된 경우에만 포함
+    if (formData.value.password) {
+      submitData.password = formData.value.password
+    }
+
+    const id = route.params.id
+    const { data, error } = await put(`/branch/manager/${id}`, submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '수정에 실패했습니다.'
+    } else {
+      successMessage.value = '지점장 정보가 수정되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/branch/manager')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+// 목록으로 이동
+const goToList = () => {
+  router.push('/admin/branch/manager')
+}
+
+onMounted(async () => {
+  await loadBranches()
+  await loadManager()
+})
+</script>

+ 225 - 0
app/pages/admin/branch/manager/index.vue

@@ -0,0 +1,225 @@
+<template>
+  <div class="admin--manager-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="searchType" class="admin--form-select admin--search-select">
+          <option value="branch_name">지점명</option>
+          <option value="name">이름</option>
+          <option value="user_id">아이디</option>
+          <option value="email">이메일</option>
+        </select>
+        <input
+          v-model="searchKeyword"
+          type="text"
+          class="admin--form-input admin--search-input"
+          placeholder="검색어를 입력하세요"
+          @keyup.enter="handleSearch"
+        >
+        <button class="admin--btn admin--btn-primary" @click="handleSearch">
+          검색
+        </button>
+        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+          초기화
+        </button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+          + 지점장 등록
+        </button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>지점명</th>
+            <th>이름</th>
+            <th>아이디</th>
+            <th>이메일</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="6" class="admin--table-loading">
+              데이터를 불러오는 중...
+            </td>
+          </tr>
+          <tr v-else-if="!managers || managers.length === 0">
+            <td colspan="6" class="admin--table-empty">
+              등록된 지점장이 없습니다.
+            </td>
+          </tr>
+          <tr v-else v-for="(manager, index) in managers" :key="manager.id">
+            <td>{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td>{{ manager.branch_name }}</td>
+            <td class="admin--table-title">{{ manager.name }}</td>
+            <td>{{ manager.user_id }}</td>
+            <td>{{ manager.email }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button
+                  class="admin--btn-small admin--btn-small-primary"
+                  @click="goToEdit(manager.id)"
+                >
+                  수정
+                </button>
+                <button
+                  class="admin--btn-small admin--btn-small-danger"
+                  @click="handleDelete(manager.id)"
+                >
+                  삭제
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+      >
+        이전
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+      >
+        다음
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, del } = useApi()
+
+const isLoading = ref(false)
+const managers = ref([])
+const searchType = ref('branch_name')
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+// 보이는 페이지 번호 계산
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+
+  if (end - start < maxVisible - 1) {
+    start = Math.max(1, end - maxVisible + 1)
+  }
+
+  for (let i = start; i <= end; i++) {
+    pages.push(i)
+  }
+
+  return pages
+})
+
+// 데이터 로드
+const loadManagers = async () => {
+  isLoading.value = true
+
+  const params = {
+    page: currentPage.value,
+    per_page: perPage.value
+  }
+
+  if (searchKeyword.value) {
+    params.search_type = searchType.value
+    params.search_keyword = searchKeyword.value
+  }
+
+  const { data, error } = await get('/branch/manager', params)
+
+  if (data) {
+    managers.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+
+  isLoading.value = false
+}
+
+// 검색
+const handleSearch = () => {
+  currentPage.value = 1
+  loadManagers()
+}
+
+// 초기화
+const handleReset = () => {
+  searchType.value = 'branch_name'
+  searchKeyword.value = ''
+  currentPage.value = 1
+  loadManagers()
+}
+
+// 페이지 변경
+const changePage = (page) => {
+  if (page < 1 || page > totalPages.value) return
+  currentPage.value = page
+  loadManagers()
+}
+
+// 등록 페이지로 이동
+const goToCreate = () => {
+  router.push('/admin/branch/manager/create')
+}
+
+// 수정 페이지로 이동
+const goToEdit = (id) => {
+  router.push(`/admin/branch/manager/edit/${id}`)
+}
+
+// 삭제
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+
+  const { error } = await del(`/branch/manager/${id}`)
+
+  if (error) {
+    alert('삭제에 실패했습니다.')
+  } else {
+    alert('삭제되었습니다.')
+    loadManagers()
+  }
+}
+
+onMounted(() => {
+  loadManagers()
+})
+</script>

+ 88 - 0
app/pages/admin/dashboard.vue

@@ -0,0 +1,88 @@
+<template>
+  <div class="admin--dashboard">
+    <div class="admin--dashboard-welcome">
+      <h3>관리자 대시보드</h3>
+      <p>고진모터스 AUDI 관리자 페이지에 오신 것을 환영합니다.</p>
+    </div>
+
+    <div class="admin--dashboard-stats">
+      <div class="admin--stat-card">
+        <div class="admin--stat-icon">📊</div>
+        <div class="admin--stat-content">
+          <h4>총 팝업</h4>
+          <p class="admin--stat-number">{{ stats.popups }}</p>
+        </div>
+      </div>
+
+      <div class="admin--stat-card">
+        <div class="admin--stat-icon">🏢</div>
+        <div class="admin--stat-content">
+          <h4>총 지점</h4>
+          <p class="admin--stat-number">{{ stats.branches }}</p>
+        </div>
+      </div>
+
+      <div class="admin--stat-card">
+        <div class="admin--stat-icon">👥</div>
+        <div class="admin--stat-content">
+          <h4>총 직원</h4>
+          <p class="admin--stat-number">{{ stats.employees }}</p>
+        </div>
+      </div>
+
+      <div class="admin--stat-card">
+        <div class="admin--stat-icon">📝</div>
+        <div class="admin--stat-content">
+          <h4>브로셔 요청</h4>
+          <p class="admin--stat-number">{{ stats.brochures }}</p>
+        </div>
+      </div>
+    </div>
+
+    <div class="admin--dashboard-recent">
+      <h4>최근 활동</h4>
+      <div class="admin--recent-list">
+        <p class="admin--no-data">최근 활동 내역이 없습니다.</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const stats = ref({
+  popups: 0,
+  branches: 0,
+  employees: 0,
+  brochures: 0
+})
+
+const { get } = useApi()
+
+const loadDashboardStats = async () => {
+  const { data, error } = await get('/dashboard/stats')
+
+  console.log('[Dashboard] API 응답:', { data, error })
+
+  if (error) {
+    console.error('[Dashboard] 통계 로드 실패:', error)
+    return
+  }
+
+  // API 응답: { success: true, data: { popups, branches, employees, brochures } }
+  if (data?.success && data?.data) {
+    stats.value = data.data
+    console.log('[Dashboard] 통계 로드 성공:', stats.value)
+  }
+}
+
+onMounted(() => {
+  loadDashboardStats()
+})
+</script>

+ 65 - 22
app/pages/admin/index.vue

@@ -8,36 +8,42 @@
         </div>
         
         <form @submit.prevent="handleLogin" class="login--form">
+          <div v-if="errorMessage" class="login--error">
+            {{ errorMessage }}
+          </div>
+
           <div class="form--group">
-            <input 
-              v-model="email"
-              type="email" 
-              placeholder="이메일"
+            <input
+              v-model="username"
+              type="text"
+              placeholder="아이디"
               class="form--input"
               required
+              :disabled="isLoading"
             >
           </div>
-          
+
           <div class="form--group">
-            <input 
+            <input
               v-model="password"
-              type="password" 
+              type="password"
               placeholder="비밀번호"
               class="form--input"
               required
+              :disabled="isLoading"
             >
           </div>
-          
+
           <div class="form--options">
             <label class="checkbox--label">
-              <input type="checkbox" v-model="rememberMe">
+              <input type="checkbox" v-model="rememberMe" :disabled="isLoading">
               <span>로그인 상태 유지</span>
             </label>
             <a href="#" class="forgot--password">비밀번호 찾기</a>
           </div>
-          
-          <button type="submit" class="login--button">
-            로그인
+
+          <button type="submit" class="login--button" :disabled="isLoading">
+            {{ isLoading ? '로그인 중...' : '로그인' }}
           </button>
         </form>
         
@@ -51,26 +57,63 @@
 
 <script setup>
 import { ref } from 'vue'
-import { useRouter } from 'vue-router'
 
-const router = useRouter()
-const email = ref('')
+definePageMeta({
+  layout: false,
+  middleware: ['auth']
+})
+
+const { post } = useApi()
+
+const username = ref('')
 const password = ref('')
 const rememberMe = ref(false)
+const isLoading = ref(false)
+const errorMessage = ref('')
 
 const handleLogin = async () => {
+  if (!username.value || !password.value) {
+    errorMessage.value = '아이디와 비밀번호를 입력해주세요.'
+    return
+  }
+
+  isLoading.value = true
+  errorMessage.value = ''
+
   try {
-    // 로그인 로직 구현
-    console.log('Login attempt:', {
-      email: email.value,
+    const { data, error } = await post('/auth/login', {
+      username: username.value,
       password: password.value,
-      rememberMe: rememberMe.value
+      remember: rememberMe.value
     })
-    
-    // 로그인 성공 시 대시보드로 이동
-    // router.push('/admin/dashboard')
+
+    if (error) {
+      errorMessage.value = error.message || '로그인에 실패했습니다.'
+      return
+    }
+
+    // API 응답: { success: true, data: { token, admin } }
+    if (data?.success && data?.data?.token) {
+      console.log('[Login] 로그인 성공, 토큰 저장:', data.data.token.substring(0, 20) + '...')
+
+      localStorage.setItem('admin_token', data.data.token)
+
+      if (data.data.admin) {
+        localStorage.setItem('admin_user', JSON.stringify(data.data.admin))
+      }
+
+      console.log('[Login] localStorage 확인:', localStorage.getItem('admin_token') ? '저장됨' : '저장 실패')
+
+      // 로그인 성공 후 dashboard로 이동
+      await navigateTo('/admin/dashboard')
+    } else {
+      errorMessage.value = data?.message || '로그인 정보가 올바르지 않습니다.'
+    }
   } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
     console.error('Login error:', error)
+  } finally {
+    isLoading.value = false
   }
 }
 </script>

+ 229 - 0
app/pages/admin/service/brochure.vue

@@ -0,0 +1,229 @@
+<template>
+  <div class="admin--brochure-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <input
+          v-model="searchKeyword"
+          type="text"
+          class="admin--form-input admin--search-input"
+          placeholder="신청자명으로 검색"
+          @keyup.enter="handleSearch"
+        >
+        <button class="admin--btn admin--btn-primary" @click="handleSearch">
+          검색
+        </button>
+        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+          초기화
+        </button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-secondary" @click="handleExcelDownload">
+          엑셀 다운로드
+        </button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>신청자</th>
+            <th>지점</th>
+            <th>희망차종</th>
+            <th>핸드폰</th>
+            <th>구입예상</th>
+            <th>신청일자</th>
+            <th>상태</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="9" class="admin--table-loading">
+              데이터를 불러오는 중...
+            </td>
+          </tr>
+          <tr v-else-if="!brochures || brochures.length === 0">
+            <td colspan="9" class="admin--table-empty">
+              브로셔 요청이 없습니다.
+            </td>
+          </tr>
+          <tr v-else v-for="(brochure, index) in brochures" :key="brochure.id">
+            <td>{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td class="admin--table-title">{{ brochure.name }}</td>
+            <td>{{ brochure.branch_name }}</td>
+            <td>{{ brochure.car_model }}</td>
+            <td>{{ brochure.phone }}</td>
+            <td>{{ brochure.purchase_plan }}</td>
+            <td>{{ formatDate(brochure.created_at) }}</td>
+            <td>
+              <select
+                v-model="brochure.status"
+                class="admin--status-select"
+                @change="handleStatusChange(brochure.id, brochure.status)"
+              >
+                <option value="접수">접수</option>
+                <option value="접수완료">접수완료</option>
+                <option value="계약완료">계약완료</option>
+                <option value="출고완료">출고완료</option>
+              </select>
+            </td>
+            <td>
+              <button
+                class="admin--btn-small admin--btn-small-danger"
+                @click="handleDelete(brochure.id)"
+              >
+                삭제
+              </button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+      >
+        이전
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+      >
+        다음
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const { get, put, del } = useApi()
+
+const isLoading = ref(false)
+const brochures = ref([])
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+
+  if (end - start < maxVisible - 1) {
+    start = Math.max(1, end - maxVisible + 1)
+  }
+
+  for (let i = start; i <= end; i++) {
+    pages.push(i)
+  }
+
+  return pages
+})
+
+const loadBrochures = async () => {
+  isLoading.value = true
+
+  const params = {
+    page: currentPage.value,
+    per_page: perPage.value
+  }
+
+  if (searchKeyword.value) {
+    params.search_keyword = searchKeyword.value
+  }
+
+  const { data, error } = await get('/service/brochure', params)
+
+  if (data) {
+    brochures.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+
+  isLoading.value = false
+}
+
+const handleSearch = () => {
+  currentPage.value = 1
+  loadBrochures()
+}
+
+const handleReset = () => {
+  searchKeyword.value = ''
+  currentPage.value = 1
+  loadBrochures()
+}
+
+const changePage = (page) => {
+  if (page < 1 || page > totalPages.value) return
+  currentPage.value = page
+  loadBrochures()
+}
+
+const handleExcelDownload = () => {
+  const params = searchKeyword.value ? { search_keyword: searchKeyword.value } : {}
+  window.open(`/api/service/brochure/excel?${new URLSearchParams(params)}`, '_blank')
+}
+
+const handleStatusChange = async (id, status) => {
+  const { error } = await put(`/service/brochure/${id}/status`, { status })
+
+  if (error) {
+    alert('상태 변경에 실패했습니다.')
+    loadBrochures()
+  } else {
+    alert('상태가 변경되었습니다.')
+  }
+}
+
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+
+  const { error } = await del(`/service/brochure/${id}`)
+
+  if (error) {
+    alert('삭제에 실패했습니다.')
+  } else {
+    alert('삭제되었습니다.')
+    loadBrochures()
+  }
+}
+
+const formatDate = (dateString) => {
+  if (!dateString) return '-'
+  const date = new Date(dateString)
+  return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`
+}
+
+onMounted(() => {
+  loadBrochures()
+})
+</script>

+ 228 - 0
app/pages/admin/staff/advisor/create.vue

@@ -0,0 +1,228 @@
+<template>
+  <div class="admin--advisor-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 서비스센터 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">서비스센터 <span class="admin--required">*</span></label>
+        <select v-model="formData.service_center_id" class="admin--form-select" required>
+          <option value="">서비스센터를 선택하세요</option>
+          <option v-for="center in serviceCenters" :key="center.id" :value="center.id">
+            {{ center.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 대표번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">대표번호 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.main_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5678"
+          required
+        >
+      </div>
+
+      <!-- 직통번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">직통번호</label>
+        <input
+          v-model="formData.direct_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5679"
+        >
+      </div>
+
+      <!-- 핸드폰 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">핸드폰</label>
+        <input
+          v-model="formData.mobile"
+          type="tel"
+          class="admin--form-input"
+          placeholder="010-1234-5678"
+        >
+      </div>
+
+      <!-- 사진 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">사진</label>
+        <input
+          type="file"
+          accept="image/*"
+          class="admin--form-file"
+          @change="handlePhotoUpload"
+        >
+        <div v-if="photoPreview" class="admin--image-preview">
+          <img :src="photoPreview" alt="미리보기">
+          <button type="button" class="admin--btn-remove-image" @click="removePhoto">
+            삭제
+          </button>
+        </div>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, post, upload } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const photoPreview = ref(null)
+const photoFile = ref(null)
+const serviceCenters = ref([])
+
+const formData = ref({
+  service_center_id: '',
+  name: '',
+  main_phone: '',
+  direct_phone: '',
+  mobile: '',
+  photo_url: ''
+})
+
+const loadServiceCenters = async () => {
+  const { data } = await get('/staff/service-centers')
+  if (data) serviceCenters.value = data
+}
+
+const handlePhotoUpload = (event) => {
+  const file = event.target.files[0]
+  if (!file) return
+
+  if (!file.type.startsWith('image/')) {
+    alert('이미지 파일만 업로드 가능합니다.')
+    return
+  }
+
+  photoFile.value = file
+
+  const reader = new FileReader()
+  reader.onload = (e) => {
+    photoPreview.value = e.target.result
+  }
+  reader.readAsDataURL(file)
+}
+
+const removePhoto = () => {
+  photoPreview.value = null
+  photoFile.value = null
+  formData.value.photo_url = ''
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.service_center_id) {
+    errorMessage.value = '서비스센터를 선택하세요.'
+    return
+  }
+
+  if (!formData.value.name) {
+    errorMessage.value = '이름을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let photoUrl = formData.value.photo_url
+
+    if (photoFile.value) {
+      const formDataImage = new FormData()
+      formDataImage.append('image', photoFile.value)
+
+      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+
+      if (uploadError) {
+        errorMessage.value = '사진 업로드에 실패했습니다.'
+        isSaving.value = false
+        return
+      }
+
+      photoUrl = uploadData.url
+    }
+
+    const submitData = {
+      ...formData.value,
+      photo_url: photoUrl
+    }
+
+    const { data, error } = await post('/staff/advisor', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = '어드바이저가 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/staff/advisor')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/staff/advisor')
+}
+
+onMounted(() => {
+  loadServiceCenters()
+})
+</script>

+ 228 - 0
app/pages/admin/staff/advisor/edit/[id].vue

@@ -0,0 +1,228 @@
+<template>
+  <div class="admin--advisor-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 서비스센터 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">서비스센터 <span class="admin--required">*</span></label>
+        <select v-model="formData.service_center_id" class="admin--form-select" required>
+          <option value="">서비스센터를 선택하세요</option>
+          <option v-for="center in serviceCenters" :key="center.id" :value="center.id">
+            {{ center.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 대표번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">대표번호 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.main_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5678"
+          required
+        >
+      </div>
+
+      <!-- 직통번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">직통번호</label>
+        <input
+          v-model="formData.direct_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5679"
+        >
+      </div>
+
+      <!-- 핸드폰 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">핸드폰</label>
+        <input
+          v-model="formData.mobile"
+          type="tel"
+          class="admin--form-input"
+          placeholder="010-1234-5678"
+        >
+      </div>
+
+      <!-- 사진 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">사진</label>
+        <input
+          type="file"
+          accept="image/*"
+          class="admin--form-file"
+          @change="handlePhotoUpload"
+        >
+        <div v-if="photoPreview" class="admin--image-preview">
+          <img :src="photoPreview" alt="미리보기">
+          <button type="button" class="admin--btn-remove-image" @click="removePhoto">
+            삭제
+          </button>
+        </div>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, post, upload } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const photoPreview = ref(null)
+const photoFile = ref(null)
+const serviceCenters = ref([])
+
+const formData = ref({
+  service_center_id: '',
+  name: '',
+  main_phone: '',
+  direct_phone: '',
+  mobile: '',
+  photo_url: ''
+})
+
+const loadServiceCenters = async () => {
+  const { data } = await get('/staff/service-centers')
+  if (data) serviceCenters.value = data
+}
+
+const handlePhotoUpload = (event) => {
+  const file = event.target.files[0]
+  if (!file) return
+
+  if (!file.type.startsWith('image/')) {
+    alert('이미지 파일만 업로드 가능합니다.')
+    return
+  }
+
+  photoFile.value = file
+
+  const reader = new FileReader()
+  reader.onload = (e) => {
+    photoPreview.value = e.target.result
+  }
+  reader.readAsDataURL(file)
+}
+
+const removePhoto = () => {
+  photoPreview.value = null
+  photoFile.value = null
+  formData.value.photo_url = ''
+}
+
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.service_center_id) {
+    errorMessage.value = '서비스센터를 선택하세요.'
+    return
+  }
+
+  if (!formData.value.name) {
+    errorMessage.value = '이름을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let photoUrl = formData.value.photo_url
+
+    if (photoFile.value) {
+      const formDataImage = new FormData()
+      formDataImage.append('image', photoFile.value)
+
+      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+
+      if (uploadError) {
+        errorMessage.value = '사진 업로드에 실패했습니다.'
+        isSaving.value = false
+        return
+      }
+
+      photoUrl = uploadData.url
+    }
+
+    const submitData = {
+      ...formData.value,
+      photo_url: photoUrl
+    }
+
+    const { data, error } = await post('/staff/advisor', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = '어드바이저가 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/staff/advisor')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/staff/advisor')
+}
+
+onMounted(() => {
+  loadServiceCenters()
+})
+</script>

+ 223 - 0
app/pages/admin/staff/advisor/index.vue

@@ -0,0 +1,223 @@
+<template>
+  <div class="admin--advisor-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="searchType" class="admin--form-select admin--search-select">
+          <option value="all">전체</option>
+          <option value="name">이름</option>
+          <option value="service_center">서비스센터</option>
+        </select>
+        <input
+          v-model="searchKeyword"
+          type="text"
+          class="admin--form-input admin--search-input"
+          placeholder="검색어를 입력하세요"
+          @keyup.enter="handleSearch"
+        >
+        <button class="admin--btn admin--btn-primary" @click="handleSearch">
+          검색
+        </button>
+        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+          초기화
+        </button>
+      </div>
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+          + 어드바이저 등록
+        </button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>사진</th>
+            <th>서비스센터</th>
+            <th>이름</th>
+            <th>대표번호</th>
+            <th>핸드폰</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="7" class="admin--table-loading">
+              데이터를 불러오는 중...
+            </td>
+          </tr>
+          <tr v-else-if="!advisors || advisors.length === 0">
+            <td colspan="7" class="admin--table-empty">
+              등록된 어드바이저가 없습니다.
+            </td>
+          </tr>
+          <tr v-else v-for="(advisor, index) in advisors" :key="advisor.id">
+            <td>{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td>
+              <div class="admin--table-photo">
+                <img v-if="advisor.photo_url" :src="advisor.photo_url" :alt="advisor.name">
+                <div v-else class="admin--table-photo-empty">사진없음</div>
+              </div>
+            </td>
+            <td>{{ advisor.service_center_name }}</td>
+            <td class="admin--table-title">{{ advisor.name }}</td>
+            <td>{{ advisor.main_phone }}</td>
+            <td>{{ advisor.mobile }}</td>
+            <td>
+              <div class="admin--table-actions">
+                <button
+                  class="admin--btn-small admin--btn-small-primary"
+                  @click="goToEdit(advisor.id)"
+                >
+                  수정
+                </button>
+                <button
+                  class="admin--btn-small admin--btn-small-danger"
+                  @click="handleDelete(advisor.id)"
+                >
+                  삭제
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+      >
+        이전
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+      >
+        다음
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, del } = useApi()
+
+const isLoading = ref(false)
+const advisors = ref([])
+const searchType = ref('all')
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+
+  if (end - start < maxVisible - 1) {
+    start = Math.max(1, end - maxVisible + 1)
+  }
+
+  for (let i = start; i <= end; i++) {
+    pages.push(i)
+  }
+
+  return pages
+})
+
+const loadAdvisors = async () => {
+  isLoading.value = true
+
+  const params = {
+    page: currentPage.value,
+    per_page: perPage.value
+  }
+
+  if (searchKeyword.value) {
+    params.search_type = searchType.value
+    params.search_keyword = searchKeyword.value
+  }
+
+  const { data, error } = await get('/staff/advisor', params)
+
+  if (data) {
+    advisors.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+
+  isLoading.value = false
+}
+
+const handleSearch = () => {
+  currentPage.value = 1
+  loadAdvisors()
+}
+
+const handleReset = () => {
+  searchType.value = 'all'
+  searchKeyword.value = ''
+  currentPage.value = 1
+  loadAdvisors()
+}
+
+const changePage = (page) => {
+  if (page < 1 || page > totalPages.value) return
+  currentPage.value = page
+  loadAdvisors()
+}
+
+const goToCreate = () => {
+  router.push('/admin/staff/advisor/create')
+}
+
+const goToEdit = (id) => {
+  router.push(`/admin/staff/advisor/edit/${id}`)
+}
+
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+
+  const { error } = await del(`/staff/advisor/${id}`)
+
+  if (error) {
+    alert('삭제에 실패했습니다.')
+  } else {
+    alert('삭제되었습니다.')
+    loadAdvisors()
+  }
+}
+
+onMounted(() => {
+  loadAdvisors()
+})
+</script>

+ 340 - 0
app/pages/admin/staff/sales/create.vue

@@ -0,0 +1,340 @@
+<template>
+  <div class="admin--sales-form">
+    <form @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 전시장 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">전시장 <span class="admin--required">*</span></label>
+        <select v-model="formData.showroom_id" class="admin--form-select" required>
+          <option value="">전시장을 선택하세요</option>
+          <option v-for="showroom in showrooms" :key="showroom.id" :value="showroom.id">
+            {{ showroom.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 지점 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">지점 <span class="admin--required">*</span></label>
+        <select v-model="formData.branch_id" class="admin--form-select" required>
+          <option value="">지점을 선택하세요</option>
+          <option v-for="branch in branches" :key="branch.id" :value="branch.id">
+            {{ branch.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 영업팀 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">영업팀 <span class="admin--required">*</span></label>
+        <select v-model="formData.team_id" class="admin--form-select" required>
+          <option value="">영업팀을 선택하세요</option>
+          <option v-for="team in teams" :key="team.id" :value="team.id">
+            {{ team.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 직책 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">직책 <span class="admin--required">*</span></label>
+        <select v-model="formData.position" class="admin--form-select" required>
+          <option value="">직책을 선택하세요</option>
+          <option value="팀장">팀장</option>
+          <option value="마스터">마스터</option>
+          <option value="차장">차장</option>
+          <option value="과장">과장</option>
+          <option value="대리">대리</option>
+          <option value="주임">주임</option>
+          <option value="사원">사원</option>
+        </select>
+      </div>
+
+      <!-- 대표번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">대표번호 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.main_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5678"
+          required
+        >
+      </div>
+
+      <!-- 직통번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">직통번호</label>
+        <input
+          v-model="formData.direct_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5679"
+        >
+      </div>
+
+      <!-- 핸드폰 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">핸드폰</label>
+        <input
+          v-model="formData.mobile"
+          type="tel"
+          class="admin--form-input"
+          placeholder="010-1234-5678"
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일</label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="email@example.com"
+        >
+      </div>
+
+      <!-- 사진 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">사진</label>
+        <input
+          type="file"
+          accept="image/*"
+          class="admin--form-file"
+          @change="handlePhotoUpload"
+        >
+        <div v-if="photoPreview" class="admin--image-preview">
+          <img :src="photoPreview" alt="미리보기">
+          <button type="button" class="admin--btn-remove-image" @click="removePhoto">
+            삭제
+          </button>
+        </div>
+      </div>
+
+      <!-- SACT -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">SACT</label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input v-model="formData.is_sact" type="radio" :value="true" name="is_sact">
+            <span>예</span>
+          </label>
+          <label class="admin--radio-label">
+            <input v-model="formData.is_sact" type="radio" :value="false" name="is_sact">
+            <span>아니오</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- TOP30 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">TOP30</label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input v-model="formData.is_top30" type="radio" :value="true" name="is_top30">
+            <span>예</span>
+          </label>
+          <label class="admin--radio-label">
+            <input v-model="formData.is_top30" type="radio" :value="false" name="is_top30">
+            <span>아니오</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 노출순서 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">노출순서</label>
+        <input
+          v-model.number="formData.display_order"
+          type="number"
+          class="admin--form-input"
+          placeholder="숫자만 입력"
+          min="0"
+        >
+        <p class="admin--form-help">숫자가 작을수록 먼저 노출됩니다.</p>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, post, upload } = useApi()
+
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const photoPreview = ref(null)
+const photoFile = ref(null)
+
+const showrooms = ref([])
+const branches = ref([])
+const teams = ref([])
+
+const formData = ref({
+  showroom_id: '',
+  branch_id: '',
+  team_id: '',
+  name: '',
+  position: '',
+  main_phone: '',
+  direct_phone: '',
+  mobile: '',
+  email: '',
+  photo_url: '',
+  is_sact: false,
+  is_top30: false,
+  display_order: 0
+})
+
+// 필터 데이터 로드
+const loadFilters = async () => {
+  const { data: showroomData } = await get('/staff/showrooms')
+  if (showroomData) showrooms.value = showroomData
+
+  const { data: branchData } = await get('/branch/list', { per_page: 1000 })
+  if (branchData) branches.value = branchData.items || []
+
+  const { data: teamData } = await get('/staff/teams')
+  if (teamData) teams.value = teamData
+}
+
+// 사진 업로드
+const handlePhotoUpload = (event) => {
+  const file = event.target.files[0]
+  if (!file) return
+
+  if (!file.type.startsWith('image/')) {
+    alert('이미지 파일만 업로드 가능합니다.')
+    return
+  }
+
+  photoFile.value = file
+
+  const reader = new FileReader()
+  reader.onload = (e) => {
+    photoPreview.value = e.target.result
+  }
+  reader.readAsDataURL(file)
+}
+
+// 사진 삭제
+const removePhoto = () => {
+  photoPreview.value = null
+  photoFile.value = null
+  formData.value.photo_url = ''
+}
+
+// 폼 제출
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.showroom_id || !formData.value.branch_id || !formData.value.team_id) {
+    errorMessage.value = '전시장, 지점, 영업팀을 선택하세요.'
+    return
+  }
+
+  if (!formData.value.name) {
+    errorMessage.value = '이름을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let photoUrl = formData.value.photo_url
+
+    // 사진 업로드
+    if (photoFile.value) {
+      const formDataImage = new FormData()
+      formDataImage.append('image', photoFile.value)
+
+      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+
+      if (uploadError) {
+        errorMessage.value = '사진 업로드에 실패했습니다.'
+        isSaving.value = false
+        return
+      }
+
+      photoUrl = uploadData.url
+    }
+
+    const submitData = {
+      ...formData.value,
+      photo_url: photoUrl
+    }
+
+    const { data, error } = await post('/staff/sales', submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '등록에 실패했습니다.'
+    } else {
+      successMessage.value = '영업사원이 등록되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/staff/sales')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/staff/sales')
+}
+
+onMounted(() => {
+  loadFilters()
+})
+</script>

+ 376 - 0
app/pages/admin/staff/sales/edit/[id].vue

@@ -0,0 +1,376 @@
+<template>
+  <div class="admin--sales-form">
+    <div v-if="isLoading" class="admin--loading">
+      데이터를 불러오는 중...
+    </div>
+
+    <form v-else @submit.prevent="handleSubmit" class="admin--form">
+      <!-- 전시장 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">전시장 <span class="admin--required">*</span></label>
+        <select v-model="formData.showroom_id" class="admin--form-select" required>
+          <option value="">전시장을 선택하세요</option>
+          <option v-for="showroom in showrooms" :key="showroom.id" :value="showroom.id">
+            {{ showroom.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 지점 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">지점 <span class="admin--required">*</span></label>
+        <select v-model="formData.branch_id" class="admin--form-select" required>
+          <option value="">지점을 선택하세요</option>
+          <option v-for="branch in branches" :key="branch.id" :value="branch.id">
+            {{ branch.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 영업팀 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">영업팀 <span class="admin--required">*</span></label>
+        <select v-model="formData.team_id" class="admin--form-select" required>
+          <option value="">영업팀을 선택하세요</option>
+          <option v-for="team in teams" :key="team.id" :value="team.id">
+            {{ team.name }}
+          </option>
+        </select>
+      </div>
+
+      <!-- 이름 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이름 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.name"
+          type="text"
+          class="admin--form-input"
+          placeholder="이름을 입력하세요"
+          required
+        >
+      </div>
+
+      <!-- 직책 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">직책 <span class="admin--required">*</span></label>
+        <select v-model="formData.position" class="admin--form-select" required>
+          <option value="">직책을 선택하세요</option>
+          <option value="팀장">팀장</option>
+          <option value="마스터">마스터</option>
+          <option value="차장">차장</option>
+          <option value="과장">과장</option>
+          <option value="대리">대리</option>
+          <option value="주임">주임</option>
+          <option value="사원">사원</option>
+        </select>
+      </div>
+
+      <!-- 대표번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">대표번호 <span class="admin--required">*</span></label>
+        <input
+          v-model="formData.main_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5678"
+          required
+        >
+      </div>
+
+      <!-- 직통번호 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">직통번호</label>
+        <input
+          v-model="formData.direct_phone"
+          type="tel"
+          class="admin--form-input"
+          placeholder="02-1234-5679"
+        >
+      </div>
+
+      <!-- 핸드폰 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">핸드폰</label>
+        <input
+          v-model="formData.mobile"
+          type="tel"
+          class="admin--form-input"
+          placeholder="010-1234-5678"
+        >
+      </div>
+
+      <!-- 이메일 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">이메일</label>
+        <input
+          v-model="formData.email"
+          type="email"
+          class="admin--form-input"
+          placeholder="email@example.com"
+        >
+      </div>
+
+      <!-- 사진 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">사진</label>
+        <input
+          type="file"
+          accept="image/*"
+          class="admin--form-file"
+          @change="handlePhotoUpload"
+        >
+        <div v-if="photoPreview || formData.photo_url" class="admin--image-preview">
+          <img :src="photoPreview || formData.photo_url" alt="미리보기">
+          <button type="button" class="admin--btn-remove-image" @click="removePhoto">
+            삭제
+          </button>
+        </div>
+      </div>
+
+      <!-- SACT -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">SACT</label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input v-model="formData.is_sact" type="radio" :value="true" name="is_sact">
+            <span>예</span>
+          </label>
+          <label class="admin--radio-label">
+            <input v-model="formData.is_sact" type="radio" :value="false" name="is_sact">
+            <span>아니오</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- TOP30 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">TOP30</label>
+        <div class="admin--radio-group">
+          <label class="admin--radio-label">
+            <input v-model="formData.is_top30" type="radio" :value="true" name="is_top30">
+            <span>예</span>
+          </label>
+          <label class="admin--radio-label">
+            <input v-model="formData.is_top30" type="radio" :value="false" name="is_top30">
+            <span>아니오</span>
+          </label>
+        </div>
+      </div>
+
+      <!-- 노출순서 -->
+      <div class="admin--form-group">
+        <label class="admin--form-label">노출순서</label>
+        <input
+          v-model.number="formData.display_order"
+          type="number"
+          class="admin--form-input"
+          placeholder="숫자만 입력"
+          min="0"
+        >
+        <p class="admin--form-help">숫자가 작을수록 먼저 노출됩니다.</p>
+      </div>
+
+      <!-- 버튼 영역 -->
+      <div class="admin--form-actions">
+        <button
+          type="submit"
+          class="admin--btn admin--btn-primary"
+          :disabled="isSaving"
+        >
+          {{ isSaving ? '저장 중...' : '확인' }}
+        </button>
+        <button
+          type="button"
+          class="admin--btn admin--btn-secondary"
+          @click="goToList"
+        >
+          목록
+        </button>
+      </div>
+
+      <!-- 성공/에러 메시지 -->
+      <div v-if="successMessage" class="admin--alert admin--alert-success">
+        {{ successMessage }}
+      </div>
+      <div v-if="errorMessage" class="admin--alert admin--alert-error">
+        {{ errorMessage }}
+      </div>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const route = useRoute()
+const router = useRouter()
+const { get, put, upload } = useApi()
+
+const isLoading = ref(true)
+const isSaving = ref(false)
+const successMessage = ref('')
+const errorMessage = ref('')
+const photoPreview = ref(null)
+const photoFile = ref(null)
+
+const showrooms = ref([])
+const branches = ref([])
+const teams = ref([])
+
+const formData = ref({
+  showroom_id: '',
+  branch_id: '',
+  team_id: '',
+  name: '',
+  position: '',
+  main_phone: '',
+  direct_phone: '',
+  mobile: '',
+  email: '',
+  photo_url: '',
+  is_sact: false,
+  is_top30: false,
+  display_order: 0
+})
+
+// 필터 데이터 로드
+const loadFilters = async () => {
+  const { data: showroomData } = await get('/staff/showrooms')
+  if (showroomData) showrooms.value = showroomData
+
+  const { data: branchData } = await get('/branch/list', { per_page: 1000 })
+  if (branchData) branches.value = branchData.items || []
+
+  const { data: teamData } = await get('/staff/teams')
+  if (teamData) teams.value = teamData
+}
+
+// 데이터 로드
+const loadSales = async () => {
+  isLoading.value = true
+
+  const id = route.params.id
+  const { data, error } = await get(`/staff/sales/${id}`)
+
+  if (data) {
+    formData.value = {
+      showroom_id: data.showroom_id || '',
+      branch_id: data.branch_id || '',
+      team_id: data.team_id || '',
+      name: data.name || '',
+      position: data.position || '',
+      main_phone: data.main_phone || '',
+      direct_phone: data.direct_phone || '',
+      mobile: data.mobile || '',
+      email: data.email || '',
+      photo_url: data.photo_url || '',
+      is_sact: data.is_sact || false,
+      is_top30: data.is_top30 || false,
+      display_order: data.display_order || 0
+    }
+  }
+
+  isLoading.value = false
+}
+
+// 사진 업로드
+const handlePhotoUpload = (event) => {
+  const file = event.target.files[0]
+  if (!file) return
+
+  if (!file.type.startsWith('image/')) {
+    alert('이미지 파일만 업로드 가능합니다.')
+    return
+  }
+
+  photoFile.value = file
+
+  const reader = new FileReader()
+  reader.onload = (e) => {
+    photoPreview.value = e.target.result
+  }
+  reader.readAsDataURL(file)
+}
+
+// 사진 삭제
+const removePhoto = () => {
+  photoPreview.value = null
+  photoFile.value = null
+  formData.value.photo_url = ''
+}
+
+// 폼 제출
+const handleSubmit = async () => {
+  successMessage.value = ''
+  errorMessage.value = ''
+
+  if (!formData.value.showroom_id || !formData.value.branch_id || !formData.value.team_id) {
+    errorMessage.value = '전시장, 지점, 영업팀을 선택하세요.'
+    return
+  }
+
+  if (!formData.value.name) {
+    errorMessage.value = '이름을 입력하세요.'
+    return
+  }
+
+  isSaving.value = true
+
+  try {
+    let photoUrl = formData.value.photo_url
+
+    // 새 사진 업로드
+    if (photoFile.value) {
+      const formDataImage = new FormData()
+      formDataImage.append('image', photoFile.value)
+
+      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+
+      if (uploadError) {
+        errorMessage.value = '사진 업로드에 실패했습니다.'
+        isSaving.value = false
+        return
+      }
+
+      photoUrl = uploadData.url
+    }
+
+    const submitData = {
+      ...formData.value,
+      photo_url: photoUrl
+    }
+
+    const id = route.params.id
+    const { data, error } = await put(`/staff/sales/${id}`, submitData)
+
+    if (error) {
+      errorMessage.value = error.message || '수정에 실패했습니다.'
+    } else {
+      successMessage.value = '영업사원 정보가 수정되었습니다.'
+      setTimeout(() => {
+        router.push('/admin/staff/sales')
+      }, 1000)
+    }
+  } catch (error) {
+    errorMessage.value = '서버 오류가 발생했습니다.'
+    console.error('Save error:', error)
+  } finally {
+    isSaving.value = false
+  }
+}
+
+const goToList = () => {
+  router.push('/admin/staff/sales')
+}
+
+onMounted(async () => {
+  await loadFilters()
+  await loadSales()
+})
+</script>

+ 329 - 0
app/pages/admin/staff/sales/index.vue

@@ -0,0 +1,329 @@
+<template>
+  <div class="admin--sales-list">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box admin--search-box-large">
+      <div class="admin--search-filters">
+        <div class="admin--filter-row">
+          <label class="admin--filter-label">전시장</label>
+          <select v-model="filters.showroom" class="admin--form-select">
+            <option value="">전체</option>
+            <option v-for="showroom in showrooms" :key="showroom.id" :value="showroom.id">
+              {{ showroom.name }}
+            </option>
+          </select>
+
+          <label class="admin--filter-label">지점</label>
+          <select v-model="filters.branch" class="admin--form-select">
+            <option value="">전체</option>
+            <option v-for="branch in branches" :key="branch.id" :value="branch.id">
+              {{ branch.name }}
+            </option>
+          </select>
+
+          <label class="admin--filter-label">팀</label>
+          <select v-model="filters.team" class="admin--form-select">
+            <option value="">전체</option>
+            <option v-for="team in teams" :key="team.id" :value="team.id">
+              {{ team.name }}
+            </option>
+          </select>
+        </div>
+
+        <div class="admin--filter-row">
+          <label class="admin--filter-label">검색어</label>
+          <input
+            v-model="filters.keyword"
+            type="text"
+            class="admin--form-input"
+            placeholder="이름으로 검색"
+            @keyup.enter="handleSearch"
+          >
+          <button class="admin--btn admin--btn-primary" @click="handleSearch">
+            검색
+          </button>
+          <button class="admin--btn admin--btn-secondary" @click="handleReset">
+            초기화
+          </button>
+        </div>
+      </div>
+
+      <div class="admin--search-actions">
+        <button class="admin--btn admin--btn-secondary" @click="handleExcelDownload">
+          엑셀 다운로드
+        </button>
+        <button class="admin--btn admin--btn-secondary" @click="handleA2Print">
+          A2 출력하기
+        </button>
+        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+          + 사원 등록
+        </button>
+      </div>
+    </div>
+
+    <!-- 테이블 -->
+    <div class="admin--table-wrapper">
+      <table class="admin--table admin--table-sales">
+        <thead>
+          <tr>
+            <th>NO</th>
+            <th>사진</th>
+            <th>전시장</th>
+            <th>지점</th>
+            <th>팀</th>
+            <th>이름</th>
+            <th>직책</th>
+            <th>대표번호</th>
+            <th>핸드폰</th>
+            <th>이메일</th>
+            <th>관리</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="isLoading">
+            <td colspan="11" class="admin--table-loading">
+              데이터를 불러오는 중...
+            </td>
+          </tr>
+          <tr v-else-if="!salesList || salesList.length === 0">
+            <td colspan="11" class="admin--table-empty">
+              등록된 영업사원이 없습니다.
+            </td>
+          </tr>
+          <tr v-else v-for="(sales, index) in salesList" :key="sales.id">
+            <td>{{ totalCount - ((currentPage - 1) * perPage + index) }}</td>
+            <td>
+              <div class="admin--table-photo">
+                <img v-if="sales.photo_url" :src="sales.photo_url" :alt="sales.name">
+                <div v-else class="admin--table-photo-empty">사진없음</div>
+              </div>
+            </td>
+            <td>{{ sales.showroom_name }}</td>
+            <td>{{ sales.branch_name }}</td>
+            <td>{{ sales.team_name }}</td>
+            <td class="admin--table-title">{{ sales.name }}</td>
+            <td>{{ sales.position }}</td>
+            <td>{{ sales.main_phone }}</td>
+            <td>{{ sales.mobile }}</td>
+            <td>{{ sales.email }}</td>
+            <td>
+              <div class="admin--table-actions admin--table-actions-col">
+                <button
+                  class="admin--btn-small admin--btn-small-primary"
+                  @click="goToEdit(sales.id)"
+                >
+                  수정
+                </button>
+                <button
+                  class="admin--btn-small admin--btn-small-danger"
+                  @click="handleDelete(sales.id)"
+                >
+                  삭제
+                </button>
+                <button
+                  class="admin--btn-small admin--btn-small-secondary"
+                  @click="handlePrint(sales.id)"
+                >
+                  프린트
+                </button>
+                <button
+                  class="admin--btn-small admin--btn-small-secondary"
+                  @click="handleExcelExport(sales.id)"
+                >
+                  엑셀출력
+                </button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <!-- 페이지네이션 -->
+    <div v-if="totalPages > 1" class="admin--pagination">
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === 1"
+        @click="changePage(currentPage - 1)"
+      >
+        이전
+      </button>
+      <button
+        v-for="page in visiblePages"
+        :key="page"
+        class="admin--pagination-btn"
+        :class="{ 'is-active': page === currentPage }"
+        @click="changePage(page)"
+      >
+        {{ page }}
+      </button>
+      <button
+        class="admin--pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="changePage(currentPage + 1)"
+      >
+        다음
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+definePageMeta({
+  layout: 'admin',
+  middleware: ['auth']
+})
+
+const router = useRouter()
+const { get, del } = useApi()
+
+const isLoading = ref(false)
+const salesList = ref([])
+const showrooms = ref([])
+const branches = ref([])
+const teams = ref([])
+
+const filters = ref({
+  showroom: '',
+  branch: '',
+  team: '',
+  keyword: ''
+})
+
+const currentPage = ref(1)
+const perPage = ref(10)
+const totalCount = ref(0)
+const totalPages = ref(0)
+
+// 보이는 페이지 번호 계산
+const visiblePages = computed(() => {
+  const pages = []
+  const maxVisible = 5
+  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
+  let end = Math.min(totalPages.value, start + maxVisible - 1)
+
+  if (end - start < maxVisible - 1) {
+    start = Math.max(1, end - maxVisible + 1)
+  }
+
+  for (let i = start; i <= end; i++) {
+    pages.push(i)
+  }
+
+  return pages
+})
+
+// 필터 데이터 로드
+const loadFilters = async () => {
+  // 전시장 리스트
+  const { data: showroomData } = await get('/staff/showrooms')
+  if (showroomData) showrooms.value = showroomData
+
+  // 지점 리스트
+  const { data: branchData } = await get('/branch/list', { per_page: 1000 })
+  if (branchData) branches.value = branchData.items || []
+
+  // 팀 리스트
+  const { data: teamData } = await get('/staff/teams')
+  if (teamData) teams.value = teamData
+}
+
+// 데이터 로드
+const loadSales = async () => {
+  isLoading.value = true
+
+  const params = {
+    page: currentPage.value,
+    per_page: perPage.value,
+    ...filters.value
+  }
+
+  const { data, error } = await get('/staff/sales', params)
+
+  if (data) {
+    salesList.value = data.items || []
+    totalCount.value = data.total || 0
+    totalPages.value = Math.ceil(totalCount.value / perPage.value)
+  }
+
+  isLoading.value = false
+}
+
+// 검색
+const handleSearch = () => {
+  currentPage.value = 1
+  loadSales()
+}
+
+// 초기화
+const handleReset = () => {
+  filters.value = {
+    showroom: '',
+    branch: '',
+    team: '',
+    keyword: ''
+  }
+  currentPage.value = 1
+  loadSales()
+}
+
+// 페이지 변경
+const changePage = (page) => {
+  if (page < 1 || page > totalPages.value) return
+  currentPage.value = page
+  loadSales()
+}
+
+// 엑셀 다운로드 (전체)
+const handleExcelDownload = async () => {
+  const params = { ...filters.value }
+  window.open(`/api/staff/sales/excel?${new URLSearchParams(params)}`, '_blank')
+}
+
+// A2 출력
+const handleA2Print = () => {
+  const params = { ...filters.value }
+  window.open(`/api/staff/sales/print-a2?${new URLSearchParams(params)}`, '_blank')
+}
+
+// 개별 프린트
+const handlePrint = (id) => {
+  window.open(`/api/staff/sales/${id}/print`, '_blank')
+}
+
+// 개별 엑셀 출력
+const handleExcelExport = (id) => {
+  window.open(`/api/staff/sales/${id}/excel`, '_blank')
+}
+
+// 등록 페이지로 이동
+const goToCreate = () => {
+  router.push('/admin/staff/sales/create')
+}
+
+// 수정 페이지로 이동
+const goToEdit = (id) => {
+  router.push(`/admin/staff/sales/edit/${id}`)
+}
+
+// 삭제
+const handleDelete = async (id) => {
+  if (!confirm('정말 삭제하시겠습니까?')) return
+
+  const { error } = await del(`/staff/sales/${id}`)
+
+  if (error) {
+    alert('삭제에 실패했습니다.')
+  } else {
+    alert('삭제되었습니다.')
+    loadSales()
+  }
+}
+
+onMounted(() => {
+  loadFilters()
+  loadSales()
+})
+</script>

+ 308 - 0
package-lock.json

@@ -10,8 +10,11 @@
         "@nuxt/image": "^1.11.0",
         "@nuxt/kit": "^3.15.0",
         "@nuxt/ui": "^4.1.0",
+        "axios": "^1.13.2",
         "eslint": "^9.38.0",
         "nuxt": "^4.1.3",
+        "suneditor": "^2.47.8",
+        "suneditor-react": "^3.6.1",
         "swiper": "^12.0.3",
         "vue": "^3.5.22",
         "vue-router": "^4.6.3"
@@ -3902,6 +3905,12 @@
       "version": "3.1.1",
       "license": "MIT"
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
     "node_modules/autoprefixer": {
       "version": "10.4.21",
       "funding": [
@@ -3937,6 +3946,17 @@
         "postcss": "^8.1.0"
       }
     },
+    "node_modules/axios": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+      "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.4",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
     "node_modules/b4a": {
       "version": "1.7.3",
       "license": "Apache-2.0",
@@ -4301,6 +4321,19 @@
         "node": ">=8"
       }
     },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/callsites": {
       "version": "3.1.0",
       "license": "MIT",
@@ -4548,6 +4581,18 @@
       "version": "5.0.0",
       "license": "Apache-2.0"
     },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/commander": {
       "version": "7.2.0",
       "license": "MIT",
@@ -4986,6 +5031,15 @@
       "version": "6.1.4",
       "license": "MIT"
     },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/denque": {
       "version": "2.1.0",
       "license": "Apache-2.0",
@@ -5096,6 +5150,20 @@
         "url": "https://dotenvx.com"
       }
     },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/duplexer": {
       "version": "0.1.2",
       "license": "MIT"
@@ -5233,10 +5301,55 @@
       "version": "0.1.0",
       "license": "MIT"
     },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/es-module-lexer": {
       "version": "1.7.0",
       "license": "MIT"
     },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.25.11",
       "hasInstallScript": true,
@@ -6434,6 +6547,26 @@
       "version": "3.3.3",
       "license": "ISC"
     },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/fontaine": {
       "version": "0.6.0",
       "license": "MIT",
@@ -6477,6 +6610,43 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/form-data": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+      "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/form-data/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/form-data/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/fraction.js": {
       "version": "4.3.7",
       "license": "MIT",
@@ -6564,10 +6734,47 @@
         "node": "6.* || 8.* || >= 10.*"
       }
     },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/get-port-please": {
       "version": "3.2.0",
       "license": "MIT"
     },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/get-stream": {
       "version": "8.0.1",
       "license": "MIT",
@@ -6702,6 +6909,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/graceful-fs": {
       "version": "4.2.11",
       "license": "ISC"
@@ -6745,6 +6964,33 @@
         "node": ">=8"
       }
     },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/hasown": {
       "version": "2.0.2",
       "license": "MIT",
@@ -7747,6 +7993,15 @@
         "source-map-js": "^1.2.0"
       }
     },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/mdn-data": {
       "version": "2.12.2",
       "license": "CC0-1.0"
@@ -10039,6 +10294,12 @@
       "version": "2.0.2",
       "license": "MIT"
     },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
     "node_modules/pump": {
       "version": "3.0.3",
       "license": "MIT",
@@ -10140,6 +10401,29 @@
         "destr": "^2.0.3"
       }
     },
+    "node_modules/react": {
+      "version": "19.2.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
+      "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
+      "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.0"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "4.7.0",
       "license": "MIT",
@@ -11175,6 +11459,13 @@
       "version": "1.4.1",
       "license": "ISC"
     },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT",
+      "peer": true
+    },
     "node_modules/scslre": {
       "version": "0.3.0",
       "license": "MIT",
@@ -11670,6 +11961,23 @@
         "node": ">=4"
       }
     },
+    "node_modules/suneditor": {
+      "version": "2.47.8",
+      "resolved": "https://registry.npmjs.org/suneditor/-/suneditor-2.47.8.tgz",
+      "integrity": "sha512-GGzaN1m+rFIR/zHdRxSH5JvzzJIF4UoaX2bWmcvk6NIcq2qgvdkR0iO+yFyjIL6zTVMeBI/GWkJMvOh8Qe5ehw==",
+      "license": "MIT"
+    },
+    "node_modules/suneditor-react": {
+      "version": "3.6.1",
+      "resolved": "https://registry.npmjs.org/suneditor-react/-/suneditor-react-3.6.1.tgz",
+      "integrity": "sha512-12f9KLnEB6pAdyHJINTzRBg3UOWVZZ+jVYSEtwdBTDYQW4amUZr0xOnpikbBAlxb9rcTYV5RHAsad3gnNhLsuA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": ">= 16.8.0",
+        "react-dom": ">= 16.8.0",
+        "suneditor": "^2.44.10"
+      }
+    },
     "node_modules/superjson": {
       "version": "2.2.3",
       "license": "MIT",

+ 3 - 0
package.json

@@ -13,8 +13,11 @@
     "@nuxt/image": "^1.11.0",
     "@nuxt/kit": "^3.15.0",
     "@nuxt/ui": "^4.1.0",
+    "axios": "^1.13.2",
     "eslint": "^9.38.0",
     "nuxt": "^4.1.3",
+    "suneditor": "^2.47.8",
+    "suneditor-react": "^3.6.1",
     "swiper": "^12.0.3",
     "vue": "^3.5.22",
     "vue-router": "^4.6.3"