353 lines
12 KiB
Vue
353 lines
12 KiB
Vue
<template>
|
|
<div>
|
|
<AppHeader />
|
|
|
|
<div class="container-fluid py-4">
|
|
<!-- 헤더 -->
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h4 class="mb-0">
|
|
<i class="bi bi-people me-2"></i>사용자 관리
|
|
</h4>
|
|
<div>
|
|
<NuxtLink to="/admin/user/create" class="btn btn-primary me-2">
|
|
<i class="bi bi-person-plus me-1"></i>사용자 추가
|
|
</NuxtLink>
|
|
<button class="btn btn-outline-secondary" @click="showRoleModal = true">
|
|
<i class="bi bi-shield-lock me-1"></i>권한 관리
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 검색 -->
|
|
<div class="card mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col-1 text-end"><label class="col-form-label">소속사</label></div>
|
|
<div class="col-2">
|
|
<select class="form-select form-select-sm" v-model="search.company">
|
|
<option value="">전체</option>
|
|
<option value="(주)터보소프트">(주)터보소프트</option>
|
|
<option value="(주)코쿤">(주)코쿤</option>
|
|
<option value="(주)오솔정보기술">(주)오솔정보기술</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-1 text-end"><label class="col-form-label">이름</label></div>
|
|
<div class="col-2">
|
|
<input type="text" class="form-control form-control-sm" v-model="search.name" @keyup.enter="loadUsers" />
|
|
</div>
|
|
<div class="col-1 text-end"><label class="col-form-label">상태</label></div>
|
|
<div class="col-2">
|
|
<div class="btn-group" role="group">
|
|
<input type="radio" class="btn-check" name="status" id="statusAll" value="all" v-model="search.status" @change="loadUsers">
|
|
<label class="btn btn-outline-secondary btn-sm" for="statusAll">전체</label>
|
|
<input type="radio" class="btn-check" name="status" id="statusActive" value="active" v-model="search.status" @change="loadUsers">
|
|
<label class="btn btn-outline-secondary btn-sm" for="statusActive">활성</label>
|
|
<input type="radio" class="btn-check" name="status" id="statusInactive" value="inactive" v-model="search.status" @change="loadUsers">
|
|
<label class="btn btn-outline-secondary btn-sm" for="statusInactive">비활성</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row g-2 align-items-center mt-1">
|
|
<div class="col-1 text-end"><label class="col-form-label">이메일</label></div>
|
|
<div class="col-2">
|
|
<input type="text" class="form-control form-control-sm" v-model="search.email" @keyup.enter="loadUsers" />
|
|
</div>
|
|
<div class="col-1 text-end"><label class="col-form-label">연락처</label></div>
|
|
<div class="col-2">
|
|
<input type="text" class="form-control form-control-sm" v-model="search.phone" @keyup.enter="loadUsers" />
|
|
</div>
|
|
<div class="col-1"></div>
|
|
<div class="col-2">
|
|
<button class="btn btn-primary btn-sm me-1" @click="loadUsers">
|
|
<i class="bi bi-search me-1"></i>조회
|
|
</button>
|
|
<button class="btn btn-outline-secondary btn-sm" @click="resetSearch">
|
|
<i class="bi bi-arrow-counterclockwise"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 사용자 목록 -->
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span>사용자 목록 총 <strong>{{ users.length }}</strong>건</span>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-bordered mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width: 50px" class="text-center">No</th>
|
|
<th style="width: 130px">소속사</th>
|
|
<th style="width: 100px">직급</th>
|
|
<th style="width: 100px">이름</th>
|
|
<th style="width: 180px">이메일</th>
|
|
<th style="width: 130px">연락처</th>
|
|
<th style="width: 70px" class="text-center">상태</th>
|
|
<!-- 동적 권한 컬럼 -->
|
|
<th
|
|
v-for="role in roles"
|
|
:key="role.role_id"
|
|
style="width: 70px"
|
|
class="text-center"
|
|
>
|
|
{{ role.role_name }}
|
|
</th>
|
|
<th style="width: 160px" class="text-center">최근로그인일시</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="isLoading">
|
|
<td :colspan="8 + roles.length" class="text-center py-4">
|
|
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
|
</td>
|
|
</tr>
|
|
<tr v-else-if="users.length === 0">
|
|
<td :colspan="8 + roles.length" class="text-center py-5 text-muted">
|
|
<i class="bi bi-inbox display-4"></i>
|
|
<p class="mt-2 mb-0">조회된 사용자가 없습니다.</p>
|
|
</td>
|
|
</tr>
|
|
<tr v-else v-for="(user, idx) in users" :key="user.employee_id">
|
|
<td class="text-center">{{ idx + 1 }}</td>
|
|
<td>{{ user.company || '-' }}</td>
|
|
<td>{{ user.employee_position || '-' }}</td>
|
|
<td>
|
|
<NuxtLink :to="`/admin/user/${user.employee_id}`" class="text-decoration-none">
|
|
{{ user.employee_name }}
|
|
</NuxtLink>
|
|
</td>
|
|
<td>{{ user.employee_email }}</td>
|
|
<td>{{ formatPhone(user.employee_phone) }}</td>
|
|
<td class="text-center">
|
|
<span
|
|
class="badge"
|
|
:class="user.is_active ? 'bg-success' : 'bg-secondary'"
|
|
>
|
|
{{ user.is_active ? '활성' : '비활성' }}
|
|
</span>
|
|
</td>
|
|
<!-- 동적 권한 체크박스 -->
|
|
<td
|
|
v-for="role in roles"
|
|
:key="role.role_id"
|
|
class="text-center"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
class="form-check-input"
|
|
:checked="user.roleIds.includes(role.role_id)"
|
|
@change="toggleRole(user.employee_id, role.role_id, role.role_name)"
|
|
:disabled="isToggling[`${user.employee_id}-${role.role_id}`]"
|
|
/>
|
|
</td>
|
|
<td class="text-center small">
|
|
{{ formatDateTime(user.last_login_at) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 토스트 메시지 -->
|
|
<div
|
|
v-if="toastMessage"
|
|
class="toast-message"
|
|
:class="toastType === 'success' ? 'bg-success' : 'bg-danger'"
|
|
>
|
|
<i :class="toastType === 'success' ? 'bi bi-check-circle' : 'bi bi-x-circle'" class="me-2"></i>
|
|
{{ toastMessage }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 권한관리 모달 -->
|
|
<RoleManageModal
|
|
v-if="showRoleModal"
|
|
@close="showRoleModal = false"
|
|
@updated="loadUsers"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const { fetchCurrentUser, hasMenuAccess } = useAuth()
|
|
const router = useRouter()
|
|
|
|
const isLoading = ref(true)
|
|
const users = ref<any[]>([])
|
|
const roles = ref<any[]>([])
|
|
|
|
// 검색 조건
|
|
const search = ref({
|
|
company: '',
|
|
name: '',
|
|
email: '',
|
|
phone: '',
|
|
status: 'active'
|
|
})
|
|
|
|
// 모달 상태
|
|
const showRoleModal = ref(false)
|
|
const isToggling = ref<Record<string, boolean>>({})
|
|
|
|
// 토스트 메시지
|
|
const toastMessage = ref('')
|
|
const toastType = ref<'success' | 'error'>('success')
|
|
let toastTimer: any = null
|
|
|
|
onMounted(async () => {
|
|
const user = await fetchCurrentUser()
|
|
if (!user) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
|
|
if (!hasMenuAccess('ADMIN_USER')) {
|
|
alert('접근 권한이 없습니다.')
|
|
router.push('/')
|
|
return
|
|
}
|
|
|
|
await loadUsers()
|
|
})
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
isLoading.value = true
|
|
const response = await $fetch<any>('/api/admin/user/list', {
|
|
query: {
|
|
company: search.value.company,
|
|
name: search.value.name,
|
|
email: search.value.email,
|
|
phone: search.value.phone,
|
|
status: search.value.status
|
|
}
|
|
})
|
|
users.value = response.users || []
|
|
roles.value = response.roles || []
|
|
} catch (e: any) {
|
|
console.error(e)
|
|
alert('사용자 목록을 불러오는데 실패했습니다.')
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
function resetSearch() {
|
|
search.value = {
|
|
company: '',
|
|
name: '',
|
|
email: '',
|
|
phone: '',
|
|
status: 'active'
|
|
}
|
|
loadUsers()
|
|
}
|
|
|
|
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
toastMessage.value = message
|
|
toastType.value = type
|
|
toastTimer = setTimeout(() => {
|
|
toastMessage.value = ''
|
|
}, 2500)
|
|
}
|
|
|
|
async function toggleRole(employeeId: number, roleId: number, roleName: string) {
|
|
const key = `${employeeId}-${roleId}`
|
|
isToggling.value[key] = true
|
|
|
|
try {
|
|
const response = await $fetch<any>(`/api/admin/user/${employeeId}/toggle-role`, {
|
|
method: 'POST',
|
|
body: { roleId }
|
|
})
|
|
|
|
const user = users.value.find(u => u.employee_id === employeeId)
|
|
if (user) {
|
|
if (response.added) {
|
|
user.roleIds.push(roleId)
|
|
showToast(`${user.employee_name}님에게 ${roleName} 권한이 부여되었습니다.`, 'success')
|
|
} else {
|
|
user.roleIds = user.roleIds.filter((id: number) => id !== roleId)
|
|
showToast(`${user.employee_name}님의 ${roleName} 권한이 해제되었습니다.`, 'success')
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.error(e)
|
|
showToast(e.data?.message || '권한 변경에 실패했습니다.', 'error')
|
|
await loadUsers()
|
|
} finally {
|
|
isToggling.value[key] = false
|
|
}
|
|
}
|
|
|
|
function formatPhone(phone: string) {
|
|
if (!phone) return '-'
|
|
// 숫자만 추출
|
|
const nums = phone.replace(/[^0-9]/g, '')
|
|
// 010-1234-5678 형식
|
|
if (nums.length === 11) {
|
|
return `${nums.slice(0, 3)}-${nums.slice(3, 7)}-${nums.slice(7)}`
|
|
}
|
|
// 02-1234-5678 형식 (서울)
|
|
if (nums.length === 10 && nums.startsWith('02')) {
|
|
return `${nums.slice(0, 2)}-${nums.slice(2, 6)}-${nums.slice(6)}`
|
|
}
|
|
// 031-123-4567 형식 (지역번호)
|
|
if (nums.length === 10) {
|
|
return `${nums.slice(0, 3)}-${nums.slice(3, 6)}-${nums.slice(6)}`
|
|
}
|
|
// 그 외는 원본 반환
|
|
return phone
|
|
}
|
|
|
|
function formatDateTime(dateStr: string) {
|
|
if (!dateStr) return '-'
|
|
const d = new Date(dateStr)
|
|
const year = d.getFullYear()
|
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
const day = String(d.getDate()).padStart(2, '0')
|
|
const hour = String(d.getHours()).padStart(2, '0')
|
|
const minute = String(d.getMinutes()).padStart(2, '0')
|
|
const second = String(d.getSeconds()).padStart(2, '0')
|
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.form-check-input {
|
|
cursor: pointer;
|
|
width: 1.2em;
|
|
height: 1.2em;
|
|
}
|
|
.form-check-input:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.toast-message {
|
|
position: absolute;
|
|
bottom: 10px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
animation: fadeInOut 2.5s ease-in-out;
|
|
z-index: 100;
|
|
}
|
|
|
|
@keyframes fadeInOut {
|
|
0% { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
|
10% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
80% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
|
}
|
|
</style>
|