권한, 사용자, 메뉴 등에 대한 기능 업데이트
This commit is contained in:
@@ -359,7 +359,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const { fetchCurrentUser, hasMenuAccess } = useAuth()
|
||||
const { getWeekInfo, getWeekDates, getLastWeekInfo, getActualCurrentWeekInfo, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -403,6 +403,12 @@ onMounted(async () => {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
// 메뉴 권한 체크
|
||||
if (!hasMenuAccess('ADMIN_BULK_IMPORT')) {
|
||||
alert('접근 권한이 없습니다.')
|
||||
router.push('/report/weekly')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getHeaderClass(report: any) {
|
||||
|
||||
161
frontend/admin/menu/index.vue
Normal file
161
frontend/admin/menu/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<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-list me-2"></i>메뉴 관리
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px">No</th>
|
||||
<th style="width: 150px">메뉴코드</th>
|
||||
<th>메뉴명</th>
|
||||
<th style="width: 200px">경로</th>
|
||||
<th v-for="role in roles" :key="role.roleId" style="width: 100px" class="text-center">
|
||||
{{ role.roleName }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td :colspan="5 + roles.length" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<template v-for="(menu, idx) in menus" :key="menu.menuId">
|
||||
<tr :class="{ 'table-secondary': !menu.parentMenuId }">
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td>
|
||||
<code class="small">{{ menu.menuCode }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="menu.parentMenuId" class="text-muted me-2">└</span>
|
||||
<i :class="['bi', menu.menuIcon, 'me-1']" v-if="menu.menuIcon"></i>
|
||||
<strong v-if="!menu.parentMenuId">{{ menu.menuName }}</strong>
|
||||
<span v-else>{{ menu.menuName }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="small text-muted">{{ menu.menuPath || '-' }}</code>
|
||||
</td>
|
||||
<td v-for="role in roles" :key="role.roleId" class="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
:checked="menu.roleIds.includes(role.roleId)"
|
||||
@change="toggleRole(menu.menuId, role.roleId, $event)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 메시지 -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1100">
|
||||
<div v-if="toast.show" class="toast show" role="alert">
|
||||
<div class="toast-header" :class="toast.type === 'success' ? 'bg-success text-white' : 'bg-danger text-white'">
|
||||
<strong class="me-auto">{{ toast.type === 'success' ? '성공' : '오류' }}</strong>
|
||||
<button type="button" class="btn-close btn-close-white" @click="toast.show = false"></button>
|
||||
</div>
|
||||
<div class="toast-body">{{ toast.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser, hasMenuAccess } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const menus = ref<any[]>([])
|
||||
const roles = ref<any[]>([])
|
||||
|
||||
const toast = ref({
|
||||
show: false,
|
||||
type: 'success' as 'success' | 'error',
|
||||
message: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasMenuAccess('ADMIN_MENU')) {
|
||||
alert('접근 권한이 없습니다.')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
await loadMenus()
|
||||
})
|
||||
|
||||
async function loadMenus() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/admin/menu/list')
|
||||
menus.value = res.menus
|
||||
roles.value = res.roles
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
showToast('error', '메뉴 목록을 불러오는데 실패했습니다.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRole(menuId: number, roleId: number, event: Event) {
|
||||
const checkbox = event.target as HTMLInputElement
|
||||
const enabled = checkbox.checked
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/menu/${menuId}/toggle-role`, {
|
||||
method: 'POST',
|
||||
body: { roleId, enabled }
|
||||
})
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
const menu = menus.value.find(m => m.menuId === menuId)
|
||||
if (menu) {
|
||||
if (enabled) {
|
||||
if (!menu.roleIds.includes(roleId)) {
|
||||
menu.roleIds.push(roleId)
|
||||
}
|
||||
} else {
|
||||
menu.roleIds = menu.roleIds.filter((id: number) => id !== roleId)
|
||||
}
|
||||
}
|
||||
|
||||
const roleName = roles.value.find(r => r.roleId === roleId)?.roleName || ''
|
||||
showToast('success', `${enabled ? '권한 추가' : '권한 제거'}: ${roleName}`)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
checkbox.checked = !enabled // 롤백
|
||||
showToast('error', '권한 변경에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(type: 'success' | 'error', message: string) {
|
||||
toast.value = { show: true, type, message }
|
||||
setTimeout(() => {
|
||||
toast.value.show = false
|
||||
}, 2500)
|
||||
}
|
||||
</script>
|
||||
359
frontend/admin/user/[id].vue
Normal file
359
frontend/admin/user/[id].vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<!-- 헤더 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-pencil me-2"></i>사용자 수정
|
||||
</h4>
|
||||
<NuxtLink to="/admin/user" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록으로
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</div>
|
||||
|
||||
<template v-else-if="userInfo">
|
||||
<!-- 기본 정보 폼 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>기본 정보</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="saveUser">
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">이름 <span class="text-danger">*</span></label>
|
||||
<div class="col-9">
|
||||
<input type="text" class="form-control" v-model="form.employeeName" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">이메일</label>
|
||||
<div class="col-9">
|
||||
<input type="email" class="form-control" :value="userInfo.employeeEmail" disabled />
|
||||
<small class="text-muted">이메일은 변경할 수 없습니다.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">소속사</label>
|
||||
<div class="col-9">
|
||||
<select class="form-select" v-model="form.company">
|
||||
<option value="">(선택)</option>
|
||||
<option value="(주)터보소프트">(주)터보소프트</option>
|
||||
<option value="(주)코쿤">(주)코쿤</option>
|
||||
<option value="(주)오솔정보기술">(주)오솔정보기술</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">직급</label>
|
||||
<div class="col-9">
|
||||
<select class="form-select" v-model="form.employeePosition">
|
||||
<option value="">선택</option>
|
||||
<optgroup label="일반">
|
||||
<option value="인턴">인턴</option>
|
||||
<option value="사원">사원</option>
|
||||
<option value="주임">주임</option>
|
||||
<option value="대리">대리</option>
|
||||
<option value="과장">과장</option>
|
||||
<option value="차장">차장</option>
|
||||
<option value="부장">부장</option>
|
||||
</optgroup>
|
||||
<optgroup label="연구소">
|
||||
<option value="연구원">연구원</option>
|
||||
<option value="주임연구원">주임연구원</option>
|
||||
<option value="선임연구원">선임연구원</option>
|
||||
<option value="책임연구원">책임연구원</option>
|
||||
<option value="수석연구원">수석연구원</option>
|
||||
<option value="연구소장">연구소장</option>
|
||||
</optgroup>
|
||||
<optgroup label="임원">
|
||||
<option value="이사">이사</option>
|
||||
<option value="상무이사">상무이사</option>
|
||||
<option value="전무이사">전무이사</option>
|
||||
<option value="부사장">부사장</option>
|
||||
<option value="사장">사장</option>
|
||||
<option value="대표이사">대표이사</option>
|
||||
</optgroup>
|
||||
<optgroup label="기타">
|
||||
<option value="팀장">팀장</option>
|
||||
<option value="실장">실장</option>
|
||||
<option value="본부장">본부장</option>
|
||||
<option value="고문">고문</option>
|
||||
<option value="감사">감사</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">연락처</label>
|
||||
<div class="col-9">
|
||||
<input type="tel" class="form-control" v-model="form.employeePhone" placeholder="010-0000-0000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">입사일</label>
|
||||
<div class="col-9">
|
||||
<input type="date" class="form-control" v-model="form.joinDate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">상태</label>
|
||||
<div class="col-9">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="isActiveSwitch" v-model="form.isActive" />
|
||||
<label class="form-check-label" for="isActiveSwitch">
|
||||
{{ form.isActive ? '활성' : '비활성' }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3" />
|
||||
|
||||
<div class="row mb-2">
|
||||
<label class="col-3 col-form-label text-muted small">최초입력</label>
|
||||
<div class="col-9">
|
||||
<span class="form-control-plaintext small text-muted">
|
||||
{{ formatDateTime(userInfo.createdAt) }}
|
||||
<span v-if="userInfo.createdIp" class="ms-2"><code>{{ userInfo.createdIp }}</code></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label text-muted small">최종수정</label>
|
||||
<div class="col-9">
|
||||
<span class="form-control-plaintext small text-muted">
|
||||
{{ formatDateTime(userInfo.updatedAt) }}
|
||||
<span v-if="userInfo.updatedIp" class="ms-2"><code>{{ userInfo.updatedIp }}</code></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<NuxtLink to="/admin/user" class="btn btn-secondary me-2">취소</NuxtLink>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!canSave || isSaving">
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1"></span>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 이력 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<strong>로그인 이력</strong>
|
||||
<small class="text-muted ms-2">(최근 20건)</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>로그인 시간</th>
|
||||
<th>로그인 IP</th>
|
||||
<th>로그아웃 시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loginHistory.length === 0">
|
||||
<td colspan="3" class="text-center text-muted py-3">로그인 이력이 없습니다.</td>
|
||||
</tr>
|
||||
<tr v-else v-for="h in loginHistory" :key="h.historyId">
|
||||
<td>{{ formatDateTime(h.loginAt) }}</td>
|
||||
<td><code>{{ h.loginIp || '-' }}</code></td>
|
||||
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 위험 영역 -->
|
||||
<div class="card border-danger" v-if="!isSelf">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<strong><i class="bi bi-exclamation-triangle me-2"></i>위험 영역</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">이 사용자를 삭제합니다. 주간보고가 있는 경우 비활성화 처리됩니다.</p>
|
||||
<button class="btn btn-danger" @click="confirmDelete" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i class="bi bi-trash me-1"></i>사용자 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div v-if="showDeleteModal" class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title">삭제 확인</h5>
|
||||
<button type="button" class="btn-close btn-close-white" @click="showDeleteModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0"><strong>{{ userInfo?.employeeName }}</strong>님을 삭제하시겠습니까?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">취소</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteUser" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser, hasMenuAccess } = useAuth()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const employeeId = computed(() => route.params.id as string)
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const currentUser = ref<any>(null)
|
||||
const userInfo = ref<any>(null)
|
||||
const loginHistory = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
employeeName: '',
|
||||
company: '',
|
||||
employeePosition: '',
|
||||
employeePhone: '',
|
||||
joinDate: '',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
const canSave = computed(() => form.value.employeeName.trim())
|
||||
const isSelf = computed(() => currentUser.value?.employeeId === parseInt(employeeId.value))
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
currentUser.value = user
|
||||
|
||||
if (!hasMenuAccess('ADMIN_USER')) {
|
||||
alert('접근 권한이 없습니다.')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
await loadUserInfo()
|
||||
})
|
||||
|
||||
async function loadUserInfo() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<any>(`/api/employee/${employeeId.value}/detail`)
|
||||
userInfo.value = res.employee
|
||||
loginHistory.value = res.loginHistory || []
|
||||
|
||||
// 폼에 기존 데이터 설정
|
||||
form.value = {
|
||||
employeeName: res.employee.employeeName || '',
|
||||
company: res.employee.company || '',
|
||||
employeePosition: res.employee.employeePosition || '',
|
||||
employeePhone: res.employee.employeePhone || '',
|
||||
joinDate: res.employee.joinDate ? res.employee.joinDate.split('T')[0] : '',
|
||||
isActive: res.employee.isActive
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert('사용자 정보를 불러오는데 실패했습니다.')
|
||||
router.push('/admin/user')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!canSave.value) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch(`/api/employee/${employeeId.value}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
employeeName: form.value.employeeName,
|
||||
company: form.value.company || null,
|
||||
employeePosition: form.value.employeePosition || null,
|
||||
employeePhone: form.value.employeePhone || null,
|
||||
joinDate: form.value.joinDate || null,
|
||||
isActive: form.value.isActive
|
||||
}
|
||||
})
|
||||
|
||||
alert('저장되었습니다.')
|
||||
router.push('/admin/user')
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
isDeleting.value = true
|
||||
try {
|
||||
const response = await $fetch<any>(`/api/employee/${employeeId.value}/delete`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
alert(response.message)
|
||||
router.push('/admin/user')
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
showDeleteModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
177
frontend/admin/user/create.vue
Normal file
177
frontend/admin/user/create.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<!-- 헤더 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-person-plus me-2"></i>사용자 추가
|
||||
</h4>
|
||||
<NuxtLink to="/admin/user" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>목록으로
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 폼 -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="saveUser">
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">이름 <span class="text-danger">*</span></label>
|
||||
<div class="col-9">
|
||||
<input type="text" class="form-control" v-model="form.employeeName" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">이메일 <span class="text-danger">*</span></label>
|
||||
<div class="col-9">
|
||||
<input type="email" class="form-control" v-model="form.employeeEmail" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">소속사</label>
|
||||
<div class="col-9">
|
||||
<select class="form-select" v-model="form.company">
|
||||
<option value="">(선택)</option>
|
||||
<option value="(주)터보소프트">(주)터보소프트</option>
|
||||
<option value="(주)코쿤">(주)코쿤</option>
|
||||
<option value="(주)오솔정보기술">(주)오솔정보기술</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">직급</label>
|
||||
<div class="col-9">
|
||||
<select class="form-select" v-model="form.employeePosition">
|
||||
<option value="">선택</option>
|
||||
<optgroup label="일반">
|
||||
<option value="인턴">인턴</option>
|
||||
<option value="사원">사원</option>
|
||||
<option value="주임">주임</option>
|
||||
<option value="대리">대리</option>
|
||||
<option value="과장">과장</option>
|
||||
<option value="차장">차장</option>
|
||||
<option value="부장">부장</option>
|
||||
</optgroup>
|
||||
<optgroup label="연구소">
|
||||
<option value="연구원">연구원</option>
|
||||
<option value="주임연구원">주임연구원</option>
|
||||
<option value="선임연구원">선임연구원</option>
|
||||
<option value="책임연구원">책임연구원</option>
|
||||
<option value="수석연구원">수석연구원</option>
|
||||
<option value="연구소장">연구소장</option>
|
||||
</optgroup>
|
||||
<optgroup label="임원">
|
||||
<option value="이사">이사</option>
|
||||
<option value="상무이사">상무이사</option>
|
||||
<option value="전무이사">전무이사</option>
|
||||
<option value="부사장">부사장</option>
|
||||
<option value="사장">사장</option>
|
||||
<option value="대표이사">대표이사</option>
|
||||
</optgroup>
|
||||
<optgroup label="기타">
|
||||
<option value="팀장">팀장</option>
|
||||
<option value="실장">실장</option>
|
||||
<option value="본부장">본부장</option>
|
||||
<option value="고문">고문</option>
|
||||
<option value="감사">감사</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">연락처</label>
|
||||
<div class="col-9">
|
||||
<input type="tel" class="form-control" v-model="form.employeePhone" placeholder="010-0000-0000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-3 col-form-label">입사일</label>
|
||||
<div class="col-9">
|
||||
<input type="date" class="form-control" v-model="form.joinDate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<NuxtLink to="/admin/user" class="btn btn-secondary me-2">취소</NuxtLink>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!canSave || isSaving">
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1"></span>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser, hasMenuAccess } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const form = ref({
|
||||
employeeName: '',
|
||||
employeeEmail: '',
|
||||
company: '(주)터보소프트',
|
||||
employeePosition: '',
|
||||
employeePhone: '',
|
||||
joinDate: ''
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return form.value.employeeName.trim() && form.value.employeeEmail.trim()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasMenuAccess('ADMIN_USER')) {
|
||||
alert('접근 권한이 없습니다.')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
async function saveUser() {
|
||||
if (!canSave.value) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await $fetch('/api/employee/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
employeeName: form.value.employeeName,
|
||||
employeeEmail: form.value.employeeEmail,
|
||||
company: form.value.company || null,
|
||||
employeePosition: form.value.employeePosition || null,
|
||||
employeePhone: form.value.employeePhone || null,
|
||||
joinDate: form.value.joinDate || null
|
||||
}
|
||||
})
|
||||
|
||||
alert('사용자가 등록되었습니다.')
|
||||
router.push('/admin/user')
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert(e.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
352
frontend/admin/user/index.vue
Normal file
352
frontend/admin/user/index.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user