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

Merge remote-tracking branch 'origin/master'

DESKTOP-T61HUSC\user пре 1 месец
родитељ
комит
16271ff423

+ 1 - 0
app/app.vue

@@ -1,5 +1,6 @@
 <template>
   <UApp>
+    <AdminLoadingOverlay />
     <Header v-if="!isAdminPage" />
     <NuxtLayout>
       <NuxtPage />

+ 97 - 6
app/assets/scss/admin.scss

@@ -4401,6 +4401,36 @@ footer{
   font-size: 16px;
 }
 
+// 전역 로딩 오버레이
+.admin--loading-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99999;
+}
+
+// 스피너
+.admin--spinner {
+  width: 50px;
+  height: 50px;
+  border: 4px solid rgba(255, 255, 255, 0.3);
+  border-top-color: #fff;
+  border-radius: 50%;
+  animation: admin-spin 0.8s linear infinite;
+}
+
+@keyframes admin-spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
 // 검색 박스
 .admin--search-box {
   background: var(--admin-bg-secondary);
@@ -4419,21 +4449,70 @@ footer{
     flex: 1;
   }
 
-  .admin--search-select {
+  // 검색 박스 내 모든 select, input 통일된 스타일
+  .admin--search-select,
+  .admin--form-select,
+  .admin--form-input,
+  .admin--search-input {
+    border: 1px solid var(--admin-border-color) !important;
+    border-radius: 4px;
+    height: 33px !important;
+    padding: 6px 14px !important;
+    font-size: 13px !important;
+    background: var(--admin-bg-tertiary) !important;
+    line-height: normal;
+  }
+
+  .admin--search-select,
+  .admin--form-select {
     width: 140px;
   }
 
+  .admin--form-input,
   .admin--search-input {
     flex: 1;
     max-width: 400px;
   }
+}
 
-  .admin--search-actions {
-    display: flex;
-    gap: 12px;
+// 날짜 입력 필드
+.admin--date-input {
+  cursor: pointer;
+
+  &::-webkit-calendar-picker-indicator {
+    cursor: pointer;
+    opacity: 0.7;
+    transition: opacity 0.2s;
+
+    &:hover {
+      opacity: 1;
+    }
+  }
+}
+
+// 날짜 범위
+.admin--date-range {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+
+  .admin--date-separator {
+    color: var(--admin-text-secondary);
+    font-size: 14px;
+  }
+
+  .admin--form-input {
+    flex: 1;
+    max-width: 200px;
   }
 }
 
+// 검색 input 강제 스타일 (두 클래스 함께 사용될 때)
+.admin--form-input.admin--search-input {
+  border: 1px solid var(--admin-border-color) !important;
+  background: var(--admin-bg-tertiary) !important;
+}
+
 // 테이블
 .admin--table-wrapper {
   background: var(--admin-bg-secondary);
@@ -4803,8 +4882,8 @@ footer{
 
 // 테이블 사진
 .admin--table-photo {
-  width: 50px;
-  height: 50px;
+  width: 80px;
+  height: 120px;
   border-radius: 4px;
   overflow: hidden;
   display: flex;
@@ -4848,6 +4927,18 @@ footer{
   }
 }
 
+// 엑셀 버튼 (녹색 배경)
+.admin--btn-small-excel {
+  background: #217346;
+  color: #ffffff;
+  border: 1px solid #1a5c37;
+
+  &:hover {
+    background: #185c37;
+    border-color: #144d2d;
+  }
+}
+
 // Responsive
 @media (max-width: 1024px) {
   .admin--sidebar {

+ 25 - 0
app/components/admin/AdminLoadingOverlay.vue

@@ -0,0 +1,25 @@
+<template>
+  <Transition name="loading-fade">
+    <div v-if="isGlobalLoading" class="admin--loading-overlay">
+      <div class="admin--spinner"></div>
+    </div>
+  </Transition>
+</template>
+
+<script setup>
+import { useLoading } from '~/composables/useLoading'
+
+const { isGlobalLoading } = useLoading()
+</script>
+
+<style scoped>
+.loading-fade-enter-active,
+.loading-fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+
+.loading-fade-enter-from,
+.loading-fade-leave-to {
+  opacity: 0;
+}
+</style>

+ 256 - 75
app/components/admin/AdminModal.vue

@@ -6,92 +6,100 @@
         <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>
+      <form @submit.prevent="save" class="admin--modal-form">
+        <div class="admin--modal-body">
+          <div class="admin--form-row">
+            <div class="admin--form-group">
+              <label class="admin--form-label">아이디 *</label>
+              <input
+                v-model="formData.username"
+                type="text"
+                class="admin--form-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 class="admin--form-group">
+              <label class="admin--form-label">이름 *</label>
+              <input
+                v-model="formData.name"
+                type="text"
+                class="admin--form-input"
+                required
+                placeholder="관리자 이름"
+              />
+            </div>
           </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-row">
+            <div class="admin--form-group">
+              <label class="admin--form-label">이메일 *</label>
+              <input
+                v-model="formData.email"
+                type="email"
+                class="admin--form-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 class="admin--form-group">
+              <label class="admin--form-label">역할 *</label>
+              <select v-model="formData.role" class="admin--form-select" required>
+                <option value="admin">일반 관리자</option>
+                <option value="super_admin">슈퍼 관리자</option>
+              </select>
+            </div>
           </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-row" v-if="!isEdit">
+            <div class="admin--form-group">
+              <label class="admin--form-label">비밀번호 *</label>
+              <input
+                v-model="formData.password"
+                type="password"
+                class="admin--form-input"
+                :required="!isEdit"
+                placeholder="최소 8자 이상"
+                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 class="admin--form-group">
+              <label class="admin--form-label">비밀번호 확인 *</label>
+              <input
+                v-model="formData.password_confirm"
+                type="password"
+                class="admin--form-input"
+                :required="!isEdit"
+                placeholder="비밀번호 재입력"
+                minlength="8"
+              />
+            </div>
           </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 class="admin--form-row">
+            <div class="admin--form-group">
+              <label class="admin--form-label">상태 *</label>
+              <select v-model="formData.status" class="admin--form-select" required>
+                <option value="active">활성</option>
+                <option value="inactive">비활성</option>
+              </select>
+            </div>
           </div>
+        </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 class="admin--modal-footer">
+          <button type="button" @click="close" class="admin--btn-small admin--btn-small-secondary">
+            취소
+          </button>
+          <button type="submit" class="admin--btn-small admin--btn-small-primary" :disabled="saving">
+            {{ saving ? '저장 중...' : '저장' }}
+          </button>
+        </div>
+      </form>
     </div>
   </div>
 </template>
@@ -199,3 +207,176 @@ const save = async () => {
   }
 }
 </script>
+
+<style scoped>
+.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--modal {
+  background: #2d2d2d;
+  padding: 0;
+  border-radius: 8px;
+  min-width: 700px;
+  max-width: 800px;
+  width: 90%;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.admin--modal-form {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-height: 0;
+}
+
+.admin--modal-header {
+  padding: 20px;
+  border-bottom: 1px solid #404040;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.admin--modal-header h4 {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.admin--modal-close {
+  background: none;
+  border: none;
+  font-size: 28px;
+  cursor: pointer;
+  color: #999;
+  line-height: 1;
+  transition: color 0.2s;
+  padding: 0;
+  width: 30px;
+  height: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.admin--modal-close:hover {
+  color: #fff;
+}
+
+.admin--modal-body {
+  padding: 24px;
+  overflow-y: auto;
+  flex: 1;
+  min-height: 0;
+}
+
+.admin--form-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+.admin--form-row:last-child {
+  margin-bottom: 0;
+}
+
+.admin--form-group {
+  display: flex;
+  flex-direction: column;
+}
+
+.admin--form-label {
+  display: block;
+  margin-bottom: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  color: #e0e0e0;
+}
+
+.admin--form-input,
+.admin--form-select {
+  width: 100%;
+  padding: 10px 14px;
+  border: 1px solid #404040;
+  border-radius: 4px;
+  background: #1a1a1a;
+  color: #ffffff;
+  font-size: 14px;
+  transition: border-color 0.2s;
+}
+
+.admin--form-input:focus,
+.admin--form-select:focus {
+  outline: none;
+  border-color: var(--admin-accent-primary, #217346);
+}
+
+.admin--form-input:disabled {
+  background: #252525;
+  color: #666;
+  cursor: not-allowed;
+}
+
+.admin--form-input::placeholder {
+  color: #666;
+}
+
+.admin--modal-footer {
+  padding: 20px 24px;
+  border-top: 1px solid #404040;
+  display: flex;
+  gap: 10px;
+  justify-content: flex-end;
+  flex-shrink: 0;
+}
+
+.admin--btn-small {
+  padding: 10px 20px;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  transition: all 0.2s;
+  border: none;
+  font-weight: 500;
+}
+
+.admin--btn-small:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.admin--btn-small-secondary {
+  background: #252525;
+  color: #e0e0e0;
+  border: 1px solid #404040;
+}
+
+.admin--btn-small-secondary:hover:not(:disabled) {
+  background: #2d2d2d;
+}
+
+.admin--btn-small-primary {
+  background: var(--admin-accent-primary, #217346);
+  color: white;
+}
+
+.admin--btn-small-primary:hover:not(:disabled) {
+  background: var(--admin-accent-hover, #1a5c37);
+}
+</style>

+ 47 - 2
app/components/admin/SunEditor.vue

@@ -30,6 +30,9 @@ const emit = defineEmits(['update:modelValue'])
 const editorElement = ref(null)
 let editorInstance = null
 
+const { upload } = useApi()
+const { getImageUrl } = useImage()
+
 onMounted(() => {
   if (editorElement.value) {
     editorInstance = suneditor.create(editorElement.value, {
@@ -56,10 +59,52 @@ onMounted(() => {
       imageResizing: true,
       imageHeightShow: true,
       imageWidth: '100%',
-      imageUploadUrl: '/api/upload/image',
-      placeholder: props.placeholder
+      placeholder: props.placeholder,
+      callBackSave: function (contents) {
+        emit('update:modelValue', contents)
+      }
     })
 
+    // 커스텀 이미지 업로드 핸들러
+    editorInstance.onImageUploadBefore = function (files, info, core) {
+      console.log('[SunEditor] onImageUploadBefore 호출, files:', files, 'info:', info)
+
+      // 비동기 파일 업로드 처리
+      ;(async () => {
+        for (const file of files) {
+          const formData = new FormData()
+          formData.append('file', file)
+
+          try {
+            const { data: uploadData, error } = await upload('/upload/event-file', formData)
+
+            console.log('[SunEditor] 업로드 응답:', uploadData)
+
+            if (uploadData?.success && uploadData?.data?.url) {
+              // 전체 URL 생성
+              const fullUrl = getImageUrl(uploadData.data.url)
+
+              console.log('[SunEditor] 원본 URL:', uploadData.data.url)
+              console.log('[SunEditor] 전체 URL:', fullUrl)
+
+              // 에디터에 직접 이미지 태그 삽입
+              const imgTag = `<img src="${fullUrl}" alt="${file.name}" />`
+              editorInstance.insertHTML(imgTag)
+
+              console.log('[SunEditor] 이미지 삽입 완료:', imgTag)
+            } else {
+              console.error('[SunEditor] 이미지 업로드 응답 오류:', uploadData, error)
+            }
+          } catch (error) {
+            console.error('[SunEditor] 이미지 업로드 실패:', error)
+          }
+        }
+      })()
+
+      // 기본 동작 막기
+      return false
+    }
+
     // 초기값 설정
     if (props.modelValue) {
       editorInstance.setContents(props.modelValue)

+ 30 - 2
app/composables/useApi.js

@@ -1,4 +1,5 @@
 import axios from 'axios'
+import { useLoading } from './useLoading'
 
 // API Base URL (환경변수 또는 기본값)
 const API_BASE_URL = process.env.NUXT_PUBLIC_API_BASE || 'https://gojinaudi.mycafe24.com/api'
@@ -12,9 +13,19 @@ const apiClient = axios.create({
   }
 })
 
-// Request Interceptor (토큰 자동 추가)
+// 로딩 카운터 (동시에 여러 요청이 있을 수 있으므로)
+let pendingRequests = 0
+const { showLoading, hideLoading } = useLoading()
+
+// Request Interceptor (토큰 자동 추가 + 로딩 시작)
 apiClient.interceptors.request.use(
   (config) => {
+    // 로딩 카운터 증가
+    pendingRequests++
+    if (pendingRequests === 1) {
+      showLoading()
+    }
+
     if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') {
       const token = localStorage.getItem('admin_token')
       if (token) {
@@ -27,17 +38,34 @@ apiClient.interceptors.request.use(
     return config
   },
   (error) => {
+    // 에러 발생시에도 카운터 감소
+    pendingRequests--
+    if (pendingRequests === 0) {
+      hideLoading()
+    }
     return Promise.reject(error)
   }
 )
 
-// Response Interceptor (에러 처리)
+// Response Interceptor (에러 처리 + 로딩 종료)
 apiClient.interceptors.response.use(
   (response) => {
+    // 로딩 카운터 감소
+    pendingRequests--
+    if (pendingRequests === 0) {
+      hideLoading()
+    }
+
     console.log('[useApi] Response:', response.config.url, response.status)
     return response
   },
   (error) => {
+    // 에러 시에도 카운터 감소
+    pendingRequests--
+    if (pendingRequests === 0) {
+      hideLoading()
+    }
+
     const status = error.response?.status
     const url = error.config?.url
 

+ 38 - 0
app/composables/useLoading.js

@@ -0,0 +1,38 @@
+import { ref } from 'vue'
+
+const isGlobalLoading = ref(false)
+let loadingStartTime = null
+
+export const useLoading = () => {
+  const showLoading = () => {
+    isGlobalLoading.value = true
+    loadingStartTime = Date.now()
+  }
+
+  const hideLoading = () => {
+    if (!loadingStartTime) {
+      isGlobalLoading.value = false
+      return
+    }
+
+    // 최소 300ms 동안 로딩을 표시
+    const elapsedTime = Date.now() - loadingStartTime
+    const minLoadingTime = 300
+
+    if (elapsedTime < minLoadingTime) {
+      setTimeout(() => {
+        isGlobalLoading.value = false
+        loadingStartTime = null
+      }, minLoadingTime - elapsedTime)
+    } else {
+      isGlobalLoading.value = false
+      loadingStartTime = null
+    }
+  }
+
+  return {
+    isGlobalLoading,
+    showLoading,
+    hideLoading
+  }
+}

+ 138 - 140
app/layouts/admin.vue

@@ -9,9 +9,7 @@
         </div>
       </div>
       <div class="admin--header-right">
-        <button class="admin--header-btn" @click="goToProfile">
-          정보수정
-        </button>
+        <button class="admin--header-btn" @click="goToProfile">정보수정</button>
         <button
           type="button"
           class="admin--header-btn admin--header-btn-logout"
@@ -27,17 +25,13 @@
       <!-- 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)"
-            >
+          <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
+                class="admin--gnb-arrow"
+                :class="{ 'is-open': openMenus.includes(menu.id) }"
+              >
               </span>
             </div>
@@ -70,7 +64,11 @@
                 {{ 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
+                v-if="index < breadcrumbs.length - 1"
+                class="admin--breadcrumb-separator"
+                >/</span
+              >
             </span>
           </div>
         </div>
@@ -82,7 +80,9 @@
 
         <!-- Admin Footer -->
         <footer class="admin--footer">
-          <p>&copy; {{ new Date().getFullYear() }} Audi 고진모터스. All rights reserved.</p>
+          <p>
+            &copy; {{ new Date().getFullYear() }} Audi 고진모터스. All rights reserved.
+          </p>
         </footer>
       </main>
     </div>
@@ -101,130 +101,128 @@
 </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
+  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 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')
-}
+    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>

+ 43 - 19
app/pages/admin/admins/index.vue

@@ -2,36 +2,37 @@
   <div class="admin--admins">
     <div class="admin--page-header">
       <h3>관리자 관리</h3>
-      <button @click="openCreateModal" class="admin--btn-primary">
-        <span>+</span> 관리자 추가
+      <button @click="openCreateModal" class="admin--btn-small admin--btn-small-primary">
+        + 관리자 추가
       </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">
+    <!-- 검색 영역 -->
+    <div class="admin--search-box">
+      <div class="admin--search-form">
+        <select v-model="filterRole" @change="loadAdmins" class="admin--form-select admin--search-select">
           <option value="">전체 역할</option>
           <option value="super_admin">슈퍼 관리자</option>
           <option value="admin">일반 관리자</option>
         </select>
-
-        <select v-model="filterStatus" @change="loadAdmins" class="admin--select">
+        <select v-model="filterStatus" @change="loadAdmins" class="admin--form-select admin--search-select">
           <option value="">전체 상태</option>
           <option value="active">활성</option>
           <option value="inactive">비활성</option>
         </select>
+        <input
+          v-model="searchQuery"
+          type="text"
+          placeholder="아이디, 이름으로 검색"
+          @keyup.enter="loadAdmins"
+          class="admin--form-input admin--search-input"
+        />
+        <button @click="loadAdmins" class="admin--btn-small admin--btn-small-primary">
+          검색
+        </button>
+        <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">
+          초기화
+        </button>
       </div>
     </div>
 
@@ -255,6 +256,8 @@ const loadAdmins = async () => {
       params.status = filterStatus.value
     }
 
+    console.log('[Admins] 검색 파라미터:', params)
+
     const { data, error } = await get('/admin', { params })
 
     if (error) {
@@ -280,6 +283,15 @@ const changePage = (page) => {
   loadAdmins()
 }
 
+// 검색 초기화
+const resetSearch = () => {
+  searchQuery.value = ''
+  filterRole.value = ''
+  filterStatus.value = ''
+  currentPage.value = 1
+  loadAdmins()
+}
+
 // 모달 열기/닫기
 const openCreateModal = () => {
   selectedAdmin.value = null
@@ -393,3 +405,15 @@ onMounted(() => {
   loadAdmins()
 })
 </script>
+
+<style scoped>
+.admin--search-box .admin--form-input,
+.admin--search-box .admin--search-input,
+.admin--search-box .admin--form-select,
+.admin--search-box .admin--search-select {
+  border: 1px solid var(--admin-border-color) !important;
+  height: 33px !important;
+  padding: 6px 14px !important;
+  font-size: 13px !important;
+}
+</style>

+ 4 - 15
app/pages/admin/basic/popup/index.vue

@@ -14,15 +14,15 @@
           placeholder="검색어를 입력하세요"
           @keyup.enter="handleSearch"
         >
-        <button class="admin--btn admin--btn-primary" @click="handleSearch">
+        <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">
           검색
         </button>
-        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+        <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
           초기화
         </button>
       </div>
       <div class="admin--search-actions">
-        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+        <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">
           + 팝업 등록
         </button>
       </div>
@@ -43,12 +43,7 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-if="isLoading">
-            <td colspan="7" class="admin--table-loading">
-              데이터를 불러오는 중...
-            </td>
-          </tr>
-          <tr v-else-if="!popups || popups.length === 0">
+          <tr v-if="!popups || popups.length === 0">
             <td colspan="7" class="admin--table-empty">
               등록된 팝업이 없습니다.
             </td>
@@ -129,8 +124,6 @@ definePageMeta({
 
 const router = useRouter()
 const { get, del } = useApi()
-
-const isLoading = ref(false)
 const popups = ref([])
 const searchType = ref('title')
 const searchKeyword = ref('')
@@ -159,8 +152,6 @@ const visiblePages = computed(() => {
 
 // 데이터 로드
 const loadPopups = async () => {
-  isLoading.value = true
-
   const params = {
     page: currentPage.value,
     per_page: perPage.value
@@ -188,8 +179,6 @@ const loadPopups = async () => {
   } else {
     console.log('[Popup List] 데이터 없음 또는 에러')
   }
-
-  isLoading.value = false
 }
 
 // 검색

+ 25 - 10
app/pages/admin/board/event/create.vue

@@ -61,19 +61,19 @@
       <div class="admin--form-group">
         <label class="admin--form-label">기간 <span class="admin--required">*</span></label>
         <div class="admin--date-range">
-          <input
+          <DatePicker
             v-model="formData.start_date"
-            type="date"
-            class="admin--form-input"
+            placeholder="시작일 선택"
+            :max-date="formData.end_date"
             required
-          >
+          />
           <span class="admin--date-separator">~</span>
-          <input
+          <DatePicker
             v-model="formData.end_date"
-            type="date"
-            class="admin--form-input"
+            placeholder="종료일 선택"
+            :min-date="formData.start_date"
             required
-          >
+          />
         </div>
       </div>
 
@@ -152,6 +152,7 @@
 import { ref } from 'vue'
 import { useRouter } from 'vue-router'
 import SunEditor from '~/components/admin/SunEditor.vue'
+import DatePicker from '~/components/admin/DatePicker.vue'
 
 definePageMeta({
   layout: 'admin',
@@ -237,7 +238,7 @@ const handleSubmit = async () => {
         const formDataFile = new FormData()
         formDataFile.append('file', file)
 
-        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+        const { data: uploadData, error: uploadError } = await upload('/upload/event-file', formDataFile)
 
         if (uploadError) {
           errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
@@ -245,16 +246,30 @@ const handleSubmit = async () => {
           return
         }
 
+        if (!uploadData?.success || !uploadData?.data?.url) {
+          errorMessage.value = '파일 업로드 응답이 올바르지 않습니다.'
+          isSaving.value = false
+          return
+        }
+
         fileUrls.push({
           name: file.name,
-          url: uploadData.url,
+          url: uploadData.data.url,
           size: file.size
         })
       }
     }
 
+    // content에서 도메인 제거
+    let contentToSave = formData.value.content
+    if (contentToSave) {
+      // http://도메인 또는 https://도메인 제거
+      contentToSave = contentToSave.replace(/https?:\/\/[^\/]+/g, '')
+    }
+
     const submitData = {
       ...formData.value,
+      content: contentToSave,
       file_urls: fileUrls
     }
 

+ 27 - 12
app/pages/admin/board/event/edit/[id].vue

@@ -65,19 +65,19 @@
       <div class="admin--form-group">
         <label class="admin--form-label">기간 <span class="admin--required">*</span></label>
         <div class="admin--date-range">
-          <input
+          <DatePicker
             v-model="formData.start_date"
-            type="date"
-            class="admin--form-input"
+            placeholder="시작일 선택"
+            :max-date="formData.end_date"
             required
-          >
+          />
           <span class="admin--date-separator">~</span>
-          <input
+          <DatePicker
             v-model="formData.end_date"
-            type="date"
-            class="admin--form-input"
+            placeholder="종료일 선택"
+            :min-date="formData.start_date"
             required
-          >
+          />
         </div>
       </div>
 
@@ -170,6 +170,7 @@
 import { ref, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import SunEditor from '~/components/admin/SunEditor.vue'
+import DatePicker from '~/components/admin/DatePicker.vue'
 
 definePageMeta({
   layout: 'admin',
@@ -212,8 +213,8 @@ const loadEvent = async () => {
     const event = data.data
     formData.value = {
       category: event.category || '',
-      allow_comment: event.allow_comment || false,
-      is_notice: event.is_notice || false,
+      allow_comment: Boolean(event.allow_comment),
+      is_notice: Boolean(event.is_notice),
       name: event.name || '',
       email: event.email || '',
       start_date: event.start_date || '',
@@ -290,7 +291,7 @@ const handleSubmit = async () => {
         const formDataFile = new FormData()
         formDataFile.append('file', file)
 
-        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+        const { data: uploadData, error: uploadError } = await upload('/upload/event-file', formDataFile)
 
         if (uploadError) {
           errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
@@ -298,16 +299,30 @@ const handleSubmit = async () => {
           return
         }
 
+        if (!uploadData?.success || !uploadData?.data?.url) {
+          errorMessage.value = '파일 업로드 응답이 올바르지 않습니다.'
+          isSaving.value = false
+          return
+        }
+
         fileUrls.push({
           name: file.name,
-          url: uploadData.url,
+          url: uploadData.data.url,
           size: file.size
         })
       }
     }
 
+    // content에서 도메인 제거
+    let contentToSave = formData.value.content
+    if (contentToSave) {
+      // http://도메인 또는 https://도메인 제거
+      contentToSave = contentToSave.replace(/https?:\/\/[^\/]+/g, '')
+    }
+
     const submitData = {
       ...formData.value,
+      content: contentToSave,
       file_urls: fileUrls
     }
 

+ 18 - 11
app/pages/admin/board/event/index.vue

@@ -15,13 +15,13 @@
           placeholder="검색어를 입력하세요"
           @keyup.enter="handleSearch"
         />
-        <button class="admin--btn admin--btn-primary" @click="handleSearch">검색</button>
-        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+        <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">검색</button>
+        <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
           초기화
         </button>
       </div>
       <div class="admin--search-actions">
-        <button class="admin--btn admin--btn-primary" @click="goToCreate">+ 등록</button>
+        <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">+ 등록</button>
       </div>
     </div>
 
@@ -39,10 +39,7 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-if="isLoading">
-            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
-          </tr>
-          <tr v-else-if="!posts || posts.length === 0">
+          <tr v-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">
@@ -115,8 +112,6 @@
 
   const router = useRouter();
   const { get, del } = useApi();
-
-  const isLoading = ref(false);
   const posts = ref([]);
   const searchType = ref("title");
   const searchKeyword = ref("");
@@ -136,7 +131,6 @@
   });
 
   const loadPosts = async () => {
-    isLoading.value = true;
     const params = { page: currentPage.value, per_page: perPage.value };
     if (searchKeyword.value) {
       params.search_type = searchType.value;
@@ -153,7 +147,6 @@
       totalPages.value = Math.ceil(totalCount.value / perPage.value);
       console.log("[EventBoard] 로드 성공:", posts.value.length);
     }
-    isLoading.value = false;
   };
 
   const handleSearch = () => {
@@ -193,3 +186,17 @@
 
   onMounted(() => loadPosts());
 </script>
+
+<style scoped>
+  /* 검색 영역 input/select 스타일 통일 */
+  .admin--search-box .admin--form-input,
+  .admin--search-box .admin--search-input,
+  .admin--search-box .admin--form-select,
+  .admin--search-box .admin--search-select {
+    border: 1px solid var(--admin-border-color) !important;
+    border-radius: 4px;
+    height: 33px !important;
+    padding: 6px 14px !important;
+    font-size: 13px !important;
+  }
+</style>

+ 18 - 11
app/pages/admin/board/ir/index.vue

@@ -15,13 +15,13 @@
           placeholder="검색어를 입력하세요"
           @keyup.enter="handleSearch"
         />
-        <button class="admin--btn admin--btn-primary" @click="handleSearch">검색</button>
-        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+        <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">검색</button>
+        <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
           초기화
         </button>
       </div>
       <div class="admin--search-actions">
-        <button class="admin--btn admin--btn-primary" @click="goToCreate">+ 등록</button>
+        <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">+ 등록</button>
       </div>
     </div>
 
@@ -39,10 +39,7 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-if="isLoading">
-            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
-          </tr>
-          <tr v-else-if="!posts || posts.length === 0">
+          <tr v-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">
@@ -115,8 +112,6 @@
 
   const router = useRouter();
   const { get, del } = useApi();
-
-  const isLoading = ref(false);
   const posts = ref([]);
   const searchType = ref("title");
   const searchKeyword = ref("");
@@ -136,7 +131,6 @@
   });
 
   const loadPosts = async () => {
-    isLoading.value = true;
     const params = { page: currentPage.value, per_page: perPage.value };
     if (searchKeyword.value) {
       params.search_type = searchType.value;
@@ -153,7 +147,6 @@
       totalPages.value = Math.ceil(totalCount.value / perPage.value);
       console.log("[IRBoard] 로드 성공:", posts.value.length);
     }
-    isLoading.value = false;
   };
 
   const handleSearch = () => {
@@ -193,3 +186,17 @@
 
   onMounted(() => loadPosts());
 </script>
+
+<style scoped>
+  /* 검색 영역 input/select 스타일 통일 */
+  .admin--search-box .admin--form-input,
+  .admin--search-box .admin--search-input,
+  .admin--search-box .admin--form-select,
+  .admin--search-box .admin--search-select {
+    border: 1px solid var(--admin-border-color) !important;
+    border-radius: 4px;
+    height: 33px !important;
+    padding: 6px 14px !important;
+    font-size: 13px !important;
+  }
+</style>

+ 10 - 2
app/pages/admin/board/news/create.vue

@@ -206,7 +206,7 @@ const handleSubmit = async () => {
         const formDataFile = new FormData()
         formDataFile.append('file', file)
 
-        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+        const { data: uploadData, error: uploadError } = await upload('/upload/news-file', formDataFile)
 
         if (uploadError) {
           errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
@@ -216,14 +216,22 @@ const handleSubmit = async () => {
 
         fileUrls.push({
           name: file.name,
-          url: uploadData.url,
+          url: uploadData.data.url,
           size: file.size
         })
       }
     }
 
+    // content에서 도메인 제거
+    let contentToSave = formData.value.content
+    if (contentToSave) {
+      // http://도메인 또는 https://도메인 제거
+      contentToSave = contentToSave.replace(/https?:\/\/[^\/]+/g, '')
+    }
+
     const submitData = {
       ...formData.value,
+      content: contentToSave,
       file_urls: fileUrls
     }
 

+ 12 - 4
app/pages/admin/board/news/edit/[id].vue

@@ -190,8 +190,8 @@ const loadNews = async () => {
   if (data?.success && data?.data) {
     const news = data.data
     formData.value = {
-      allow_comment: news.allow_comment || false,
-      is_notice: news.is_notice || false,
+      allow_comment: Boolean(news.allow_comment),
+      is_notice: Boolean(news.is_notice),
       name: news.name || '',
       email: news.email || '',
       url: news.url || '',
@@ -257,7 +257,7 @@ const handleSubmit = async () => {
         const formDataFile = new FormData()
         formDataFile.append('file', file)
 
-        const { data: uploadData, error: uploadError } = await upload('/upload/file', formDataFile)
+        const { data: uploadData, error: uploadError } = await upload('/upload/news-file', formDataFile)
 
         if (uploadError) {
           errorMessage.value = `파일 업로드에 실패했습니다: ${file.name}`
@@ -267,14 +267,22 @@ const handleSubmit = async () => {
 
         fileUrls.push({
           name: file.name,
-          url: uploadData.url,
+          url: uploadData.data.url,
           size: file.size
         })
       }
     }
 
+    // content에서 도메인 제거
+    let contentToSave = formData.value.content
+    if (contentToSave) {
+      // http://도메인 또는 https://도메인 제거
+      contentToSave = contentToSave.replace(/https?:\/\/[^\/]+/g, '')
+    }
+
     const submitData = {
       ...formData.value,
+      content: contentToSave,
       file_urls: fileUrls
     }
 

+ 18 - 11
app/pages/admin/board/news/index.vue

@@ -15,13 +15,13 @@
           placeholder="검색어를 입력하세요"
           @keyup.enter="handleSearch"
         />
-        <button class="admin--btn admin--btn-primary" @click="handleSearch">검색</button>
-        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+        <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">검색</button>
+        <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
           초기화
         </button>
       </div>
       <div class="admin--search-actions">
-        <button class="admin--btn admin--btn-primary" @click="goToCreate">+ 등록</button>
+        <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">+ 등록</button>
       </div>
     </div>
 
@@ -39,10 +39,7 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-if="isLoading">
-            <td colspan="6" class="admin--table-loading">데이터를 불러오는 중...</td>
-          </tr>
-          <tr v-else-if="!posts || posts.length === 0">
+          <tr v-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">
@@ -115,8 +112,6 @@
 
   const router = useRouter();
   const { get, del } = useApi();
-
-  const isLoading = ref(false);
   const posts = ref([]);
   const searchType = ref("title");
   const searchKeyword = ref("");
@@ -136,7 +131,6 @@
   });
 
   const loadPosts = async () => {
-    isLoading.value = true;
     const params = { page: currentPage.value, per_page: perPage.value };
     if (searchKeyword.value) {
       params.search_type = searchType.value;
@@ -153,7 +147,6 @@
       totalPages.value = Math.ceil(totalCount.value / perPage.value);
       console.log("[NewsBoard] 로드 성공:", posts.value.length);
     }
-    isLoading.value = false;
   };
 
   const handleSearch = () => {
@@ -193,3 +186,17 @@
 
   onMounted(() => loadPosts());
 </script>
+
+<style scoped>
+  /* 검색 영역 input/select 스타일 통일 */
+  .admin--search-box .admin--form-input,
+  .admin--search-box .admin--search-input,
+  .admin--search-box .admin--form-select,
+  .admin--search-box .admin--search-select {
+    border: 1px solid var(--admin-border-color) !important;
+    border-radius: 4px;
+    height: 33px !important;
+    padding: 6px 14px !important;
+    font-size: 13px !important;
+  }
+</style>

+ 4 - 15
app/pages/admin/branch/manager/index.vue

@@ -16,15 +16,15 @@
           placeholder="검색어를 입력하세요"
           @keyup.enter="handleSearch"
         >
-        <button class="admin--btn admin--btn-primary" @click="handleSearch">
+        <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">
           검색
         </button>
-        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+        <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
           초기화
         </button>
       </div>
       <div class="admin--search-actions">
-        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+        <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">
           + 지점장 등록
         </button>
       </div>
@@ -44,12 +44,7 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-if="isLoading">
-            <td colspan="6" class="admin--table-loading">
-              데이터를 불러오는 중...
-            </td>
-          </tr>
-          <tr v-else-if="!managers || managers.length === 0">
+          <tr v-if="!managers || managers.length === 0">
             <td colspan="6" class="admin--table-empty">
               등록된 지점장이 없습니다.
             </td>
@@ -121,8 +116,6 @@ definePageMeta({
 
 const router = useRouter()
 const { get, del } = useApi()
-
-const isLoading = ref(false)
 const managers = ref([])
 const searchType = ref('branch_name')
 const searchKeyword = ref('')
@@ -151,8 +144,6 @@ const visiblePages = computed(() => {
 
 // 데이터 로드
 const loadManagers = async () => {
-  isLoading.value = true
-
   const params = {
     page: currentPage.value,
     per_page: perPage.value
@@ -174,8 +165,6 @@ const loadManagers = async () => {
     totalPages.value = Math.ceil(totalCount.value / perPage.value)
     console.log('[BranchManager] 로드 성공:', managers.value.length)
   }
-
-  isLoading.value = false
 }
 
 // 검색

+ 20 - 34
app/pages/admin/staff/advisor/create.vue

@@ -4,10 +4,10 @@
       <!-- 서비스센터 -->
       <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>
+        <select v-model="formData.service_center" class="admin--form-select" required>
           <option value="">서비스센터를 선택하세요</option>
-          <option v-for="center in serviceCenters" :key="center.id" :value="center.id">
-            {{ center.name }}
+          <option v-for="center in serviceCenters" :key="center.code" :value="center.code">
+            {{ center.code }} : {{ center.name }}
           </option>
         </select>
       </div>
@@ -47,17 +47,6 @@
         >
       </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>
@@ -121,26 +110,21 @@ const successMessage = ref('')
 const errorMessage = ref('')
 const photoPreview = ref(null)
 const photoFile = ref(null)
-const serviceCenters = ref([])
 
 const formData = ref({
-  service_center_id: '',
+  service_center: '',
   name: '',
   main_phone: '',
   direct_phone: '',
-  mobile: '',
   photo_url: ''
 })
 
-const loadServiceCenters = async () => {
-  const { data, error } = await get('/staff/service-centers')
-  console.log('[AdvisorCreate] API 응답:', { data, error })
-
-  if (data?.success && data?.data) {
-    serviceCenters.value = data.data
-    console.log('[AdvisorCreate] 서비스센터 로드 성공')
-  }
-}
+const serviceCenters = ref([
+  { code: 'CA', name: '성수서비스센터' },
+  { code: 'CB', name: '수원서비스센터' },
+  { code: 'CC', name: '대전서비스센터' },
+  { code: 'CD', name: '광주서비스센터' }
+])
 
 const handlePhotoUpload = (event) => {
   const file = event.target.files[0]
@@ -170,7 +154,7 @@ const handleSubmit = async () => {
   successMessage.value = ''
   errorMessage.value = ''
 
-  if (!formData.value.service_center_id) {
+  if (!formData.value.service_center) {
     errorMessage.value = '서비스센터를 선택하세요.'
     return
   }
@@ -187,9 +171,9 @@ const handleSubmit = async () => {
 
     if (photoFile.value) {
       const formDataImage = new FormData()
-      formDataImage.append('image', photoFile.value)
+      formDataImage.append('file', photoFile.value)
 
-      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+      const { data: uploadData, error: uploadError } = await upload('/upload/advisor-image', formDataImage)
 
       if (uploadError) {
         errorMessage.value = '사진 업로드에 실패했습니다.'
@@ -197,7 +181,13 @@ const handleSubmit = async () => {
         return
       }
 
-      photoUrl = uploadData.url
+      if (!uploadData?.success || !uploadData?.data?.url) {
+        errorMessage.value = '사진 업로드 응답이 올바르지 않습니다.'
+        isSaving.value = false
+        return
+      }
+
+      photoUrl = uploadData.data.url
     }
 
     const submitData = {
@@ -226,8 +216,4 @@ const handleSubmit = async () => {
 const goToList = () => {
   router.push('/admin/staff/advisor')
 }
-
-onMounted(() => {
-  loadServiceCenters()
-})
 </script>

+ 52 - 31
app/pages/admin/staff/advisor/edit/[id].vue

@@ -4,10 +4,10 @@
       <!-- 서비스센터 -->
       <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>
+        <select v-model="formData.service_center" class="admin--form-select" required>
           <option value="">서비스센터를 선택하세요</option>
-          <option v-for="center in serviceCenters" :key="center.id" :value="center.id">
-            {{ center.name }}
+          <option v-for="center in serviceCenters" :key="center.code" :value="center.code">
+            {{ center.code }} : {{ center.name }}
           </option>
         </select>
       </div>
@@ -47,17 +47,6 @@
         >
       </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>
@@ -114,32 +103,57 @@ definePageMeta({
 })
 
 const router = useRouter()
-const { get, post, upload } = useApi()
+const route = useRoute()
+const { get, put, upload } = useApi()
+const { getImageUrl } = useImage()
 
 const isSaving = ref(false)
+const isLoading = 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: '',
+  service_center: '',
   name: '',
   main_phone: '',
   direct_phone: '',
-  mobile: '',
   photo_url: ''
 })
 
-const loadServiceCenters = async () => {
-  const { data, error } = await get('/staff/service-centers')
-  console.log('[AdvisorEdit] API 응답:', { data, error })
+const serviceCenters = ref([
+  { code: 'CA', name: '성수서비스센터' },
+  { code: 'CB', name: '수원서비스센터' },
+  { code: 'CC', name: '대전서비스센터' },
+  { code: 'CD', name: '광주서비스센터' }
+])
+
+const loadAdvisor = async () => {
+  isLoading.value = true
+
+  const id = route.params.id
+  const { data, error } = await get(`/staff/advisor/${id}`)
+  console.log('[AdvisorEdit] 데이터 로드:', { data, error })
 
   if (data?.success && data?.data) {
-    serviceCenters.value = data.data
-    console.log('[AdvisorEdit] 서비스센터 로드 성공')
+    const advisor = data.data
+    formData.value = {
+      service_center: advisor.service_center || '',
+      name: advisor.name || '',
+      main_phone: advisor.main_phone || '',
+      direct_phone: advisor.direct_phone || '',
+      photo_url: advisor.photo_url || ''
+    }
+
+    if (advisor.photo_url) {
+      photoPreview.value = getImageUrl(advisor.photo_url)
+    }
+
+    console.log('[AdvisorEdit] 로드 성공')
   }
+
+  isLoading.value = false
 }
 
 const handlePhotoUpload = (event) => {
@@ -170,7 +184,7 @@ const handleSubmit = async () => {
   successMessage.value = ''
   errorMessage.value = ''
 
-  if (!formData.value.service_center_id) {
+  if (!formData.value.service_center) {
     errorMessage.value = '서비스센터를 선택하세요.'
     return
   }
@@ -187,9 +201,9 @@ const handleSubmit = async () => {
 
     if (photoFile.value) {
       const formDataImage = new FormData()
-      formDataImage.append('image', photoFile.value)
+      formDataImage.append('file', photoFile.value)
 
-      const { data: uploadData, error: uploadError } = await upload('/upload/image', formDataImage)
+      const { data: uploadData, error: uploadError } = await upload('/upload/advisor-image', formDataImage)
 
       if (uploadError) {
         errorMessage.value = '사진 업로드에 실패했습니다.'
@@ -197,7 +211,13 @@ const handleSubmit = async () => {
         return
       }
 
-      photoUrl = uploadData.url
+      if (!uploadData?.success || !uploadData?.data?.url) {
+        errorMessage.value = '사진 업로드 응답이 올바르지 않습니다.'
+        isSaving.value = false
+        return
+      }
+
+      photoUrl = uploadData.data.url
     }
 
     const submitData = {
@@ -205,12 +225,13 @@ const handleSubmit = async () => {
       photo_url: photoUrl
     }
 
-    const { data, error } = await post('/staff/advisor', submitData)
+    const id = route.params.id
+    const { data, error } = await put(`/staff/advisor/${id}`, submitData)
 
     if (error) {
-      errorMessage.value = error.message || '등록에 실패했습니다.'
+      errorMessage.value = error.message || '수정에 실패했습니다.'
     } else {
-      successMessage.value = '어드바이저가 등록되었습니다.'
+      successMessage.value = '어드바이저가 수정되었습니다.'
       setTimeout(() => {
         router.push('/admin/staff/advisor')
       }, 1000)
@@ -228,6 +249,6 @@ const goToList = () => {
 }
 
 onMounted(() => {
-  loadServiceCenters()
+  loadAdvisor()
 })
 </script>

+ 23 - 15
app/pages/admin/staff/advisor/index.vue

@@ -15,13 +15,15 @@
           placeholder="검색어를 입력하세요"
           @keyup.enter="handleSearch"
         />
-        <button class="admin--btn admin--btn-primary" @click="handleSearch">검색</button>
-        <button class="admin--btn admin--btn-secondary" @click="handleReset">
+        <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">
+          검색
+        </button>
+        <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
           초기화
         </button>
       </div>
       <div class="admin--search-actions">
-        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+        <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">
           + 어드바이저 등록
         </button>
       </div>
@@ -42,10 +44,7 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-if="isLoading">
-            <td colspan="7" class="admin--table-loading">데이터를 불러오는 중...</td>
-          </tr>
-          <tr v-else-if="!advisors || advisors.length === 0">
+          <tr v-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">
@@ -54,7 +53,7 @@
               <div class="admin--table-photo">
                 <img
                   v-if="advisor.photo_url"
-                  :src="advisor.photo_url"
+                  :src="getImageUrl(advisor.photo_url)"
                   :alt="advisor.name"
                 />
                 <div v-else class="admin--table-photo-empty">사진없음</div>
@@ -63,7 +62,7 @@
             <td>{{ advisor.service_center_name }}</td>
             <td class="admin--table-title">{{ advisor.name }}</td>
             <td>{{ advisor.main_phone }}</td>
-            <td>{{ advisor.mobile }}</td>
+            <td>{{ advisor.direct_phone }}</td>
             <td>
               <div class="admin--table-actions">
                 <button
@@ -125,8 +124,7 @@
 
   const router = useRouter();
   const { get, del } = useApi();
-
-  const isLoading = ref(false);
+  const { getImageUrl } = useImage();
   const advisors = ref([]);
   const searchType = ref("all");
   const searchKeyword = ref("");
@@ -153,8 +151,6 @@
   });
 
   const loadAdvisors = async () => {
-    isLoading.value = true;
-
     const params = {
       page: currentPage.value,
       per_page: perPage.value,
@@ -176,8 +172,6 @@
       totalPages.value = Math.ceil(totalCount.value / perPage.value);
       console.log("[Advisor] 로드 성공:", advisors.value.length);
     }
-
-    isLoading.value = false;
   };
 
   const handleSearch = () => {
@@ -223,3 +217,17 @@
     loadAdvisors();
   });
 </script>
+
+<style scoped>
+  /* 검색 영역 input/select 스타일 통일 */
+  .admin--search-box .admin--form-input,
+  .admin--search-box .admin--search-input,
+  .admin--search-box .admin--form-select,
+  .admin--search-box .admin--search-select {
+    border: 1px solid var(--admin-border-color) !important;
+    border-radius: 4px;
+    height: 33px !important;
+    padding: 6px 14px !important;
+    font-size: 13px !important;
+  }
+</style>

+ 27 - 8
app/pages/admin/staff/sales/create.vue

@@ -4,7 +4,7 @@
       <!-- 전시장 -->
       <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>
+        <select v-model.number="formData.showroom" class="admin--form-select" required>
           <option value="">전시장을 선택하세요</option>
           <option v-for="showroom in showrooms" :key="showroom.id" :value="showroom.id">
             {{ showroom.name }}
@@ -15,7 +15,7 @@
       <!-- 영업팀 -->
       <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>
+        <select v-model.number="formData.team" class="admin--form-select" required>
           <option value="">영업팀을 선택하세요</option>
           <option v-for="team in teams" :key="team.id" :value="team.id">
             {{ team.name }}
@@ -219,8 +219,8 @@ const teams = ref([
 ])
 
 const formData = ref({
-  showroom_id: '',
-  team_id: '',
+  showroom: '',
+  team: '',
   name: '',
   position: '',
   main_phone: '',
@@ -275,8 +275,13 @@ const handleSubmit = async () => {
   successMessage.value = ''
   errorMessage.value = ''
 
-  if (!formData.value.showroom_id || !formData.value.team_id) {
-    errorMessage.value = '전시장, 영업팀을 선택하세요.'
+  if (formData.value.showroom === '' || formData.value.showroom === null) {
+    errorMessage.value = '전시장을 선택하세요.'
+    return
+  }
+
+  if (formData.value.team === '' || formData.value.team === null) {
+    errorMessage.value = '영업팀을 선택하세요.'
     return
   }
 
@@ -285,6 +290,11 @@ const handleSubmit = async () => {
     return
   }
 
+  if (formData.value.position === '' || formData.value.position === null) {
+    errorMessage.value = '직책을 선택하세요.'
+    return
+  }
+
   isSaving.value = true
 
   try {
@@ -297,13 +307,22 @@ const handleSubmit = async () => {
 
       const { data: uploadData, error: uploadError } = await upload('/upload/staff-image', formDataImage)
 
+      console.log('[SalesCreate] 이미지 업로드 응답:', { data: uploadData, error: uploadError })
+
       if (uploadError) {
-        errorMessage.value = '사진 업로드에 실패했습니다.'
+        errorMessage.value = '사진 업로드에 실패했습니다: ' + (uploadError.message || uploadError)
+        isSaving.value = false
+        return
+      }
+
+      if (!uploadData?.success || !uploadData?.data?.url) {
+        errorMessage.value = '사진 업로드 응답이 올바르지 않습니다.'
         isSaving.value = false
         return
       }
 
-      photoUrl = uploadData.data?.url || uploadData.url
+      photoUrl = uploadData.data.url
+      console.log('[SalesCreate] 업로드된 이미지 URL:', photoUrl)
     }
 
     const submitData = {

+ 30 - 10
app/pages/admin/staff/sales/edit/[id].vue

@@ -8,7 +8,7 @@
       <!-- 전시장 -->
       <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>
+        <select v-model.number="formData.showroom" class="admin--form-select" required>
           <option value="">전시장을 선택하세요</option>
           <option v-for="showroom in showrooms" :key="showroom.id" :value="showroom.id">
             {{ showroom.name }}
@@ -19,7 +19,7 @@
       <!-- 영업팀 -->
       <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>
+        <select v-model.number="formData.team" class="admin--form-select" required>
           <option value="">영업팀을 선택하세요</option>
           <option v-for="team in teams" :key="team.id" :value="team.id">
             {{ team.name }}
@@ -226,8 +226,8 @@ const teams = ref([
 ])
 
 const formData = ref({
-  showroom_id: '',
-  team_id: '',
+  showroom: '',
+  team: '',
   name: '',
   position: '',
   main_phone: '',
@@ -262,8 +262,8 @@ const loadSales = async () => {
   if (data?.success && data?.data) {
     const sales = data.data
     formData.value = {
-      showroom_id: sales.showroom_id || '',
-      team_id: sales.team_id || '',
+      showroom: sales.showroom || '',
+      team: sales.team || '',
       name: sales.name || '',
       position: sales.position || '',
       main_phone: sales.main_phone || '',
@@ -275,6 +275,7 @@ const loadSales = async () => {
       is_top30: sales.is_top30 || false,
       display_order: sales.display_order || 0
     }
+    photoPreview.value = sales.photo_url || null
     console.log('[SalesEdit] 로드 성공')
   }
 
@@ -312,8 +313,13 @@ const handleSubmit = async () => {
   successMessage.value = ''
   errorMessage.value = ''
 
-  if (!formData.value.showroom_id || !formData.value.team_id) {
-    errorMessage.value = '전시장, 영업팀을 선택하세요.'
+  if (formData.value.showroom === '' || formData.value.showroom === null) {
+    errorMessage.value = '전시장을 선택하세요.'
+    return
+  }
+
+  if (formData.value.team === '' || formData.value.team === null) {
+    errorMessage.value = '영업팀을 선택하세요.'
     return
   }
 
@@ -322,6 +328,11 @@ const handleSubmit = async () => {
     return
   }
 
+  if (formData.value.position === '' || formData.value.position === null) {
+    errorMessage.value = '직책을 선택하세요.'
+    return
+  }
+
   isSaving.value = true
 
   try {
@@ -334,13 +345,22 @@ const handleSubmit = async () => {
 
       const { data: uploadData, error: uploadError } = await upload('/upload/staff-image', formDataImage)
 
+      console.log('[SalesEdit] 이미지 업로드 응답:', { data: uploadData, error: uploadError })
+
       if (uploadError) {
-        errorMessage.value = '사진 업로드에 실패했습니다.'
+        errorMessage.value = '사진 업로드에 실패했습니다: ' + (uploadError.message || uploadError)
+        isSaving.value = false
+        return
+      }
+
+      if (!uploadData?.success || !uploadData?.data?.url) {
+        errorMessage.value = '사진 업로드 응답이 올바르지 않습니다.'
         isSaving.value = false
         return
       }
 
-      photoUrl = uploadData.data?.url || uploadData.url
+      photoUrl = uploadData.data.url
+      console.log('[SalesEdit] 업로드된 이미지 URL:', photoUrl)
     }
 
     const submitData = {

+ 352 - 27
app/pages/admin/staff/sales/index.vue

@@ -19,6 +19,17 @@
               {{ team.name }}
             </option>
           </select>
+
+          <label class="admin--filter-label">직책</label>
+          <select v-model="filters.position" class="admin--form-select">
+            <option value="">전체</option>
+            <option value="10">팀장</option>
+            <option value="15">마스터</option>
+            <option value="20">차장</option>
+            <option value="30">과장</option>
+            <option value="40">대리</option>
+            <option value="60">사원</option>
+          </select>
         </div>
 
         <div class="admin--filter-row">
@@ -30,23 +41,23 @@
             placeholder="이름으로 검색"
             @keyup.enter="handleSearch"
           />
-          <button class="admin--btn admin--btn-primary" @click="handleSearch">
+          <button class="admin--btn-small admin--btn-small-primary" @click="handleSearch">
             검색
           </button>
-          <button class="admin--btn admin--btn-secondary" @click="handleReset">
+          <button class="admin--btn-small admin--btn-small-secondary" @click="handleReset">
             초기화
           </button>
         </div>
       </div>
 
       <div class="admin--search-actions">
-        <button class="admin--btn admin--btn-secondary" @click="handleExcelDownload">
+        <button class="admin--btn-small admin--btn-small-excel" @click="handleExcelDownload">
           엑셀 다운로드
         </button>
-        <button class="admin--btn admin--btn-secondary" @click="handleA2Print">
+        <button class="admin--btn-small admin--btn-small-secondary" @click="handleA2Print">
           A2 출력하기
         </button>
-        <button class="admin--btn admin--btn-primary" @click="goToCreate">
+        <button class="admin--btn-small admin--btn-small-primary" @click="goToCreate">
           + 사원 등록
         </button>
       </div>
@@ -70,10 +81,7 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-if="isLoading">
-            <td colspan="10" class="admin--table-loading">데이터를 불러오는 중...</td>
-          </tr>
-          <tr v-else-if="!salesList || salesList.length === 0">
+          <tr v-if="!salesList || salesList.length === 0">
             <td colspan="10" class="admin--table-empty">등록된 영업사원이 없습니다.</td>
           </tr>
           <tr v-else v-for="(sales, index) in salesList" :key="sales.id">
@@ -84,12 +92,12 @@
                 <div v-else class="admin--table-photo-empty">사진없음</div>
               </div>
             </td>
-            <td>{{ sales.showroom_name }}</td>
-            <td>{{ sales.team_name }}</td>
+            <td>{{ getShowroomName(sales.showroom) }}</td>
+            <td>{{ getTeamName(sales.team) }}</td>
             <td class="admin--table-title">{{ sales.name }}</td>
-            <td>{{ sales.position }}</td>
+            <td>{{ getPositionName(sales.position) }}</td>
             <td>{{ sales.main_phone }}</td>
-            <td>{{ sales.mobile }}</td>
+            <td>{{ sales.direct_phone }}</td>
             <td>{{ sales.email }}</td>
             <td>
               <div class="admin--table-actions admin--table-actions-col">
@@ -112,7 +120,7 @@
                   프린트
                 </button>
                 <button
-                  class="admin--btn-small admin--btn-small-secondary"
+                  class="admin--btn-small admin--btn-small-excel"
                   @click="handleExcelExport(sales.id)"
                 >
                   엑셀출력
@@ -165,8 +173,6 @@
   const router = useRouter();
   const { get, del } = useApi();
   const { getImageUrl } = useImage();
-
-  const isLoading = ref(false);
   const salesList = ref([]);
   const showrooms = ref([]);
 
@@ -188,6 +194,7 @@
   const filters = ref({
     showroom: "",
     team: "",
+    position: "",
     keyword: "",
   });
 
@@ -229,8 +236,6 @@
 
   // 데이터 로드
   const loadSales = async () => {
-    isLoading.value = true;
-
     const params = {
       page: currentPage.value,
       per_page: perPage.value,
@@ -246,8 +251,6 @@
       totalPages.value = Math.ceil(totalCount.value / perPage.value);
       console.log("[SalesList] 영업사원 목록 로드 성공");
     }
-
-    isLoading.value = false;
   };
 
   // 검색
@@ -261,12 +264,40 @@
     filters.value = {
       showroom: "",
       team: "",
+      position: "",
       keyword: "",
     };
     currentPage.value = 1;
     loadSales();
   };
 
+  // 전시장 이름 가져오기
+  const getShowroomName = (showroomId) => {
+    const showroom = showrooms.value.find(s => s.id === showroomId);
+    return showroom ? showroom.name : '-';
+  };
+
+  // 팀 이름 가져오기
+  const getTeamName = (teamId) => {
+    if (teamId === 0 || teamId === '0') {
+      return '마스터팀';
+    }
+    return teamId ? `${teamId}팀` : '-';
+  };
+
+  // 직책 이름 가져오기
+  const getPositionName = (positionCode) => {
+    const positions = {
+      10: '팀장',
+      15: '마스터',
+      20: '차장',
+      30: '과장',
+      40: '대리',
+      60: '사원'
+    };
+    return positions[positionCode] || '-';
+  };
+
   // 페이지 변경
   const changePage = (page) => {
     if (page < 1 || page > totalPages.value) return;
@@ -276,24 +307,290 @@
 
   // 엑셀 다운로드 (전체)
   const handleExcelDownload = async () => {
-    const params = { ...filters.value };
-    window.open(`/api/staff/sales/excel?${new URLSearchParams(params)}`, "_blank");
+    // 전체 데이터 가져오기 (페이지네이션 없이)
+    const params = {
+      ...filters.value,
+      per_page: 10000 // 충분히 큰 값으로 전체 가져오기
+    };
+
+    const { data } = await get("/staff/sales", params);
+    if (!data?.success || !data?.data) {
+      alert('데이터를 가져올 수 없습니다.');
+      return;
+    }
+
+    const allStaff = data.data.items || [];
+
+    if (allStaff.length === 0) {
+      alert('다운로드할 데이터가 없습니다.');
+      return;
+    }
+
+    // HTML 테이블 생성
+    let tableRows = '';
+    allStaff.forEach((staff, index) => {
+      const showroomName = getShowroomName(staff.showroom);
+      const teamName = getTeamName(staff.team);
+      const positionName = getPositionName(staff.position);
+      const photoUrl = staff.photo_url ? getImageUrl(staff.photo_url) : '';
+
+      tableRows += `
+        <tr style="height: 110px;">
+          <td style="text-align: center; vertical-align: middle;">${index + 1}</td>
+          <td style="text-align: center; padding: 5px; vertical-align: middle;">
+            ${photoUrl ? `<img src="${photoUrl}" height="100" style="display: block; margin: 0 auto; max-width: 100%;" />` : '사진없음'}
+          </td>
+          <td style="vertical-align: middle;">${showroomName}</td>
+          <td style="vertical-align: middle;">${teamName}</td>
+          <td style="vertical-align: middle;">${staff.name}</td>
+          <td style="vertical-align: middle;">${positionName}</td>
+          <td style="vertical-align: middle;">${staff.main_phone || ''}</td>
+          <td style="vertical-align: middle;">${staff.direct_phone || ''}</td>
+          <td style="vertical-align: middle;">${staff.email || ''}</td>
+        </tr>
+      `;
+    });
+
+    const html = `
+<html xmlns:x="urn:schemas-microsoft-com:office:excel">
+<head>
+    <meta charset="UTF-8">
+    <style>
+        body {
+            font-family: "Malgun Gothic", "맑은 고딕", Arial, sans-serif;
+        }
+        table {
+            border-collapse: collapse;
+            width: 100%;
+        }
+        th, td {
+            border: 1px solid #ddd;
+            padding: 10px;
+            text-align: left;
+        }
+        th {
+            background-color: #f5f5f5;
+            font-weight: bold;
+            text-align: center;
+        }
+        .title {
+            font-size: 18pt;
+            font-weight: bold;
+            margin-bottom: 20px;
+            text-align: center;
+        }
+    </style>
+</head>
+<body>
+    <div class="title">영업사원 목록</div>
+    <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>
+            ${tableRows}
+        </tbody>
+    </table>
+</body>
+</html>`;
+
+    // Blob 생성 및 다운로드
+    const blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel;charset=utf-8;' });
+    const link = document.createElement('a');
+    const url = URL.createObjectURL(blob);
+    link.setAttribute('href', url);
+    link.setAttribute('download', `sales_staff_list_${new Date().toISOString().split('T')[0]}.xls`);
+    link.style.visibility = 'hidden';
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
   };
 
   // A2 출력
   const handleA2Print = () => {
-    const params = { ...filters.value };
-    window.open(`/api/staff/sales/print-a2?${new URLSearchParams(params)}`, "_blank");
+    window.open('/admin/staff/sales/print-a2', '_blank');
   };
 
   // 개별 프린트
   const handlePrint = (id) => {
-    window.open(`/api/staff/sales/${id}/print`, "_blank");
+    window.open(`/admin/staff/sales/print/${id}`, "_blank");
   };
 
   // 개별 엑셀 출력
-  const handleExcelExport = (id) => {
-    window.open(`/api/staff/sales/${id}/excel`, "_blank");
+  const handleExcelExport = async (id) => {
+    // 해당 사원 데이터 가져오기
+    const { data } = await get(`/staff/sales/${id}`);
+    if (!data?.success || !data?.data) {
+      alert('데이터를 가져올 수 없습니다.');
+      return;
+    }
+
+    const staff = data.data;
+
+    // 전시장 이름
+    const showroomName = getShowroomName(staff.showroom);
+    // 팀 이름
+    const teamName = getTeamName(staff.team);
+    // 직책 이름
+    const positionName = getPositionName(staff.position);
+
+    // 사진 URL
+    const photoUrl = staff.photo_url ? getImageUrl(staff.photo_url) : '';
+
+    // HTML 생성 (엑셀 호환 테이블 레이아웃)
+    const html = `
+<html xmlns:x="urn:schemas-microsoft-com:office:excel">
+<head>
+    <meta charset="UTF-8">
+    <style>
+        body {
+            font-family: "Malgun Gothic", "맑은 고딕", Arial, sans-serif;
+        }
+        table {
+            border-collapse: collapse;
+            width: 100%;
+        }
+        .header-table {
+            width: 100%;
+            margin-bottom: 40px;
+            border-bottom: 2px solid #000;
+            padding-bottom: 10px;
+        }
+        .header-table td {
+            border: none;
+            padding: 10px;
+        }
+        .header-title {
+            font-size: 20pt;
+            font-weight: bold;
+            text-align: left;
+        }
+        .header-logo {
+            text-align: right;
+            font-size: 10pt;
+            color: #666;
+        }
+        .content-table {
+            width: 100%;
+            border: 1px solid #ddd;
+            margin-top: 20px;
+        }
+        .content-table td {
+            border: 1px solid #ddd;
+            vertical-align: top;
+            padding: 10px;
+        }
+        .info-wrapper {
+            padding-top: 0px;
+        }
+        .photo-cell {
+            width: 200px;
+            text-align: center;
+            vertical-align: top;
+        }
+        .photo-cell img {
+            width: 200px;
+            height: auto;
+            display: block;
+        }
+        .photo-placeholder {
+            width: 200px;
+            height: 300px;
+            border: 1px solid #ddd;
+            background-color: #f9f9f9;
+            display: inline-block;
+            line-height: 300px;
+            text-align: center;
+            color: #999;
+        }
+        .info-table {
+            width: 100%;
+            border-collapse: collapse;
+            border: none;
+        }
+        .info-table tr {
+            border: none;
+        }
+        .info-table td {
+            padding: 10px;
+            border: 1px solid #ddd;
+        }
+        .info-label {
+            width: 100px;
+            font-weight: bold;
+            color: #333;
+            font-size: 13pt;
+            background-color: #f5f5f5;
+        }
+        .info-value {
+            color: #555;
+            font-size: 13pt;
+        }
+        .info-value-name {
+            color: #000;
+            font-size: 16pt;
+            font-weight: bold;
+        }
+    </style>
+</head>
+<body>
+    <table class="content-table" style="table-layout: fixed;">
+        <colgroup>
+            <col style="width: 200px;">
+            <col style="width: 100px;">
+            <col style="width: auto;">
+        </colgroup>
+        <tr>
+            <td class="photo-cell" rowspan="6" style="padding: 0;">
+                ${photoUrl ? `<img src="${photoUrl}" width="200" style="display: block;" />` : '<div class="photo-placeholder">사진없음</div>'}
+            </td>
+            <td class="info-label" style="height: 44.5px;">전시장</td>
+            <td class="info-value">${showroomName}</td>
+        </tr>
+        <tr>
+            <td class="info-label" style="height: 44.5px;">팀</td>
+            <td class="info-value">${teamName}</td>
+        </tr>
+        <tr>
+            <td class="info-label" style="height: 44.5px;">이름</td>
+            <td class="info-value-name">${staff.name}</td>
+        </tr>
+        <tr>
+            <td class="info-label" style="height: 44.5px;">직책</td>
+            <td class="info-value">${positionName}</td>
+        </tr>
+        <tr>
+            <td class="info-label" style="height: 44.5px;">핸드폰</td>
+            <td class="info-value">${staff.direct_phone || ''}</td>
+        </tr>
+        <tr>
+            <td class="info-label" style="height: 44.5px;">이메일</td>
+            <td class="info-value">${staff.email || ''}</td>
+        </tr>
+    </table>
+</body>
+</html>`;
+
+    // Blob 생성 및 다운로드
+    const blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel;charset=utf-8;' });
+    const link = document.createElement('a');
+    const url = URL.createObjectURL(blob);
+    link.setAttribute('href', url);
+    link.setAttribute('download', `sales_staff_${staff.name}_${new Date().toISOString().split('T')[0]}.xls`);
+    link.style.visibility = 'hidden';
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
   };
 
   // 등록 페이지로 이동
@@ -325,3 +622,31 @@
     loadSales();
   });
 </script>
+
+<style scoped>
+  /* 필터 영역 input/select 테두리 스타일 */
+  .admin--filter-row .admin--form-input,
+  .admin--filter-row .admin--form-select {
+    border: 1px solid var(--admin-border-color);
+    border-radius: 4px;
+    height: 33px;
+    padding: 6px 14px;
+    font-size: 13px;
+  }
+
+  /* 버튼 영역 간격 조정 */
+  .admin--search-actions {
+    display: flex;
+    gap: 12px;
+    flex-shrink: 0;
+    white-space: nowrap;
+  }
+
+  /* 상단 버튼들 세로 크기 */
+  .admin--search-actions .admin--btn-small {
+    height: 78px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+</style>

+ 270 - 0
app/pages/admin/staff/sales/print-a2.vue

@@ -0,0 +1,270 @@
+<template>
+  <div class="a2-print-page">
+    <div class="a2-header">
+      <h1>아우디 공식딜러 고진모터스 영업사원 명단</h1>
+    </div>
+
+    <div v-for="branch in groupedData" :key="branch.id" class="branch-section">
+      <h2 class="branch-name">{{ branch.name }}</h2>
+
+      <div v-for="team in branch.teams" :key="team.id" class="team-section">
+        <h3 class="team-name">{{ team.name }}</h3>
+
+        <div class="staff-grid">
+          <div v-for="staff in team.staff" :key="staff.id" class="staff-card">
+            <div class="staff-photo">
+              <img v-if="staff.photo_url" :src="getImageUrl(staff.photo_url)" :alt="staff.name" />
+              <img v-else src="/img/audi_logo.png" alt="Audi Logo" class="logo-placeholder" />
+            </div>
+            <div class="staff-info">
+              <div class="staff-position">{{ getPositionName(staff.position) }} {{ staff.name }}</div>
+              <div class="staff-phone">{{ staff.direct_phone || staff.main_phone }}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+
+definePageMeta({
+  layout: false
+})
+
+const { get } = useApi()
+const { getImageUrl } = useImage()
+
+const salesList = ref([])
+const showrooms = ref([])
+
+// 직책 이름 가져오기
+const getPositionName = (positionCode) => {
+  const positions = {
+    10: '팀장',
+    15: '마스터',
+    20: '차장',
+    30: '과장',
+    40: '대리',
+    60: '사원'
+  }
+  return positions[positionCode] || '-'
+}
+
+// 팀 이름 가져오기
+const getTeamName = (teamId) => {
+  if (teamId === 0 || teamId === '0') {
+    return '마스터팀'
+  }
+  return teamId ? `${teamId}팀` : '기타'
+}
+
+// 지점별, 팀별로 그룹화
+const groupedData = computed(() => {
+  const branches = {}
+
+  salesList.value.forEach(staff => {
+    const branchId = staff.showroom || 0
+    const teamId = staff.team ?? 0
+
+    // 지점 초기화
+    if (!branches[branchId]) {
+      const showroom = showrooms.value.find(s => s.id === branchId)
+      branches[branchId] = {
+        id: branchId,
+        name: showroom ? showroom.name : '미지정',
+        teams: {}
+      }
+    }
+
+    // 팀 초기화
+    if (!branches[branchId].teams[teamId]) {
+      branches[branchId].teams[teamId] = {
+        id: teamId,
+        name: getTeamName(teamId),
+        staff: []
+      }
+    }
+
+    // 직원 추가
+    branches[branchId].teams[teamId].staff.push(staff)
+  })
+
+  // 배열로 변환 및 정렬
+  return Object.values(branches).map(branch => ({
+    ...branch,
+    teams: Object.values(branch.teams).map(team => ({
+      ...team,
+      staff: team.staff.sort((a, b) => {
+        // 마스터(15) 먼저
+        if (a.position === 15 && b.position !== 15) return -1
+        if (a.position !== 15 && b.position === 15) return 1
+        // 그 다음 직급 순서대로 (10, 20, 30, 40, 60)
+        return a.position - b.position
+      })
+    })).sort((a, b) => a.id - b.id)
+  })).sort((a, b) => a.id - b.id)
+})
+
+// 데이터 로드
+const loadData = async () => {
+  // 전시장 목록 로드
+  const { data: branchData } = await get('/branch/list', { per_page: 1000 })
+  if (branchData?.success && branchData?.data) {
+    showrooms.value = branchData.data.items || []
+  }
+
+  // 전체 영업사원 목록 로드
+  const { data: salesData } = await get('/staff/sales', { per_page: 1000 })
+  if (salesData?.success && salesData?.data) {
+    salesList.value = salesData.data.items || []
+  }
+}
+
+onMounted(async () => {
+  await loadData()
+  // 데이터 로드 후 자동 출력
+  setTimeout(() => {
+    window.print()
+  }, 500)
+})
+</script>
+
+<style scoped>
+.a2-print-page {
+  padding: 20px;
+  background: white;
+}
+
+.a2-header {
+  text-align: center;
+  margin-bottom: 30px;
+  padding-bottom: 20px;
+  border-bottom: 3px solid #000;
+}
+
+.a2-header h1 {
+  font-size: 28px;
+  font-weight: bold;
+  margin: 0;
+}
+
+.branch-section {
+  margin-bottom: 40px;
+  page-break-inside: avoid;
+}
+
+.branch-name {
+  font-size: 24px;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 20px;
+  padding: 10px;
+  background: #f0f0f0;
+  border-left: 5px solid #000;
+}
+
+.team-section {
+  margin-bottom: 30px;
+  page-break-inside: avoid;
+}
+
+.team-name {
+  font-size: 20px;
+  font-weight: bold;
+  color: #555;
+  margin-bottom: 15px;
+  padding: 8px;
+  background: #f8f8f8;
+  border-left: 3px solid #666;
+}
+
+.staff-grid {
+  display: grid;
+  grid-template-columns: repeat(8, 1fr);
+  gap: 20px;
+  margin-bottom: 20px;
+}
+
+.staff-card {
+  border: 1px solid #ddd;
+  padding: 15px;
+  text-align: center;
+  background: white;
+  page-break-inside: avoid;
+}
+
+.staff-photo {
+  width: 100%;
+  height: 150px;
+  margin-bottom: 10px;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ddd;
+}
+
+.staff-photo img {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: cover;
+}
+
+.staff-photo .logo-placeholder {
+  max-width: 80%;
+  max-height: 80%;
+  object-fit: contain;
+}
+
+.staff-info {
+  margin-top: 10px;
+}
+
+.staff-position {
+  font-size: 16px;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 5px;
+}
+
+.staff-phone {
+  font-size: 13px;
+  color: #888;
+}
+
+/* 인쇄 스타일 */
+@media print {
+  .a2-print-page {
+    padding: 10mm;
+  }
+
+  .staff-grid {
+    grid-template-columns: repeat(8, 1fr);
+    gap: 10px;
+  }
+
+  .staff-card {
+    padding: 10px;
+  }
+
+  .staff-photo {
+    height: 120px;
+  }
+
+  .staff-position {
+    font-size: 14px;
+  }
+
+  .staff-phone {
+    font-size: 11px;
+  }
+
+  @page {
+    size: A2;
+    margin: 10mm;
+  }
+}
+</style>

+ 303 - 0
app/pages/admin/staff/sales/print/[id].vue

@@ -0,0 +1,303 @@
+<template>
+  <div class="sales--print-page">
+    <div class="sales--print-container">
+      <!-- 헤더 -->
+      <div class="sales--print-header">
+        <h1>아우디 공식딜러 고진모터스</h1>
+        <div class="audi--logo"><img src="/img/audi_logo.png" /></div>
+      </div>
+
+      <!-- 메인 콘텐츠 -->
+      <div class="sales--print-content">
+        <!-- 사진 영역 -->
+        <div class="sales--print-photo">
+          <img
+            v-if="staff?.photo_url"
+            :src="getImageUrl(staff.photo_url)"
+            :alt="staff.name"
+          />
+          <div v-else class="sales--print-photo-empty">사진없음</div>
+        </div>
+
+        <!-- 정보 영역 -->
+        <div class="sales--print-info">
+          <div class="sales--print-row">
+            <div class="sales--print-label">전시장</div>
+            <div class="sales--print-value">{{ getShowroomName(staff?.showroom) }}</div>
+          </div>
+
+          <div class="sales--print-row">
+            <div class="sales--print-label">팀</div>
+            <div class="sales--print-value">{{ getTeamName(staff?.team) }}</div>
+          </div>
+
+          <div class="sales--print-row">
+            <div class="sales--print-label">이름</div>
+            <div class="sales--print-value sales--print-name">{{ staff?.name }}</div>
+          </div>
+
+          <div class="sales--print-row">
+            <div class="sales--print-label">직책</div>
+            <div class="sales--print-value">{{ getPositionName(staff?.position) }}</div>
+          </div>
+
+          <!-- <div class="sales--print-row">
+            <div class="sales--print-label">대표번호</div>
+            <div class="sales--print-value">{{ staff?.main_phone }}</div>
+          </div> -->
+
+          <div class="sales--print-row">
+            <div class="sales--print-label">핸드폰</div>
+            <div class="sales--print-value">{{ staff?.direct_phone }}</div>
+          </div>
+
+          <div class="sales--print-row">
+            <div class="sales--print-label">이메일</div>
+            <div class="sales--print-value">{{ staff?.email }}</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 프린트 버튼 (프린트 시 숨김) -->
+      <div class="sales--print-actions no-print">
+        <button class="sales--print-btn" @click="handlePrint">프린트</button>
+        <button class="sales--print-btn sales--print-btn-secondary" @click="handleClose">
+          닫기
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, onMounted } from "vue";
+  import { useRoute, useRouter } from "vue-router";
+
+  definePageMeta({
+    layout: "blank",
+  });
+
+  const route = useRoute();
+  const router = useRouter();
+  const { get } = useApi();
+  const { getImageUrl } = useImage();
+
+  const staff = ref(null);
+  const showrooms = ref([]);
+
+  // 데이터 로드
+  const loadData = async () => {
+    const id = route.params.id;
+
+    // 영업사원 정보
+    const { data: staffData } = await get(`/staff/sales/${id}`);
+    if (staffData?.success && staffData?.data) {
+      staff.value = staffData.data;
+    }
+
+    // 전시장 리스트
+    const { data: branchData } = await get("/branch/list", { per_page: 1000 });
+    if (branchData?.success && branchData?.data) {
+      showrooms.value = branchData.data.items || [];
+    }
+  };
+
+  // 전시장 이름 가져오기
+  const getShowroomName = (showroomId) => {
+    const showroom = showrooms.value.find((s) => s.id === showroomId);
+    return showroom ? showroom.name : "-";
+  };
+
+  // 팀 이름 가져오기
+  const getTeamName = (teamId) => {
+    if (teamId === 0 || teamId === "0") {
+      return "마스터팀";
+    }
+    return teamId ? `${teamId}팀` : "-";
+  };
+
+  // 직책 이름 가져오기
+  const getPositionName = (positionCode) => {
+    const positions = {
+      10: "팀장",
+      15: "마스터",
+      20: "차장",
+      30: "과장",
+      40: "대리",
+      60: "사원",
+    };
+    return positions[positionCode] || "-";
+  };
+
+  // 프린트
+  const handlePrint = () => {
+    window.print();
+  };
+
+  // 닫기
+  const handleClose = () => {
+    window.close();
+  };
+
+  onMounted(() => {
+    loadData();
+  });
+</script>
+
+<style scoped>
+  .audi--logo {
+    img {
+      max-height: 50px;
+    }
+  }
+  .sales--print-page {
+    min-height: 100vh;
+    background: #f5f5f5;
+    padding: 20px;
+  }
+
+  .sales--print-container {
+    max-width: 800px;
+    margin: 0 auto;
+    background: white;
+    padding: 40px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  }
+
+  .sales--print-header {
+    text-align: center;
+    margin-bottom: 40px;
+    padding-bottom: 20px;
+    border-bottom: 2px solid #333;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+  }
+
+  .sales--print-header h1 {
+    font-size: 28px;
+    font-weight: bold;
+    color: #000;
+    margin: 0;
+  }
+
+  .sales--print-content {
+    display: flex;
+    gap: 40px;
+    margin-bottom: 40px;
+  }
+
+  .sales--print-photo {
+    flex-shrink: 0;
+    width: 200px;
+    height: 300px;
+    border: 1px solid #ddd;
+    border-radius: 8px;
+    overflow: hidden;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #f9f9f9;
+  }
+
+  .sales--print-photo img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  .sales--print-photo-empty {
+    color: #999;
+    font-size: 14px;
+  }
+
+  .sales--print-info {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+  }
+
+  .sales--print-row {
+    display: flex;
+    align-items: center;
+    padding-bottom: 12px;
+    border-bottom: 1px solid #e0e0e0;
+  }
+
+  .sales--print-label {
+    width: 120px;
+    font-weight: bold;
+    color: #333;
+    font-size: 15px;
+  }
+
+  .sales--print-value {
+    flex: 1;
+    color: #555;
+    font-size: 15px;
+  }
+
+  .sales--print-name {
+    font-size: 18px;
+    font-weight: bold;
+    color: #000;
+  }
+
+  .sales--print-actions {
+    display: flex;
+    justify-content: center;
+    gap: 12px;
+    margin-top: 40px;
+  }
+
+  .sales--print-btn {
+    padding: 12px 32px;
+    font-size: 16px;
+    font-weight: bold;
+    border: none;
+    border-radius: 4px;
+    cursor: pointer;
+    background: #000;
+    color: white;
+    transition: background 0.2s;
+  }
+
+  .sales--print-btn:hover {
+    background: #333;
+  }
+
+  .sales--print-btn-secondary {
+    background: #666;
+  }
+
+  .sales--print-btn-secondary:hover {
+    background: #888;
+  }
+
+  /* 프린트 스타일 */
+  @media print {
+    .sales--print-page {
+      background: white;
+      padding: 0;
+    }
+
+    .sales--print-container {
+      max-width: none;
+      box-shadow: none;
+      padding: 20px;
+    }
+
+    .no-print {
+      display: none !important;
+    }
+
+    .sales--print-header {
+      margin-bottom: 30px;
+    }
+
+    .sales--print-content {
+      margin-bottom: 0;
+    }
+  }
+</style>

BIN
public/img/audi_logo.png