index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. <template>
  2. <div class="admin--admins">
  3. <div class="admin--page-header">
  4. <h3>관리자 관리</h3>
  5. <button @click="openCreateModal" class="admin--btn-small admin--btn-small-primary">
  6. + 관리자 추가
  7. </button>
  8. </div>
  9. <!-- 검색 영역 -->
  10. <div class="admin--search-box">
  11. <div class="admin--search-form">
  12. <select v-model="filterRole" @change="loadAdmins" class="admin--form-select admin--search-select">
  13. <option value="">전체 역할</option>
  14. <option value="super_admin">슈퍼 관리자</option>
  15. <option value="admin">일반 관리자</option>
  16. </select>
  17. <select v-model="filterStatus" @change="loadAdmins" class="admin--form-select admin--search-select">
  18. <option value="">전체 상태</option>
  19. <option value="active">활성</option>
  20. <option value="inactive">비활성</option>
  21. </select>
  22. <input
  23. v-model="searchQuery"
  24. type="text"
  25. placeholder="아이디, 이름으로 검색"
  26. @keyup.enter="loadAdmins"
  27. class="admin--form-input admin--search-input"
  28. />
  29. <button @click="loadAdmins" class="admin--btn-small admin--btn-small-primary">
  30. 검색
  31. </button>
  32. <button @click="resetSearch" class="admin--btn-small admin--btn-small-secondary">
  33. 초기화
  34. </button>
  35. </div>
  36. </div>
  37. <!-- 관리자 목록 테이블 -->
  38. <div class="admin--table-container">
  39. <table class="admin--table">
  40. <thead>
  41. <tr>
  42. <th>ID</th>
  43. <th>아이디</th>
  44. <th>이름</th>
  45. <th>이메일</th>
  46. <th>부서</th>
  47. <th>역할</th>
  48. <th>상태</th>
  49. <th>생성일</th>
  50. <th>관리</th>
  51. </tr>
  52. </thead>
  53. <tbody>
  54. <tr v-if="loading">
  55. <td colspan="9" class="admin--loading">로딩 중...</td>
  56. </tr>
  57. <tr v-else-if="admins.length === 0">
  58. <td colspan="9" class="admin--no-data">관리자가 없습니다.</td>
  59. </tr>
  60. <tr v-else v-for="admin in admins" :key="admin.id">
  61. <td>{{ admin.id }}</td>
  62. <td>{{ admin.username }}</td>
  63. <td>{{ admin.name }}</td>
  64. <td>{{ admin.email }}</td>
  65. <td>{{ admin.department || '-' }}</td>
  66. <td>
  67. <span :class="['admin--badge', getRoleBadgeClass(admin.role)]">
  68. {{ getRoleLabel(admin.role) }}
  69. </span>
  70. </td>
  71. <td>
  72. <div style="display: flex; gap: 4px; align-items: center;">
  73. <span :class="['admin--badge', getStatusBadgeClass(admin.status)]">
  74. {{ getStatusLabel(admin.status) }}
  75. </span>
  76. <span v-if="admin.login_attempts >= 5" class="admin--badge admin--badge-danger" title="로그인 5회 실패로 계정 잠김">
  77. 🔒 잠김
  78. </span>
  79. </div>
  80. </td>
  81. <td>{{ formatDate(admin.created_at) }}</td>
  82. <td>
  83. <div class="admin--table-actions admin--table-actions-col">
  84. <button @click="openEditModal(admin)" class="admin--btn-small admin--btn-small-primary">
  85. 수정
  86. </button>
  87. <button @click="openPasswordModal(admin)" class="admin--btn-small admin--btn-small-secondary">
  88. 비밀번호
  89. </button>
  90. <button
  91. v-if="admin.login_attempts >= 5"
  92. @click="confirmUnlockAccount(admin)"
  93. class="admin--btn-small admin--btn-small-warning"
  94. title="계정 잠금 해제"
  95. >
  96. 잠금해제
  97. </button>
  98. <button
  99. @click="confirmDeleteAdmin(admin)"
  100. class="admin--btn-small admin--btn-small-danger"
  101. :disabled="admin.id === currentAdminId"
  102. >
  103. 삭제
  104. </button>
  105. </div>
  106. </td>
  107. </tr>
  108. </tbody>
  109. </table>
  110. </div>
  111. <!-- 페이지네이션 -->
  112. <div class="admin--pagination" v-if="totalPages > 1">
  113. <button
  114. @click="changePage(currentPage - 1)"
  115. :disabled="currentPage === 1"
  116. class="admin--btn-page"
  117. >
  118. 이전
  119. </button>
  120. <span class="admin--page-info">
  121. {{ currentPage }} / {{ totalPages }}
  122. </span>
  123. <button
  124. @click="changePage(currentPage + 1)"
  125. :disabled="currentPage === totalPages"
  126. class="admin--btn-page"
  127. >
  128. 다음
  129. </button>
  130. </div>
  131. <!-- 관리자 추가/수정 모달 -->
  132. <AdminModal
  133. v-if="showModal"
  134. :admin="selectedAdmin"
  135. @close="closeModal"
  136. @saved="handleSaved"
  137. />
  138. <!-- 비밀번호 변경 모달 -->
  139. <PasswordModal
  140. v-if="showPasswordModal"
  141. :admin="selectedAdmin"
  142. @close="closePasswordModal"
  143. @saved="handlePasswordChanged"
  144. />
  145. <!-- 알림 모달 -->
  146. <AdminAlertModal
  147. v-if="alertModal.show"
  148. :title="alertModal.title"
  149. :message="alertModal.message"
  150. :type="alertModal.type"
  151. @confirm="handleAlertConfirm"
  152. @cancel="handleAlertCancel"
  153. @close="closeAlertModal"
  154. />
  155. </div>
  156. </template>
  157. <script setup>
  158. import { ref, onMounted, computed } from 'vue'
  159. import AdminAlertModal from '~/components/admin/AdminAlertModal.vue'
  160. import AdminModal from '~/components/admin/AdminModal.vue'
  161. import PasswordModal from '~/components/admin/PasswordModal.vue'
  162. definePageMeta({
  163. layout: 'admin',
  164. middleware: ['auth']
  165. })
  166. const { get, del, post } = useApi()
  167. // 현재 로그인한 관리자 ID
  168. const currentAdminId = computed(() => {
  169. if (typeof window === 'undefined') return null
  170. const user = localStorage.getItem('admin_user')
  171. if (!user) return null
  172. try {
  173. return JSON.parse(user).id
  174. } catch {
  175. return null
  176. }
  177. })
  178. // 데이터
  179. const admins = ref([])
  180. const loading = ref(false)
  181. const searchQuery = ref('')
  182. const filterRole = ref('')
  183. const filterStatus = ref('')
  184. // 페이지네이션
  185. const currentPage = ref(1)
  186. const totalPages = ref(1)
  187. const perPage = ref(10)
  188. // 모달
  189. const showModal = ref(false)
  190. const showPasswordModal = ref(false)
  191. const selectedAdmin = ref(null)
  192. // 알림 모달
  193. const alertModal = ref({
  194. show: false,
  195. title: '알림',
  196. message: '',
  197. type: 'alert',
  198. onConfirm: null
  199. })
  200. // 알림 모달 표시
  201. const showAlert = (message, title = '알림') => {
  202. alertModal.value = {
  203. show: true,
  204. title,
  205. message,
  206. type: 'alert',
  207. onConfirm: null
  208. }
  209. }
  210. // 확인 모달 표시
  211. const showConfirm = (message, onConfirm, title = '확인') => {
  212. alertModal.value = {
  213. show: true,
  214. title,
  215. message,
  216. type: 'confirm',
  217. onConfirm
  218. }
  219. }
  220. // 알림 모달 닫기
  221. const closeAlertModal = () => {
  222. alertModal.value.show = false
  223. }
  224. // 알림 모달 확인
  225. const handleAlertConfirm = () => {
  226. if (alertModal.value.onConfirm) {
  227. alertModal.value.onConfirm()
  228. }
  229. closeAlertModal()
  230. }
  231. // 알림 모달 취소
  232. const handleAlertCancel = () => {
  233. closeAlertModal()
  234. }
  235. // 관리자 목록 로드
  236. const loadAdmins = async () => {
  237. loading.value = true
  238. try {
  239. const params = {
  240. page: currentPage.value,
  241. per_page: perPage.value
  242. }
  243. if (searchQuery.value) {
  244. params.search = searchQuery.value
  245. }
  246. if (filterRole.value) {
  247. params.role = filterRole.value
  248. }
  249. if (filterStatus.value) {
  250. params.status = filterStatus.value
  251. }
  252. console.log('[Admins] 검색 파라미터:', params)
  253. const { data, error } = await get('/admin', { params })
  254. if (error) {
  255. console.error('[Admins] 목록 로드 실패:', error)
  256. return
  257. }
  258. // API 응답: { success: true, data: { items, total_pages }, message }
  259. if (data?.success && data?.data) {
  260. admins.value = data.data.items || []
  261. totalPages.value = data.data.total_pages || 1
  262. console.log('[Admins] 목록 로드 성공:', data.data)
  263. console.log('[Admins] 첫번째 관리자 login_attempts:', admins.value[0]?.login_attempts)
  264. console.log('[Admins] 잠긴 계정 수:', admins.value.filter(a => a.login_attempts >= 5).length)
  265. }
  266. } finally {
  267. loading.value = false
  268. }
  269. }
  270. // 페이지 변경
  271. const changePage = (page) => {
  272. if (page < 1 || page > totalPages.value) return
  273. currentPage.value = page
  274. loadAdmins()
  275. }
  276. // 검색 초기화
  277. const resetSearch = () => {
  278. searchQuery.value = ''
  279. filterRole.value = ''
  280. filterStatus.value = ''
  281. currentPage.value = 1
  282. loadAdmins()
  283. }
  284. // 모달 열기/닫기
  285. const openCreateModal = () => {
  286. selectedAdmin.value = null
  287. showModal.value = true
  288. }
  289. const openEditModal = (admin) => {
  290. selectedAdmin.value = { ...admin }
  291. showModal.value = true
  292. }
  293. const closeModal = () => {
  294. showModal.value = false
  295. selectedAdmin.value = null
  296. }
  297. const handleSaved = (message) => {
  298. closeModal()
  299. loadAdmins()
  300. if (message) {
  301. showAlert(message, '성공')
  302. }
  303. }
  304. // 비밀번호 모달
  305. const openPasswordModal = (admin) => {
  306. selectedAdmin.value = { ...admin }
  307. showPasswordModal.value = true
  308. }
  309. const closePasswordModal = () => {
  310. showPasswordModal.value = false
  311. selectedAdmin.value = null
  312. }
  313. const handlePasswordChanged = (message) => {
  314. closePasswordModal()
  315. if (message) {
  316. showAlert(message, '성공')
  317. }
  318. }
  319. // 관리자 삭제 확인
  320. const confirmDeleteAdmin = (admin) => {
  321. if (admin.id === currentAdminId.value) {
  322. showAlert('본인 계정은 삭제할 수 없습니다.', '경고')
  323. return
  324. }
  325. showConfirm(
  326. `${admin.name} (${admin.username}) 관리자를 삭제하시겠습니까?`,
  327. () => deleteAdmin(admin),
  328. '관리자 삭제'
  329. )
  330. }
  331. // 관리자 삭제
  332. const deleteAdmin = async (admin) => {
  333. const { data, error } = await del(`/admin/${admin.id}`)
  334. if (error) {
  335. showAlert('관리자 삭제에 실패했습니다.', '오류')
  336. console.error('[Admins] 삭제 실패:', error)
  337. return
  338. }
  339. if (data?.success) {
  340. showAlert('관리자가 삭제되었습니다.', '성공')
  341. loadAdmins()
  342. }
  343. }
  344. // 계정 잠금 해제 확인
  345. const confirmUnlockAccount = (admin) => {
  346. showConfirm(
  347. `${admin.name} (${admin.username}) 계정의 잠금을 해제하시겠습니까?`,
  348. () => unlockAccount(admin),
  349. '계정 잠금 해제'
  350. )
  351. }
  352. // 계정 잠금 해제
  353. const unlockAccount = async (admin) => {
  354. const { data, error } = await post(`/admin/${admin.id}/unlock`)
  355. if (error) {
  356. showAlert('계정 잠금 해제에 실패했습니다.', '오류')
  357. console.error('[Admins] 잠금 해제 실패:', error)
  358. return
  359. }
  360. if (data?.success) {
  361. showAlert('계정 잠금이 해제되었습니다.', '성공')
  362. loadAdmins()
  363. }
  364. }
  365. // 유틸리티 함수
  366. const getRoleLabel = (role) => {
  367. const labels = {
  368. super_admin: '슈퍼 관리자',
  369. admin: '일반 관리자'
  370. }
  371. return labels[role] || role
  372. }
  373. const getRoleBadgeClass = (role) => {
  374. return role === 'super_admin' ? 'admin--badge-danger' : 'admin--badge-primary'
  375. }
  376. const getStatusLabel = (status) => {
  377. const labels = {
  378. active: '활성',
  379. inactive: '비활성'
  380. }
  381. return labels[status] || status
  382. }
  383. const getStatusBadgeClass = (status) => {
  384. return status === 'active' ? 'admin--badge-success' : 'admin--badge-secondary'
  385. }
  386. const formatDate = (dateString) => {
  387. if (!dateString) return '-'
  388. const date = new Date(dateString)
  389. return date.toLocaleString('ko-KR', {
  390. year: 'numeric',
  391. month: '2-digit',
  392. day: '2-digit',
  393. hour: '2-digit',
  394. minute: '2-digit'
  395. })
  396. }
  397. onMounted(() => {
  398. loadAdmins()
  399. })
  400. </script>
  401. <style scoped>
  402. .admin--search-box .admin--form-input,
  403. .admin--search-box .admin--search-input,
  404. .admin--search-box .admin--form-select,
  405. .admin--search-box .admin--search-select {
  406. border: 1px solid var(--admin-border-color) !important;
  407. height: 33px !important;
  408. padding: 6px 14px !important;
  409. font-size: 13px !important;
  410. }
  411. .admin--btn-small-warning {
  412. background: #ff9800;
  413. color: #ffffff;
  414. border: none;
  415. }
  416. .admin--btn-small-warning:hover:not(:disabled) {
  417. background: #f57c00;
  418. }
  419. </style>