권한, 사용자, 메뉴 등에 대한 기능 업데이트

This commit is contained in:
2026-01-10 16:54:06 +09:00
parent 134a68d9db
commit ef7914d5c6
34 changed files with 2678 additions and 650 deletions

View File

@@ -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) {

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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')

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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)