권한, 사용자, 메뉴 등에 대한 기능 업데이트
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>
|
||||
231
frontend/components/common/RoleManageModal.vue
Normal file
231
frontend/components/common/RoleManageModal.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-shield-lock me-2"></i>권한관리
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" @click="$emit('close')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- 상단 버튼 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>권한 목록 총 <strong>{{ roles.length }}</strong>건</span>
|
||||
<div>
|
||||
<button class="btn btn-primary btn-sm me-2" @click="showAddForm = true" v-if="!showAddForm">
|
||||
<i class="bi bi-plus-lg me-1"></i>신규
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
@click="deleteSelected"
|
||||
:disabled="selectedIds.length === 0"
|
||||
>
|
||||
<i class="bi bi-trash me-1"></i>선택 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 신규 추가 폼 -->
|
||||
<div v-if="showAddForm" class="card mb-3 border-primary">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">권한코드 *</label>
|
||||
<input type="text" class="form-control form-control-sm" v-model="newRole.roleCode" placeholder="ROLE_XXX" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">권한명 *</label>
|
||||
<input type="text" class="form-control form-control-sm" v-model="newRole.roleName" placeholder="권한명" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-primary btn-sm me-1" @click="createRole" :disabled="isCreating">
|
||||
<span v-if="isCreating" class="spinner-border spinner-border-sm me-1"></span>
|
||||
저장
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="cancelAdd">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 권한 목록 테이블 -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 40px" class="text-center">
|
||||
<input type="checkbox" class="form-check-input" v-model="selectAll" @change="toggleSelectAll" />
|
||||
</th>
|
||||
<th style="width: 50px" class="text-center">No</th>
|
||||
<th style="width: 180px">권한코드</th>
|
||||
<th>권한명</th>
|
||||
<th style="width: 80px" class="text-center">사용자 수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="5" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="(role, idx) in roles" :key="role.role_id">
|
||||
<td class="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
:value="role.role_id"
|
||||
v-model="selectedIds"
|
||||
:disabled="isProtectedRole(role.role_code)"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-center">{{ idx + 1 }}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm border-0 bg-transparent"
|
||||
v-model="role.role_code"
|
||||
:disabled="isProtectedRole(role.role_code)"
|
||||
@blur="updateRole(role)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm border-0 bg-transparent"
|
||||
v-model="role.role_name"
|
||||
@blur="updateRole(role)"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-center">{{ role.user_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isCreating = ref(false)
|
||||
const roles = ref<any[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
const selectAll = ref(false)
|
||||
const showAddForm = ref(false)
|
||||
|
||||
const newRole = ref({
|
||||
roleCode: '',
|
||||
roleName: ''
|
||||
})
|
||||
|
||||
const protectedRoles = ['ROLE_ADMIN', 'ROLE_MANAGER', 'ROLE_USER']
|
||||
|
||||
function isProtectedRole(code: string): boolean {
|
||||
return protectedRoles.includes(code)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
})
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await $fetch<any>('/api/admin/role/list')
|
||||
roles.value = response.roles || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('권한 목록을 불러오는데 실패했습니다.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectAll.value) {
|
||||
selectedIds.value = roles.value
|
||||
.filter(r => !isProtectedRole(r.role_code))
|
||||
.map(r => r.role_id)
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
showAddForm.value = false
|
||||
newRole.value = { roleCode: '', roleName: '' }
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
if (!newRole.value.roleCode || !newRole.value.roleName) {
|
||||
alert('권한코드와 권한명은 필수입니다.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isCreating.value = true
|
||||
await $fetch('/api/admin/role/create', {
|
||||
method: 'POST',
|
||||
body: newRole.value
|
||||
})
|
||||
cancelAdd()
|
||||
await loadRoles()
|
||||
emit('updated')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '권한 생성에 실패했습니다.')
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRole(role: any) {
|
||||
try {
|
||||
await $fetch(`/api/admin/role/${role.role_id}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
roleName: role.role_name
|
||||
}
|
||||
})
|
||||
emit('updated')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '권한 수정에 실패했습니다.')
|
||||
await loadRoles()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (selectedIds.value.length === 0) return
|
||||
|
||||
if (!confirm(`선택한 ${selectedIds.value.length}개의 권한을 삭제하시겠습니까?`)) return
|
||||
|
||||
try {
|
||||
for (const id of selectedIds.value) {
|
||||
await $fetch(`/api/admin/role/${id}/delete`, { method: 'DELETE' })
|
||||
}
|
||||
selectedIds.value = []
|
||||
selectAll.value = false
|
||||
await loadRoles()
|
||||
emit('updated')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || '권한 삭제에 실패했습니다.')
|
||||
await loadRoles()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-check-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-control:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -12,49 +12,31 @@
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<NuxtLink class="nav-link" to="/" active-class="active">
|
||||
<i class="bi bi-house me-1"></i> 대시보드
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NuxtLink class="nav-link" to="/report/weekly" active-class="active">
|
||||
<i class="bi bi-journal-text me-1"></i> 주간보고
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NuxtLink class="nav-link" to="/report/summary" active-class="active">
|
||||
<i class="bi bi-collection me-1"></i> 취합보고
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NuxtLink class="nav-link" to="/project" active-class="active">
|
||||
<i class="bi bi-folder me-1"></i> 프로젝트
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NuxtLink class="nav-link" to="/employee" active-class="active">
|
||||
<i class="bi bi-people me-1"></i> 직원관리
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NuxtLink class="nav-link" to="/feedback" active-class="active">
|
||||
<i class="bi bi-lightbulb me-1"></i> 개선의견
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<!-- 관리자 메뉴 (coziny@gmail.com 전용) -->
|
||||
<li class="nav-item dropdown" v-if="isAdmin">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-gear me-1"></i> 관리자
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<NuxtLink class="dropdown-item" to="/admin/bulk-import">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>주간보고 일괄등록
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<!-- 동적 메뉴 렌더링 -->
|
||||
<template v-for="menu in userMenus" :key="menu.menuId">
|
||||
<!-- 자식이 없는 메뉴 -->
|
||||
<li class="nav-item" v-if="!menu.children || menu.children.length === 0">
|
||||
<NuxtLink class="nav-link" :to="menu.menuPath || '/'" active-class="active">
|
||||
<i :class="['bi', menu.menuIcon, 'me-1']" v-if="menu.menuIcon"></i>
|
||||
{{ menu.menuName }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<!-- 자식이 있는 메뉴 (드롭다운) -->
|
||||
<li class="nav-item dropdown" v-else>
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i :class="['bi', menu.menuIcon, 'me-1']" v-if="menu.menuIcon"></i>
|
||||
{{ menu.menuName }}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li v-for="child in menu.children" :key="child.menuId">
|
||||
<NuxtLink class="dropdown-item" :to="child.menuPath || '/'">
|
||||
<i :class="['bi', child.menuIcon, 'me-2']" v-if="child.menuIcon"></i>
|
||||
{{ child.menuName }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<!-- 사용자 정보 -->
|
||||
@@ -73,11 +55,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { currentUser, logout } = useAuth()
|
||||
const { currentUser, userMenus, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
router.push('/login')
|
||||
|
||||
@@ -7,10 +7,23 @@ interface User {
|
||||
employeeName: string
|
||||
employeeEmail: string
|
||||
employeePosition: string | null
|
||||
roles?: string[] // 권한 코드 배열 추가
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
menuId: number
|
||||
menuCode: string
|
||||
menuName: string
|
||||
menuPath: string | null
|
||||
menuIcon: string | null
|
||||
parentMenuId: number | null
|
||||
sortOrder: number
|
||||
children: MenuItem[]
|
||||
}
|
||||
|
||||
// 전역 상태
|
||||
const currentUser = ref<User | null>(null)
|
||||
const userMenus = ref<MenuItem[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
export function useAuth() {
|
||||
@@ -22,15 +35,37 @@ export function useAuth() {
|
||||
isLoading.value = true
|
||||
const response = await $fetch<{ user: User | null }>('/api/auth/current-user')
|
||||
currentUser.value = response.user
|
||||
|
||||
// 로그인된 경우 메뉴 정보도 가져오기
|
||||
if (response.user) {
|
||||
await fetchUserMenus()
|
||||
} else {
|
||||
userMenus.value = []
|
||||
}
|
||||
|
||||
return response.user
|
||||
} catch (error) {
|
||||
currentUser.value = null
|
||||
userMenus.value = []
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 메뉴 조회
|
||||
*/
|
||||
async function fetchUserMenus(): Promise<void> {
|
||||
try {
|
||||
const response = await $fetch<{ menus: MenuItem[] }>('/api/auth/menu')
|
||||
userMenus.value = response.menus
|
||||
} catch (error) {
|
||||
console.error('메뉴 조회 실패:', error)
|
||||
userMenus.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일+이름으로 로그인
|
||||
*/
|
||||
@@ -40,6 +75,7 @@ export function useAuth() {
|
||||
body: { email, name }
|
||||
})
|
||||
currentUser.value = response.user
|
||||
await fetchUserMenus()
|
||||
return response.user
|
||||
}
|
||||
|
||||
@@ -52,6 +88,7 @@ export function useAuth() {
|
||||
body: { employeeId }
|
||||
})
|
||||
currentUser.value = response.user
|
||||
await fetchUserMenus()
|
||||
return response.user
|
||||
}
|
||||
|
||||
@@ -69,6 +106,7 @@ export function useAuth() {
|
||||
async function logout(): Promise<void> {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
currentUser.value = null
|
||||
userMenus.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,14 +114,66 @@ export function useAuth() {
|
||||
*/
|
||||
const isLoggedIn = computed(() => currentUser.value !== null)
|
||||
|
||||
/**
|
||||
* 특정 권한 보유 여부 확인
|
||||
*/
|
||||
function hasRole(roleCode: string): boolean {
|
||||
return currentUser.value?.roles?.includes(roleCode) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메뉴 접근 가능 여부 확인
|
||||
*/
|
||||
function hasMenuAccess(menuCode: string): boolean {
|
||||
const findMenu = (menus: MenuItem[]): boolean => {
|
||||
for (const menu of menus) {
|
||||
if (menu.menuCode === menuCode) return true
|
||||
if (menu.children && findMenu(menu.children)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return findMenu(userMenus.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 경로 접근 가능 여부 확인
|
||||
*/
|
||||
function hasPathAccess(path: string): boolean {
|
||||
const findPath = (menus: MenuItem[]): boolean => {
|
||||
for (const menu of menus) {
|
||||
if (menu.menuPath && path.startsWith(menu.menuPath)) return true
|
||||
if (menu.children && findPath(menu.children)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return findPath(userMenus.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 여부 (ROLE_ADMIN)
|
||||
*/
|
||||
const isAdmin = computed(() => hasRole('ROLE_ADMIN'))
|
||||
|
||||
/**
|
||||
* 매니저 이상 여부 (ROLE_MANAGER 또는 ROLE_ADMIN)
|
||||
*/
|
||||
const isManager = computed(() => hasRole('ROLE_MANAGER') || hasRole('ROLE_ADMIN'))
|
||||
|
||||
return {
|
||||
currentUser: readonly(currentUser),
|
||||
userMenus: readonly(userMenus),
|
||||
isLoading: readonly(isLoading),
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
isManager,
|
||||
fetchCurrentUser,
|
||||
fetchUserMenus,
|
||||
login,
|
||||
selectUser,
|
||||
getRecentUsers,
|
||||
logout
|
||||
logout,
|
||||
hasRole,
|
||||
hasMenuAccess,
|
||||
hasPathAccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="mb-4">
|
||||
<NuxtLink to="/employee" class="text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i> 목록으로
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="employee">
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-person me-2"></i>직원 정보
|
||||
</h5>
|
||||
<span :class="employee.isActive ? 'badge bg-success' : 'badge bg-secondary'">
|
||||
{{ employee.isActive ? '재직' : '퇴직' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="updateEmployee">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이름 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.employeeName" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이메일 <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" v-model="form.employeeEmail" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">소속사</label>
|
||||
<select class="form-select" v-model="form.company">
|
||||
<option value="(주)터보소프트">(주)터보소프트</option>
|
||||
<option value="(주)코쿤">(주)코쿤</option>
|
||||
<option value="(주)오솔정보기술">(주)오솔정보기술</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">직급</label>
|
||||
<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>
|
||||
</optgroup>
|
||||
<optgroup label="연구소">
|
||||
<option value="연구원">연구원</option>
|
||||
<option value="주임연구원">주임연구원</option>
|
||||
<option value="선임연구원">선임연구원</option>
|
||||
<option value="책임연구원">책임연구원</option>
|
||||
<option value="수석연구원">수석연구원</option>
|
||||
<option value="소장">소장</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">연락처</label>
|
||||
<input type="text" class="form-control" v-model="form.employeePhone" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">입사일</label>
|
||||
<input type="date" class="form-control" v-model="form.joinDate" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="isActive" v-model="form.isActive" />
|
||||
<label class="form-check-label" for="isActive">재직중</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
|
||||
<i class="bi bi-save me-1"></i> 저장
|
||||
</button>
|
||||
<NuxtLink to="/employee" class="btn btn-outline-secondary">취소</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<!-- 기본 활동 정보 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>기본 정보
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<small class="text-muted d-block">등록일</small>
|
||||
<span>{{ formatDateTime(employee.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<small class="text-muted d-block">최종 수정</small>
|
||||
<span>{{ formatDateTime(employee.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 이력 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i>로그인 이력
|
||||
<small class="text-muted ms-2">(최근 20건)</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>로그인 시간</th>
|
||||
<th>IP</th>
|
||||
<th>로그아웃</th>
|
||||
<th style="width: 80px">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loginHistory.length === 0">
|
||||
<td colspan="4" class="text-center text-muted py-3">로그인 이력이 없습니다.</td>
|
||||
</tr>
|
||||
<tr v-for="h in loginHistory" :key="h.historyId">
|
||||
<td>{{ formatDateTime(h.loginAt) }}</td>
|
||||
<td><code class="small">{{ h.loginIp || '-' }}</code></td>
|
||||
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
|
||||
<td>
|
||||
<span v-if="h.logoutAt" class="badge bg-secondary">로그아웃</span>
|
||||
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
|
||||
<span v-else class="badge bg-warning text-dark">세션만료</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-5" v-else-if="isLoading">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const employee = ref<any>(null)
|
||||
const loginHistory = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
employeeName: '',
|
||||
employeeEmail: '',
|
||||
company: '(주)터보소프트',
|
||||
employeePosition: '',
|
||||
employeePhone: '',
|
||||
joinDate: '',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await loadEmployee()
|
||||
})
|
||||
|
||||
async function loadEmployee() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ employee: any, loginHistory: any[] }>(`/api/employee/${route.params.id}/detail`)
|
||||
employee.value = res.employee
|
||||
loginHistory.value = res.loginHistory || []
|
||||
|
||||
const e = res.employee
|
||||
form.value = {
|
||||
employeeName: e.employeeName || '',
|
||||
employeeEmail: e.employeeEmail || '',
|
||||
company: e.company || '(주)터보소프트',
|
||||
employeePosition: e.employeePosition || '',
|
||||
employeePhone: e.employeePhone || '',
|
||||
joinDate: e.joinDate ? e.joinDate.split('T')[0] : '',
|
||||
isActive: e.isActive
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert('직원 정보를 불러오는데 실패했습니다.')
|
||||
router.push('/employee')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEmployee() {
|
||||
if (!form.value.employeeName || !form.value.employeeEmail) {
|
||||
alert('이름과 이메일은 필수입니다.')
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await $fetch(`/api/employee/${route.params.id}/update`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...form.value,
|
||||
joinDate: form.value.joinDate || null,
|
||||
employeePhone: form.value.employeePhone || null,
|
||||
employeePosition: form.value.employeePosition || null
|
||||
}
|
||||
})
|
||||
alert('저장되었습니다.')
|
||||
router.push('/employee')
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString('ko-KR')
|
||||
}
|
||||
</script>
|
||||
@@ -1,320 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4><i class="bi bi-people me-2"></i>직원 관리</h4>
|
||||
<p class="text-muted mb-0">총 {{ employees.length }}명</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">
|
||||
<i class="bi bi-plus-lg me-1"></i> 직원 등록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="searchKeyword"
|
||||
placeholder="이름 또는 이메일 검색"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" v-model="filterStatus">
|
||||
<option value="">전체</option>
|
||||
<option value="active">재직</option>
|
||||
<option value="inactive">퇴직</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사원 목록 -->
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>이메일</th>
|
||||
<th style="width: 150px">소속사</th>
|
||||
<th style="width: 120px">직급</th>
|
||||
<th style="width: 100px">상태</th>
|
||||
<th style="width: 120px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="6" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else v-for="emp in filteredEmployees" :key="emp.employeeId">
|
||||
<td><strong>{{ emp.employeeName }}</strong></td>
|
||||
<td>{{ emp.employeeEmail }}</td>
|
||||
<td>{{ emp.company || '-' }}</td>
|
||||
<td>{{ emp.employeePosition || '-' }}</td>
|
||||
<td>
|
||||
<span :class="emp.isActive !== false ? 'badge bg-success' : 'badge bg-secondary'">
|
||||
{{ emp.isActive !== false ? '재직' : '퇴직' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
:to="`/employee/${emp.employeeId}`"
|
||||
class="btn btn-sm btn-outline-primary me-1"
|
||||
title="상세보기"
|
||||
>
|
||||
<i class="bi bi-eye"></i>
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
@click.stop="confirmDelete(emp)"
|
||||
title="삭제"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!isLoading && filteredEmployees.length === 0">
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">직원 정보가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사원 등록 모달 -->
|
||||
<div class="modal fade" :class="{ show: showCreateModal }" :style="{ display: showCreateModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">직원 등록</h5>
|
||||
<button type="button" class="btn-close" @click="showCreateModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이름 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="newEmployee.employeeName" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">이메일 <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" v-model="newEmployee.employeeEmail" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">소속사</label>
|
||||
<select class="form-select" v-model="newEmployee.company">
|
||||
<option value="(주)터보소프트">(주)터보소프트</option>
|
||||
<option value="(주)코쿤">(주)코쿤</option>
|
||||
<option value="(주)오솔정보기술">(주)오솔정보기술</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">직급</label>
|
||||
<select class="form-select" v-model="newEmployee.employeePosition">
|
||||
<option value="">선택</option>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">연락처</label>
|
||||
<input type="text" class="form-control" v-model="newEmployee.employeePhone" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">입사일</label>
|
||||
<input type="date" class="form-control" v-model="newEmployee.joinDate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="createEmployee">
|
||||
<i class="bi bi-check-lg me-1"></i> 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showCreateModal"></div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div class="modal fade" :class="{ show: showDeleteModal }" :style="{ display: showDeleteModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title text-danger">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>직원 삭제
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @click="showDeleteModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
<strong>{{ deleteTarget?.employeeName }}</strong> ({{ deleteTarget?.employeeEmail }}) 님을 삭제하시겠습니까?
|
||||
</p>
|
||||
<div class="alert alert-warning small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
주간보고가 있는 경우 비활성화 처리됩니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">취소</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteEmployee" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-trash me-1"></i>삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showDeleteModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const employees = ref<any[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const filterStatus = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const deleteTarget = ref<any>(null)
|
||||
const isLoading = ref(true)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
const newEmployee = ref({
|
||||
employeeName: '',
|
||||
employeeEmail: '',
|
||||
company: '(주)터보소프트',
|
||||
employeePosition: '',
|
||||
employeePhone: '',
|
||||
joinDate: ''
|
||||
})
|
||||
|
||||
// 검색어/필터로 자동 필터링
|
||||
const filteredEmployees = computed(() => {
|
||||
let list = employees.value
|
||||
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
list = list.filter(e =>
|
||||
e.employeeName?.toLowerCase().includes(keyword) ||
|
||||
e.employeeEmail?.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterStatus.value === 'active') {
|
||||
list = list.filter(e => e.isActive !== false)
|
||||
} else if (filterStatus.value === 'inactive') {
|
||||
list = list.filter(e => e.isActive === false)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await loadEmployees()
|
||||
})
|
||||
|
||||
async function loadEmployees() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ employees: any[] }>('/api/employee/list')
|
||||
employees.value = res.employees || []
|
||||
} catch (e) {
|
||||
console.error('Load employees error:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmployee() {
|
||||
if (!newEmployee.value.employeeName || !newEmployee.value.employeeEmail) {
|
||||
alert('이름과 이메일은 필수입니다.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch('/api/employee/create', {
|
||||
method: 'POST',
|
||||
body: newEmployee.value
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newEmployee.value = {
|
||||
employeeName: '',
|
||||
employeeEmail: '',
|
||||
company: '(주)터보소프트',
|
||||
employeePosition: '',
|
||||
employeePhone: '',
|
||||
joinDate: ''
|
||||
}
|
||||
await loadEmployees()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '등록에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(emp: any) {
|
||||
deleteTarget.value = emp
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteEmployee() {
|
||||
if (!deleteTarget.value) return
|
||||
|
||||
isDeleting.value = true
|
||||
try {
|
||||
const res = await $fetch<{ success: boolean; action: string; message: string }>(
|
||||
`/api/employee/${deleteTarget.value.employeeId}/delete`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
alert(res.message)
|
||||
showDeleteModal.value = false
|
||||
deleteTarget.value = null
|
||||
await loadEmployees()
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '삭제에 실패했습니다.')
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,21 @@
|
||||
<div class="col-3 text-muted">입사일</div>
|
||||
<div class="col-9">{{ userInfo.joinDate ? userInfo.joinDate.split('T')[0] : '-' }}</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<div class="row mb-2">
|
||||
<div class="col-3 text-muted small">최초입력</div>
|
||||
<div class="col-9 small text-muted">
|
||||
{{ formatDateTime(userInfo.createdAt) }}
|
||||
<code v-if="userInfo.createdIp" class="ms-2">{{ userInfo.createdIp }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-3 text-muted small">최종수정</div>
|
||||
<div class="col-9 small text-muted">
|
||||
{{ formatDateTime(userInfo.updatedAt) }}
|
||||
<code v-if="userInfo.updatedIp" class="ms-2">{{ userInfo.updatedIp }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 모드 -->
|
||||
@@ -77,12 +92,13 @@
|
||||
<select class="form-select" v-model="editForm.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>
|
||||
<option value="이사">이사</option>
|
||||
</optgroup>
|
||||
<optgroup label="연구소">
|
||||
<option value="연구원">연구원</option>
|
||||
@@ -90,7 +106,22 @@
|
||||
<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>
|
||||
@@ -255,13 +286,12 @@ async function saveProfile() {
|
||||
function formatDateTime(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
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>
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const { fetchCurrentUser, hasMenuAccess } = useAuth()
|
||||
const { getCurrentWeekInfo, getWeekDates } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -221,6 +221,12 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasMenuAccess('REPORT_SUMMARY')) {
|
||||
alert('접근 권한이 없습니다.')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
await loadWeeklyList()
|
||||
})
|
||||
|
||||
|
||||
@@ -690,7 +690,7 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const { currentUser, fetchCurrentUser } = useAuth()
|
||||
const { currentUser, fetchCurrentUser, hasRole } = useAuth()
|
||||
const { getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -721,7 +721,7 @@ const aiIsDragging = ref(false)
|
||||
const isAiParsing = ref(false)
|
||||
const aiParsedResult = ref<any>(null)
|
||||
|
||||
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
|
||||
const isAdmin = computed(() => hasRole('ROLE_ADMIN'))
|
||||
|
||||
interface EditTask {
|
||||
projectId: number
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const { fetchCurrentUser, hasRole } = useAuth()
|
||||
const { getCurrentWeekInfo, getActualCurrentWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -147,7 +147,7 @@ const reports = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const projects = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isAdmin = ref(false)
|
||||
const isAdmin = computed(() => hasRole('ROLE_ADMIN'))
|
||||
|
||||
const currentWeek = getCurrentWeekInfo()
|
||||
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
|
||||
@@ -214,8 +214,6 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin.value = user.employeeEmail === 'coziny@gmail.com'
|
||||
|
||||
// URL 쿼리 파라미터가 있으면 필터에 적용
|
||||
if (route.query.year && route.query.week) {
|
||||
filters.value.year = parseInt(route.query.year as string)
|
||||
|
||||
Reference in New Issue
Block a user