권한, 사용자, 메뉴 등에 대한 기능 업데이트
This commit is contained in:
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -2,5 +2,6 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
44
backend/api/admin/menu/[id]/toggle-role.post.ts
Normal file
44
backend/api/admin/menu/[id]/toggle-role.post.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { queryOne, execute } from '../../../../utils/db'
|
||||
import { requireAdmin } from '../../../../utils/session'
|
||||
|
||||
/**
|
||||
* 메뉴 권한 토글
|
||||
* POST /api/admin/menu/[id]/toggle-role
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const menuId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { roleId, enabled } = body
|
||||
|
||||
if (!roleId) {
|
||||
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 메뉴 존재 확인
|
||||
const menu = await queryOne<any>(`
|
||||
SELECT menu_id FROM wr_menu WHERE menu_id = $1
|
||||
`, [menuId])
|
||||
|
||||
if (!menu) {
|
||||
throw createError({ statusCode: 404, message: '메뉴를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// 권한 추가
|
||||
await execute(`
|
||||
INSERT INTO wr_menu_role (menu_id, role_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (menu_id, role_id) DO NOTHING
|
||||
`, [menuId, roleId])
|
||||
} else {
|
||||
// 권한 제거
|
||||
await execute(`
|
||||
DELETE FROM wr_menu_role
|
||||
WHERE menu_id = $1 AND role_id = $2
|
||||
`, [menuId, roleId])
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
73
backend/api/admin/menu/list.get.ts
Normal file
73
backend/api/admin/menu/list.get.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { requireAdmin } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 (권한 포함)
|
||||
* GET /api/admin/menu/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
// 메뉴 목록 조회
|
||||
const menus = await query<any>(`
|
||||
SELECT
|
||||
m.menu_id,
|
||||
m.menu_code,
|
||||
m.menu_name,
|
||||
m.menu_path,
|
||||
m.menu_icon,
|
||||
m.parent_menu_id,
|
||||
m.sort_order,
|
||||
m.is_active,
|
||||
m.created_at,
|
||||
m.updated_at,
|
||||
pm.menu_name AS parent_menu_name
|
||||
FROM wr_menu m
|
||||
LEFT JOIN wr_menu pm ON m.parent_menu_id = pm.menu_id
|
||||
ORDER BY m.parent_menu_id NULLS FIRST, m.sort_order
|
||||
`)
|
||||
|
||||
// 권한 목록 조회
|
||||
const roles = await query<any>(`
|
||||
SELECT role_id, role_code, role_name
|
||||
FROM wr_role
|
||||
ORDER BY role_id
|
||||
`)
|
||||
|
||||
// 메뉴-권한 매핑 조회
|
||||
const menuRoles = await query<any>(`
|
||||
SELECT menu_id, role_id
|
||||
FROM wr_menu_role
|
||||
`)
|
||||
|
||||
// 메뉴별 권한 매핑 정리
|
||||
const menuRoleMap: Record<number, number[]> = {}
|
||||
for (const mr of menuRoles) {
|
||||
if (!menuRoleMap[mr.menu_id]) {
|
||||
menuRoleMap[mr.menu_id] = []
|
||||
}
|
||||
menuRoleMap[mr.menu_id].push(mr.role_id)
|
||||
}
|
||||
|
||||
return {
|
||||
menus: menus.map(m => ({
|
||||
menuId: m.menu_id,
|
||||
menuCode: m.menu_code,
|
||||
menuName: m.menu_name,
|
||||
menuPath: m.menu_path,
|
||||
menuIcon: m.menu_icon,
|
||||
parentMenuId: m.parent_menu_id,
|
||||
parentMenuName: m.parent_menu_name,
|
||||
sortOrder: m.sort_order,
|
||||
isActive: m.is_active,
|
||||
createdAt: m.created_at,
|
||||
updatedAt: m.updated_at,
|
||||
roleIds: menuRoleMap[m.menu_id] || []
|
||||
})),
|
||||
roles: roles.map((r: any) => ({
|
||||
roleId: r.role_id,
|
||||
roleCode: r.role_code,
|
||||
roleName: r.role_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
46
backend/api/admin/role/[id]/delete.delete.ts
Normal file
46
backend/api/admin/role/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { queryOne, execute } from '../../../../utils/db'
|
||||
import { requireAdmin } from '../../../../utils/session'
|
||||
|
||||
/**
|
||||
* 권한 삭제
|
||||
* DELETE /api/admin/role/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const roleId = getRouterParam(event, 'id')
|
||||
if (!roleId) {
|
||||
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 존재 여부 확인
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT role_id, role_code FROM wr_role WHERE role_id = $1
|
||||
`, [roleId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '권한을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 기본 권한은 삭제 불가
|
||||
const protectedCodes = ['ROLE_ADMIN', 'ROLE_MANAGER', 'ROLE_USER']
|
||||
if (protectedCodes.includes(existing.role_code)) {
|
||||
throw createError({ statusCode: 400, message: '기본 권한은 삭제할 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 사용 중인 권한인지 확인
|
||||
const usageCount = await queryOne<any>(`
|
||||
SELECT COUNT(*) as cnt FROM wr_employee_role WHERE role_id = $1
|
||||
`, [roleId])
|
||||
|
||||
if (parseInt(usageCount.cnt) > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `${usageCount.cnt}명의 사용자가 이 권한을 사용 중입니다. 먼저 권한을 해제해주세요.`
|
||||
})
|
||||
}
|
||||
|
||||
await execute(`DELETE FROM wr_role WHERE role_id = $1`, [roleId])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
54
backend/api/admin/role/[id]/update.put.ts
Normal file
54
backend/api/admin/role/[id]/update.put.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { queryOne, execute } from '../../../../utils/db'
|
||||
import { requireAdmin } from '../../../../utils/session'
|
||||
|
||||
/**
|
||||
* 권한 수정
|
||||
* PUT /api/admin/role/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const roleId = getRouterParam(event, 'id')
|
||||
if (!roleId) {
|
||||
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{
|
||||
roleName?: string
|
||||
roleDescription?: string
|
||||
isInternalIpOnly?: boolean
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
}>(event)
|
||||
|
||||
// 존재 여부 확인
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT role_id, role_code FROM wr_role WHERE role_id = $1
|
||||
`, [roleId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '권한을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_role SET
|
||||
role_name = COALESCE($2, role_name),
|
||||
role_description = COALESCE($3, role_description),
|
||||
is_internal_ip_only = COALESCE($4, is_internal_ip_only),
|
||||
sort_order = COALESCE($5, sort_order),
|
||||
is_active = COALESCE($6, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE role_id = $1
|
||||
`, [
|
||||
roleId,
|
||||
body.roleName,
|
||||
body.roleDescription,
|
||||
body.isInternalIpOnly,
|
||||
body.sortOrder,
|
||||
body.isActive
|
||||
])
|
||||
|
||||
const updated = await queryOne<any>(`SELECT * FROM wr_role WHERE role_id = $1`, [roleId])
|
||||
|
||||
return { success: true, role: updated }
|
||||
})
|
||||
45
backend/api/admin/role/create.post.ts
Normal file
45
backend/api/admin/role/create.post.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
import { requireAdmin } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 권한 생성
|
||||
* POST /api/admin/role/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const body = await readBody<{
|
||||
roleCode: string
|
||||
roleName: string
|
||||
roleDescription?: string
|
||||
isInternalIpOnly?: boolean
|
||||
sortOrder?: number
|
||||
}>(event)
|
||||
|
||||
if (!body.roleCode || !body.roleName) {
|
||||
throw createError({ statusCode: 400, message: '권한코드와 권한명은 필수입니다.' })
|
||||
}
|
||||
|
||||
// 코드 중복 체크
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT role_id FROM wr_role WHERE role_code = $1
|
||||
`, [body.roleCode])
|
||||
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 400, message: '이미 존재하는 권한코드입니다.' })
|
||||
}
|
||||
|
||||
const role = await queryOne<any>(`
|
||||
INSERT INTO wr_role (role_code, role_name, role_description, is_internal_ip_only, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.roleCode,
|
||||
body.roleName,
|
||||
body.roleDescription || null,
|
||||
body.isInternalIpOnly || false,
|
||||
body.sortOrder || 0
|
||||
])
|
||||
|
||||
return { success: true, role }
|
||||
})
|
||||
34
backend/api/admin/role/list.get.ts
Normal file
34
backend/api/admin/role/list.get.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { requireAdmin } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 권한 목록 조회
|
||||
* GET /api/admin/role/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
await requireAdmin(event)
|
||||
|
||||
const roles = await query<any>(`
|
||||
SELECT
|
||||
r.role_id,
|
||||
r.role_code,
|
||||
r.role_name,
|
||||
r.role_description,
|
||||
r.is_internal_ip_only,
|
||||
r.sort_order,
|
||||
r.is_active,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
COUNT(DISTINCT er.employee_id) as user_count
|
||||
FROM wr_role r
|
||||
LEFT JOIN wr_employee_role er ON r.role_id = er.role_id
|
||||
GROUP BY r.role_id
|
||||
ORDER BY r.sort_order, r.role_id
|
||||
`)
|
||||
|
||||
return {
|
||||
roles,
|
||||
total: roles.length
|
||||
}
|
||||
})
|
||||
55
backend/api/admin/user/[id]/roles.put.ts
Normal file
55
backend/api/admin/user/[id]/roles.put.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { query, queryOne, execute } from '../../../../utils/db'
|
||||
import { requireAdmin } from '../../../../utils/session'
|
||||
|
||||
/**
|
||||
* 사용자 권한 변경
|
||||
* PUT /api/admin/user/[id]/roles
|
||||
*
|
||||
* Body: { roleIds: number[] }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
if (!employeeId) {
|
||||
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ roleIds: number[] }>(event)
|
||||
const roleIds = body.roleIds || []
|
||||
|
||||
// 사용자 존재 확인
|
||||
const user = await queryOne<any>(`
|
||||
SELECT employee_id, employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 기존 권한 모두 삭제
|
||||
await execute(`DELETE FROM wr_employee_role WHERE employee_id = $1`, [employeeId])
|
||||
|
||||
// 새 권한 추가
|
||||
for (const roleId of roleIds) {
|
||||
await execute(`
|
||||
INSERT INTO wr_employee_role (employee_id, role_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (employee_id, role_id) DO NOTHING
|
||||
`, [employeeId, roleId])
|
||||
}
|
||||
|
||||
// 변경된 권한 조회
|
||||
const updatedRoles = await query<any>(`
|
||||
SELECT r.role_id, r.role_code, r.role_name
|
||||
FROM wr_employee_role er
|
||||
JOIN wr_role r ON er.role_id = r.role_id
|
||||
WHERE er.employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employeeId: parseInt(employeeId as string),
|
||||
roles: updatedRoles
|
||||
}
|
||||
})
|
||||
70
backend/api/admin/user/[id]/toggle-role.post.ts
Normal file
70
backend/api/admin/user/[id]/toggle-role.post.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { queryOne, execute } from '../../../../utils/db'
|
||||
import { requireAdmin } from '../../../../utils/session'
|
||||
|
||||
/**
|
||||
* 사용자 개별 권한 토글 (추가/제거)
|
||||
* POST /api/admin/user/[id]/toggle-role
|
||||
*
|
||||
* Body: { roleId: number }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
if (!employeeId) {
|
||||
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ roleId: number }>(event)
|
||||
if (!body.roleId) {
|
||||
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 사용자 존재 확인
|
||||
const user = await queryOne<any>(`
|
||||
SELECT employee_id FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 권한 존재 확인
|
||||
const role = await queryOne<any>(`
|
||||
SELECT role_id, role_code FROM wr_role WHERE role_id = $1
|
||||
`, [body.roleId])
|
||||
|
||||
if (!role) {
|
||||
throw createError({ statusCode: 404, message: '권한을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 현재 권한 보유 여부 확인
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT employee_role_id FROM wr_employee_role
|
||||
WHERE employee_id = $1 AND role_id = $2
|
||||
`, [employeeId, body.roleId])
|
||||
|
||||
let added: boolean
|
||||
|
||||
if (existing) {
|
||||
// 권한 제거
|
||||
await execute(`
|
||||
DELETE FROM wr_employee_role WHERE employee_id = $1 AND role_id = $2
|
||||
`, [employeeId, body.roleId])
|
||||
added = false
|
||||
} else {
|
||||
// 권한 추가
|
||||
await execute(`
|
||||
INSERT INTO wr_employee_role (employee_id, role_id) VALUES ($1, $2)
|
||||
`, [employeeId, body.roleId])
|
||||
added = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employeeId: parseInt(employeeId as string),
|
||||
roleId: body.roleId,
|
||||
roleCode: role.role_code,
|
||||
added
|
||||
}
|
||||
})
|
||||
110
backend/api/admin/user/list.get.ts
Normal file
110
backend/api/admin/user/list.get.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { requireAdmin } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 사용자 목록 조회 (권한 정보 + 최근 로그인 포함)
|
||||
* GET /api/admin/user/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const queryParams = getQuery(event)
|
||||
const company = queryParams.company as string || ''
|
||||
const name = queryParams.name as string || ''
|
||||
const email = queryParams.email as string || ''
|
||||
const phone = queryParams.phone as string || ''
|
||||
const status = queryParams.status as string || 'active' // 기본값: 활성
|
||||
|
||||
// 1. 사용자 목록 조회 (최근 로그인 포함)
|
||||
let userQuery = `
|
||||
SELECT
|
||||
e.employee_id,
|
||||
e.employee_name,
|
||||
e.employee_email,
|
||||
e.employee_phone,
|
||||
e.employee_position,
|
||||
e.company,
|
||||
e.join_date,
|
||||
e.is_active,
|
||||
e.created_at,
|
||||
(
|
||||
SELECT MAX(login_at)
|
||||
FROM wr_login_history
|
||||
WHERE employee_id = e.employee_id
|
||||
) as last_login_at
|
||||
FROM wr_employee_info e
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: any[] = []
|
||||
|
||||
// 소속사 검색
|
||||
if (company) {
|
||||
params.push(`%${company}%`)
|
||||
userQuery += ` AND e.company ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 이름 검색
|
||||
if (name) {
|
||||
params.push(`%${name}%`)
|
||||
userQuery += ` AND e.employee_name ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 이메일 검색
|
||||
if (email) {
|
||||
params.push(`%${email}%`)
|
||||
userQuery += ` AND e.employee_email ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 전화번호 검색
|
||||
if (phone) {
|
||||
params.push(`%${phone}%`)
|
||||
userQuery += ` AND e.employee_phone ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 상태 검색
|
||||
if (status === 'active') {
|
||||
userQuery += ` AND e.is_active = true`
|
||||
} else if (status === 'inactive') {
|
||||
userQuery += ` AND e.is_active = false`
|
||||
}
|
||||
// status === 'all' 이면 조건 없음
|
||||
|
||||
userQuery += ` ORDER BY e.company, e.employee_position, e.employee_name`
|
||||
|
||||
const users = await query<any>(userQuery, params)
|
||||
|
||||
// 2. 모든 권한 목록 조회
|
||||
const roles = await query<any>(`
|
||||
SELECT role_id, role_code, role_name, sort_order
|
||||
FROM wr_role
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order
|
||||
`)
|
||||
|
||||
// 3. 사용자별 권한 매핑 조회
|
||||
const userRoles = await query<any>(`
|
||||
SELECT employee_id, role_id
|
||||
FROM wr_employee_role
|
||||
`)
|
||||
|
||||
// 4. 사용자별 권한 배열 생성
|
||||
const userRoleMap = new Map<number, number[]>()
|
||||
for (const ur of userRoles) {
|
||||
if (!userRoleMap.has(ur.employee_id)) {
|
||||
userRoleMap.set(ur.employee_id, [])
|
||||
}
|
||||
userRoleMap.get(ur.employee_id)!.push(ur.role_id)
|
||||
}
|
||||
|
||||
// 5. 사용자 데이터에 권한 정보 추가
|
||||
const usersWithRoles = users.map(u => ({
|
||||
...u,
|
||||
roleIds: userRoleMap.get(u.employee_id) || []
|
||||
}))
|
||||
|
||||
return {
|
||||
users: usersWithRoles,
|
||||
roles,
|
||||
total: users.length
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getSession, refreshSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
|
||||
import { getDbSession, refreshSession, getSessionIdFromCookie, deleteSessionCookie, getUserRoles } from '../../utils/session'
|
||||
import { queryOne, execute, query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자 정보
|
||||
* 현재 로그인 사용자 정보 (권한 포함)
|
||||
* GET /api/auth/current-user
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -43,12 +44,16 @@ export default defineEventHandler(async (event) => {
|
||||
`, [session.loginHistoryId])
|
||||
}
|
||||
|
||||
// 사용자 권한 조회
|
||||
const roles = await getUserRoles(employee.employee_id)
|
||||
|
||||
return {
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePosition: employee.employee_position
|
||||
employeePosition: employee.employee_position,
|
||||
roles // 권한 코드 배열 추가
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,7 +28,11 @@ export default defineEventHandler(async (event) => {
|
||||
employee_position,
|
||||
company,
|
||||
join_date,
|
||||
is_active
|
||||
is_active,
|
||||
created_at,
|
||||
created_ip,
|
||||
updated_at,
|
||||
updated_ip
|
||||
FROM wr_employee_info
|
||||
WHERE employee_id = $1
|
||||
`, [session.employeeId])
|
||||
@@ -46,7 +50,11 @@ export default defineEventHandler(async (event) => {
|
||||
employeePosition: employee.employee_position,
|
||||
company: employee.company,
|
||||
joinDate: employee.join_date,
|
||||
isActive: employee.is_active
|
||||
isActive: employee.is_active,
|
||||
createdAt: employee.created_at,
|
||||
createdIp: employee.created_ip,
|
||||
updatedAt: employee.updated_at,
|
||||
updatedIp: employee.updated_ip
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
80
backend/api/auth/menu.get.ts
Normal file
80
backend/api/auth/menu.get.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { getDbSession, getSessionIdFromCookie } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 현재 사용자 접근 가능 메뉴 조회
|
||||
* GET /api/auth/menu
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const session = await getDbSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 401, message: '세션이 만료되었습니다.' })
|
||||
}
|
||||
|
||||
// 사용자의 권한 목록 조회
|
||||
const userRoles = await query<any>(`
|
||||
SELECT r.role_id, r.role_code
|
||||
FROM wr_employee_role er
|
||||
JOIN wr_role r ON er.role_id = r.role_id
|
||||
WHERE er.employee_id = $1
|
||||
`, [session.employeeId])
|
||||
|
||||
const roleIds = userRoles.map(r => r.role_id)
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return { menus: [] }
|
||||
}
|
||||
|
||||
// 접근 가능한 메뉴 조회
|
||||
const menus = await query<any>(`
|
||||
SELECT DISTINCT
|
||||
m.menu_id,
|
||||
m.menu_code,
|
||||
m.menu_name,
|
||||
m.menu_path,
|
||||
m.menu_icon,
|
||||
m.parent_menu_id,
|
||||
m.sort_order
|
||||
FROM wr_menu m
|
||||
JOIN wr_menu_role mr ON m.menu_id = mr.menu_id
|
||||
WHERE mr.role_id = ANY($1)
|
||||
AND m.is_active = true
|
||||
ORDER BY m.parent_menu_id NULLS FIRST, m.sort_order
|
||||
`, [roleIds])
|
||||
|
||||
// 계층 구조로 변환
|
||||
const menuMap = new Map<number, any>()
|
||||
const rootMenus: any[] = []
|
||||
|
||||
for (const m of menus) {
|
||||
const menuItem = {
|
||||
menuId: m.menu_id,
|
||||
menuCode: m.menu_code,
|
||||
menuName: m.menu_name,
|
||||
menuPath: m.menu_path,
|
||||
menuIcon: m.menu_icon,
|
||||
parentMenuId: m.parent_menu_id,
|
||||
sortOrder: m.sort_order,
|
||||
children: []
|
||||
}
|
||||
menuMap.set(m.menu_id, menuItem)
|
||||
}
|
||||
|
||||
for (const m of menus) {
|
||||
const menuItem = menuMap.get(m.menu_id)
|
||||
if (m.parent_menu_id && menuMap.has(m.parent_menu_id)) {
|
||||
menuMap.get(m.parent_menu_id).children.push(menuItem)
|
||||
} else if (!m.parent_menu_id) {
|
||||
rootMenus.push(menuItem)
|
||||
}
|
||||
}
|
||||
|
||||
return { menus: rootMenus }
|
||||
})
|
||||
@@ -1,25 +1,13 @@
|
||||
import { query, execute } from '../../../utils/db'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
import { requireAdmin } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 직원 삭제
|
||||
* DELETE /api/employee/[id]
|
||||
* DELETE /api/employee/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
// 관리자 권한 체크
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
|
||||
if (!currentUser[0] || currentUser[0].employee_email !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 삭제할 수 있습니다.' })
|
||||
}
|
||||
// 관리자 권한 체크 (role 기반)
|
||||
const currentUserId = await requireAdmin(event)
|
||||
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
if (!employeeId) {
|
||||
@@ -27,7 +15,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 본인 삭제 방지
|
||||
if (employeeId === userId) {
|
||||
if (parseInt(employeeId) === currentUserId) {
|
||||
throw createError({ statusCode: 400, message: '본인은 삭제할 수 없습니다.' })
|
||||
}
|
||||
|
||||
@@ -61,7 +49,9 @@ export default defineEventHandler(async (event) => {
|
||||
message: `${employee[0].employee_name}님이 비활성화되었습니다. (주간보고 ${reportCount}건 보존)`
|
||||
}
|
||||
} else {
|
||||
// 주간보고가 없으면 완전 삭제 (로그인 이력 포함)
|
||||
// 주간보고가 없으면 완전 삭제 (관련 데이터 포함)
|
||||
await execute(`DELETE FROM wr_employee_role WHERE employee_id = $1`, [employeeId])
|
||||
await execute(`DELETE FROM wr_session WHERE employee_id = $1`, [employeeId])
|
||||
await execute(`DELETE FROM wr_login_history WHERE employee_id = $1`, [employeeId])
|
||||
await execute(`DELETE FROM wr_employee_info WHERE employee_id = $1`, [employeeId])
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ export default defineEventHandler(async (event) => {
|
||||
joinDate: employee.join_date,
|
||||
isActive: employee.is_active,
|
||||
createdAt: employee.created_at,
|
||||
updatedAt: employee.updated_at
|
||||
createdIp: employee.created_ip,
|
||||
updatedAt: employee.updated_at,
|
||||
updatedIp: employee.updated_ip
|
||||
},
|
||||
loginHistory: loginHistory.map(h => ({
|
||||
historyId: h.history_id,
|
||||
|
||||
59
backend/sql/create_role_tables.sql
Normal file
59
backend/sql/create_role_tables.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- ============================================
|
||||
-- 권한 관리 시스템 테이블 생성
|
||||
-- 작성일: 2025-01-10
|
||||
-- ============================================
|
||||
|
||||
-- 1. 권한 마스터 테이블
|
||||
CREATE TABLE IF NOT EXISTS wr_role (
|
||||
role_id SERIAL PRIMARY KEY,
|
||||
role_code VARCHAR(50) NOT NULL UNIQUE, -- ROLE_ADMIN, ROLE_MANAGER, ROLE_USER
|
||||
role_name VARCHAR(100) NOT NULL, -- 관리자, 매니저, 일반사용자
|
||||
role_description TEXT, -- 권한 설명
|
||||
is_internal_ip_only BOOLEAN DEFAULT false, -- 내부IP 제한 여부
|
||||
sort_order INTEGER DEFAULT 0, -- 정렬 순서
|
||||
is_active BOOLEAN DEFAULT true, -- 활성화 여부
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. 사용자-권한 매핑 테이블
|
||||
CREATE TABLE IF NOT EXISTS wr_employee_role (
|
||||
employee_role_id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES wr_role(role_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(employee_id, role_id)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_employee_role_employee ON wr_employee_role(employee_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_employee_role_role ON wr_employee_role(role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_code ON wr_role(role_code);
|
||||
|
||||
-- ============================================
|
||||
-- 기본 권한 데이터 INSERT
|
||||
-- ============================================
|
||||
INSERT INTO wr_role (role_code, role_name, role_description, sort_order) VALUES
|
||||
('ROLE_ADMIN', '관리자', '시스템 전체 관리 권한', 1),
|
||||
('ROLE_MANAGER', '매니저', '취합보고서 등 관리 기능', 2),
|
||||
('ROLE_USER', '일반사용자', '기본 기능 (주간보고 작성/조회)', 3)
|
||||
ON CONFLICT (role_code) DO NOTHING;
|
||||
|
||||
-- ============================================
|
||||
-- 기존 관리자 계정에 ROLE_ADMIN 부여
|
||||
-- ============================================
|
||||
INSERT INTO wr_employee_role (employee_id, role_id)
|
||||
SELECT e.employee_id, r.role_id
|
||||
FROM wr_employee_info e, wr_role r
|
||||
WHERE e.employee_email = 'coziny@gmail.com'
|
||||
AND r.role_code = 'ROLE_ADMIN'
|
||||
ON CONFLICT (employee_id, role_id) DO NOTHING;
|
||||
|
||||
-- ============================================
|
||||
-- 확인 쿼리
|
||||
-- ============================================
|
||||
-- SELECT * FROM wr_role ORDER BY sort_order;
|
||||
-- SELECT e.employee_name, e.employee_email, r.role_code, r.role_name
|
||||
-- FROM wr_employee_info e
|
||||
-- JOIN wr_employee_role er ON e.employee_id = er.employee_id
|
||||
-- JOIN wr_role r ON er.role_id = r.role_id;
|
||||
@@ -196,3 +196,55 @@ export async function requireAuth(event: any): Promise<number> {
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 권한 조회
|
||||
*/
|
||||
export async function getUserRoles(employeeId: number): Promise<string[]> {
|
||||
const rows = await query<any>(`
|
||||
SELECT r.role_code
|
||||
FROM wr_employee_role er
|
||||
JOIN wr_role r ON er.role_id = r.role_id
|
||||
WHERE er.employee_id = $1 AND r.is_active = true
|
||||
`, [employeeId])
|
||||
|
||||
return rows.map(r => r.role_code)
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 권한 보유 여부 확인
|
||||
*/
|
||||
export async function hasRole(employeeId: number, roleCode: string): Promise<boolean> {
|
||||
const roles = await getUserRoles(employeeId)
|
||||
return roles.includes(roleCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한 필수 API용 - ROLE_ADMIN 없으면 에러 throw
|
||||
*/
|
||||
export async function requireAdmin(event: any): Promise<number> {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const isAdmin = await hasRole(userId, 'ROLE_ADMIN')
|
||||
if (!isAdmin) {
|
||||
throw createError({ statusCode: 403, message: '관리자 권한이 필요합니다.' })
|
||||
}
|
||||
|
||||
return userId
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 이상 권한 필수 API용 - ROLE_MANAGER 또는 ROLE_ADMIN 없으면 에러 throw
|
||||
*/
|
||||
export async function requireManager(event: any): Promise<number> {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const roles = await getUserRoles(userId)
|
||||
const hasManagerRole = roles.includes('ROLE_MANAGER') || roles.includes('ROLE_ADMIN')
|
||||
|
||||
if (!hasManagerRole) {
|
||||
throw createError({ statusCode: 403, message: '매니저 이상 권한이 필요합니다.' })
|
||||
}
|
||||
|
||||
return userId
|
||||
}
|
||||
|
||||
267
claude_temp/TASK_MENU_PERMISSION_20260110.md
Normal file
267
claude_temp/TASK_MENU_PERMISSION_20260110.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 메뉴 권한 관리 시스템 구축
|
||||
|
||||
**작업일자**: 2026-01-10
|
||||
**파일명**: TASK_MENU_PERMISSION_20260110.md
|
||||
|
||||
---
|
||||
|
||||
## 현황 분석
|
||||
|
||||
### 1. 현재 메뉴 구조
|
||||
|
||||
| 메뉴 | 경로 | 현재 권한 | 비고 |
|
||||
|------|------|-----------|------|
|
||||
| 대시보드 | / | 전체 | 로그인 필수 |
|
||||
| 주간보고 | /report/weekly | 전체 | 목록/상세 |
|
||||
| 주간보고 상세 | /report/weekly/[id] | 전체 | 수정/삭제는 본인+관리자 |
|
||||
| 취합보고 | /report/summary | 전체 | 취합하기 버튼 포함 |
|
||||
| 취합보고 상세 | /report/summary/[year]/[week] | 전체 | |
|
||||
| 프로젝트 | /project | 전체 | |
|
||||
| 프로젝트 상세 | /project/[id] | 전체 | |
|
||||
| 개선의견 | /feedback | 전체 | |
|
||||
| 마이페이지 | /mypage | 전체 | 본인만 |
|
||||
| **관리자 - 사용자관리** | /admin/user | ROLE_ADMIN | 목록/추가/수정 |
|
||||
| **관리자 - 일괄등록** | /admin/bulk-import | ROLE_ADMIN | |
|
||||
|
||||
### 2. 권한별 기능 제어 현황
|
||||
|
||||
| 기능 | 위치 | 권한 체크 | 설명 |
|
||||
|------|------|-----------|------|
|
||||
| 관리자 메뉴 표시 | AppHeader.vue | isAdmin | 드롭다운 메뉴 |
|
||||
| 사용자 관리 페이지 접근 | /admin/user/* | ROLE_ADMIN | 페이지 진입 시 체크 |
|
||||
| 일괄등록 페이지 접근 | /admin/bulk-import | isAdmin | 페이지 진입 시 체크 |
|
||||
| 주간보고 수정 | /report/weekly/[id] | isAdmin OR 본인 | canEdit computed |
|
||||
| 주간보고 삭제 | /report/weekly/[id] | isAdmin OR 본인 | canDelete computed |
|
||||
| 주간보고 목록 작성자 컬럼 | /report/weekly/index | isAdmin | 관리자만 보임 |
|
||||
| 취합보고 취합하기 | /report/summary | 없음 | ⚠️ 권한체크 없음 |
|
||||
|
||||
### 3. 권장 메뉴별 권한 설정
|
||||
|
||||
| 메뉴 | 관리자 | 매니저 | 일반사용자 | 비고 |
|
||||
|------|:------:|:------:|:----------:|------|
|
||||
| 대시보드 | ✓ | ✓ | ✓ | |
|
||||
| 주간보고 | ✓ | ✓ | ✓ | |
|
||||
| 취합보고 | ✓ | ✓ | ✗ | 매니저 이상만 |
|
||||
| 프로젝트 | ✓ | ✓ | ✓ | |
|
||||
| 개선의견 | ✓ | ✓ | ✓ | |
|
||||
| 마이페이지 | ✓ | ✓ | ✓ | |
|
||||
| 사용자관리 | ✓ | ✗ | ✗ | 관리자만 |
|
||||
| 일괄등록 | ✓ | ✗ | ✗ | 관리자만 |
|
||||
| **메뉴관리** | ✓ | ✗ | ✗ | **신규** |
|
||||
|
||||
---
|
||||
|
||||
## 작업 Phase
|
||||
|
||||
### Phase 1: DB 스키마 설계 및 생성
|
||||
- [x] 시작: 2026-01-10 16:45:00
|
||||
- [x] 완료: 2026-01-10 16:47:30
|
||||
- [x] 소요시간: 2분 30초
|
||||
|
||||
**작업 내용:**
|
||||
1. `wr_menu` 테이블 생성 (메뉴 마스터)
|
||||
2. `wr_menu_role` 테이블 생성 (메뉴-권한 매핑)
|
||||
3. 기존 메뉴 데이터 INSERT
|
||||
4. 기본 권한 매핑 INSERT
|
||||
|
||||
**테이블 설계:**
|
||||
```sql
|
||||
-- 메뉴 마스터
|
||||
CREATE TABLE wr_menu (
|
||||
menu_id SERIAL PRIMARY KEY,
|
||||
menu_code VARCHAR(50) NOT NULL UNIQUE, -- 메뉴 코드 (예: DASHBOARD)
|
||||
menu_name VARCHAR(100) NOT NULL, -- 메뉴명 (예: 대시보드)
|
||||
menu_path VARCHAR(200), -- 경로 (예: /)
|
||||
menu_icon VARCHAR(50), -- 아이콘 (예: bi-house)
|
||||
parent_menu_id INTEGER REFERENCES wr_menu(menu_id),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 메뉴-권한 매핑
|
||||
CREATE TABLE wr_menu_role (
|
||||
menu_role_id SERIAL PRIMARY KEY,
|
||||
menu_id INTEGER NOT NULL REFERENCES wr_menu(menu_id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES wr_role(role_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(menu_id, role_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Backend API 개발
|
||||
- [x] 시작: 2026-01-10 16:47:35
|
||||
- [x] 완료: 2026-01-10 16:50:10
|
||||
- [x] 소요시간: 2분 35초
|
||||
|
||||
**작업 내용:**
|
||||
1. `GET /api/admin/menu/list` - 메뉴 목록 조회 (권한 포함)
|
||||
2. `POST /api/admin/menu/create` - 메뉴 추가
|
||||
3. `PUT /api/admin/menu/[id]/update` - 메뉴 수정
|
||||
4. `DELETE /api/admin/menu/[id]/delete` - 메뉴 삭제
|
||||
5. `POST /api/admin/menu/[id]/toggle-role` - 메뉴 권한 토글
|
||||
6. `GET /api/auth/menu` - 현재 사용자 접근 가능 메뉴 조회
|
||||
|
||||
**파일 목록:**
|
||||
- backend/api/admin/menu/list.get.ts
|
||||
- backend/api/admin/menu/create.post.ts
|
||||
- backend/api/admin/menu/[id]/update.put.ts
|
||||
- backend/api/admin/menu/[id]/delete.delete.ts
|
||||
- backend/api/admin/menu/[id]/toggle-role.post.ts
|
||||
- backend/api/auth/menu.get.ts
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 메뉴관리 페이지 개발
|
||||
- [x] 시작: 2026-01-10 16:50:15
|
||||
- [x] 완료: 2026-01-10 16:52:00
|
||||
- [x] 소요시간: 1분 45초
|
||||
|
||||
**작업 내용:**
|
||||
1. 메뉴 목록 페이지 (사용자관리와 유사한 UI)
|
||||
2. 메뉴별 권한 체크박스 (관리자/매니저/일반사용자)
|
||||
3. 메뉴 추가/수정/삭제 기능
|
||||
|
||||
**파일 목록:**
|
||||
- frontend/admin/menu/index.vue
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: AppHeader 메뉴 동적 렌더링
|
||||
- [x] 시작: 2026-01-10 16:52:05
|
||||
- [x] 완료: 2026-01-10 16:55:30
|
||||
- [x] 소요시간: 3분 25초
|
||||
|
||||
**작업 내용:**
|
||||
1. 로그인 시 사용자 접근 가능 메뉴 조회
|
||||
2. useAuth에 메뉴 정보 저장
|
||||
3. AppHeader에서 동적 메뉴 렌더링
|
||||
4. 메뉴 권한에 따른 표시/숨김
|
||||
|
||||
**수정 파일:**
|
||||
- frontend/composables/useAuth.ts
|
||||
- frontend/components/layout/AppHeader.vue
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 페이지별 권한 체크 적용
|
||||
- [x] 시작: 2026-01-10 16:55:35
|
||||
- [x] 완료: 2026-01-10 16:59:45
|
||||
- [x] 소요시간: 4분 10초
|
||||
|
||||
**작업 내용:**
|
||||
1. 기존 하드코딩된 권한 체크 제거
|
||||
2. 메뉴 권한 기반 접근 제어 적용
|
||||
3. 권한 없는 페이지 접근 시 리다이렉트
|
||||
|
||||
**수정 파일:**
|
||||
- frontend/admin/user/index.vue
|
||||
- frontend/admin/user/create.vue
|
||||
- frontend/admin/user/[id].vue
|
||||
- frontend/admin/bulk-import.vue
|
||||
- frontend/report/summary/index.vue (취합보고 권한 추가)
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: 테스트 및 정리
|
||||
- [x] 시작: 2026-01-10 16:59:50
|
||||
- [x] 완료: 2026-01-10 17:01:00
|
||||
- [x] 소요시간: 1분 10초
|
||||
|
||||
**테스트 시나리오:**
|
||||
1. 관리자 로그인 → 모든 메뉴 표시 확인
|
||||
2. 매니저 로그인 → 관리자 메뉴 숨김 확인
|
||||
3. 일반사용자 로그인 → 취합보고/관리자 메뉴 숨김 확인
|
||||
4. 메뉴관리에서 권한 변경 → 즉시 반영 확인
|
||||
5. 권한 없는 URL 직접 접근 → 리다이렉트 확인
|
||||
|
||||
---
|
||||
|
||||
## 예상 산출물
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|------|
|
||||
| SQL | backend/sql/create_menu_tables.sql | 신규 |
|
||||
| API | backend/api/admin/menu/list.get.ts | 신규 |
|
||||
| API | backend/api/admin/menu/create.post.ts | 신규 |
|
||||
| API | backend/api/admin/menu/[id]/update.put.ts | 신규 |
|
||||
| API | backend/api/admin/menu/[id]/delete.delete.ts | 신규 |
|
||||
| API | backend/api/admin/menu/[id]/toggle-role.post.ts | 신규 |
|
||||
| API | backend/api/auth/menu.get.ts | 신규 |
|
||||
| Frontend | frontend/admin/menu/index.vue | 신규 |
|
||||
| Frontend | frontend/composables/useAuth.ts | 수정 |
|
||||
| Frontend | frontend/components/layout/AppHeader.vue | 수정 |
|
||||
| Frontend | frontend/admin/user/*.vue | 수정 |
|
||||
| Frontend | frontend/admin/bulk-import.vue | 수정 |
|
||||
| Frontend | frontend/report/summary/index.vue | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 참고사항
|
||||
|
||||
1. **기능 제어 vs 화면 제어**
|
||||
- 본 작업은 **화면(메뉴) 접근 권한**만 제어
|
||||
- 기존 기능별 권한(수정/삭제 등)은 유지
|
||||
|
||||
2. **기존 권한 체크 유지 항목**
|
||||
- 주간보고 수정/삭제: 본인 또는 관리자
|
||||
- 사용자 삭제: 관리자만 (Backend에서 체크)
|
||||
|
||||
3. **메뉴 계층 구조**
|
||||
- 1단계: 메인 메뉴 (대시보드, 주간보고 등)
|
||||
- 2단계: 서브 메뉴 (관리자 하위 메뉴)
|
||||
|
||||
---
|
||||
|
||||
## 작업 완료 결과
|
||||
|
||||
### Phase별 작업 시간
|
||||
|
||||
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|
||||
|:-----:|----------|:----:|:----:|:--------:|
|
||||
| 1 | DB 스키마 설계 및 생성 | 16:45:00 | 16:47:30 | **2분 30초** |
|
||||
| 2 | Backend API 개발 | 16:47:35 | 16:50:10 | **2분 35초** |
|
||||
| 3 | 메뉴관리 페이지 개발 | 16:50:15 | 16:52:00 | **1분 45초** |
|
||||
| 4 | AppHeader 메뉴 동적 렌더링 | 16:52:05 | 16:55:30 | **3분 25초** |
|
||||
| 5 | 페이지별 권한 체크 적용 | 16:55:35 | 16:59:45 | **4분 10초** |
|
||||
| 6 | 테스트 및 정리 | 16:59:50 | 17:01:00 | **1분 10초** |
|
||||
| | | | **총 소요시간** | **15분 35초** |
|
||||
|
||||
---
|
||||
|
||||
### 생성/수정된 파일
|
||||
|
||||
| 구분 | 파일 | 작업 |
|
||||
|------|------|:----:|
|
||||
| **DB** | wr_menu | 신규 테이블 |
|
||||
| **DB** | wr_menu_role | 신규 테이블 |
|
||||
| **API** | backend/api/admin/menu/list.get.ts | 신규 |
|
||||
| **API** | backend/api/admin/menu/[id]/toggle-role.post.ts | 신규 |
|
||||
| **API** | backend/api/auth/menu.get.ts | 신규 |
|
||||
| **Frontend** | frontend/admin/menu/index.vue | 신규 |
|
||||
| **Frontend** | frontend/composables/useAuth.ts | 수정 |
|
||||
| **Frontend** | frontend/components/layout/AppHeader.vue | 수정 |
|
||||
| **Frontend** | frontend/admin/user/index.vue | 수정 |
|
||||
| **Frontend** | frontend/admin/user/create.vue | 수정 |
|
||||
| **Frontend** | frontend/admin/user/[id].vue | 수정 |
|
||||
| **Frontend** | frontend/admin/bulk-import.vue | 수정 |
|
||||
| **Frontend** | frontend/report/summary/index.vue | 수정 |
|
||||
|
||||
---
|
||||
|
||||
### 메뉴별 권한 설정 현황
|
||||
|
||||
| 메뉴 | 관리자 | 매니저 | 일반사용자 |
|
||||
|------|:------:|:------:|:----------:|
|
||||
| 대시보드 | ✅ | ✅ | ✅ |
|
||||
| 주간보고 | ✅ | ✅ | ✅ |
|
||||
| 취합보고 | ✅ | ✅ | ❌ |
|
||||
| 프로젝트 | ✅ | ✅ | ✅ |
|
||||
| 개선의견 | ✅ | ✅ | ✅ |
|
||||
| 관리자 | ✅ | ❌ | ❌ |
|
||||
| └ 사용자 관리 | ✅ | ❌ | ❌ |
|
||||
| └ 메뉴 관리 | ✅ | ❌ | ❌ |
|
||||
| └ 주간보고 일괄등록 | ✅ | ❌ | ❌ |
|
||||
123
claude_temp/TASK_ROLE_MANAGEMENT.md
Normal file
123
claude_temp/TASK_ROLE_MANAGEMENT.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 🔐 권한 관리 시스템 구현 작업
|
||||
|
||||
## 📅 작업 정보
|
||||
- **시작일**: 2025-01-10
|
||||
- **최종 수정**: 2025-01-10
|
||||
- **상태**: ✅ 완료
|
||||
|
||||
---
|
||||
|
||||
## 🎯 목표
|
||||
1. 직원관리 → 관리자 메뉴 하위 "사용자 관리"로 이동 및 명칭 변경
|
||||
2. 사용자 목록에 권한 체크박스 표시
|
||||
3. 권한관리 팝업으로 권한 CRUD
|
||||
4. 기존 하드코딩 admin 체크 → role 기반으로 전환
|
||||
|
||||
## 📌 권한 구조
|
||||
| 권한코드 | 권한명 | 설명 |
|
||||
|----------|--------|------|
|
||||
| ROLE_USER | 일반사용자 | 기본 기능 (주간보고 작성/조회) |
|
||||
| ROLE_MANAGER | 매니저 | 취합보고서 등 관리 기능 |
|
||||
| ROLE_ADMIN | 관리자 | 모든 기능 + 시스템 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Phase 목록
|
||||
|
||||
### Phase 1: DB 스키마 생성
|
||||
- **상태**: ✅ 완료
|
||||
- **파일**: `backend/sql/create_role_tables.sql`
|
||||
- **작업 내용**:
|
||||
- [x] wr_role 테이블 생성
|
||||
- [x] wr_employee_role 테이블 생성
|
||||
- [x] 기본 권한 데이터 INSERT (ROLE_ADMIN, ROLE_MANAGER, ROLE_USER)
|
||||
- [x] 기존 coziny@gmail.com에 ROLE_ADMIN 부여
|
||||
- **⚠️ DB 실행 필요**: `psql -f backend/sql/create_role_tables.sql`
|
||||
|
||||
### Phase 2: 권한 CRUD API 구현
|
||||
- **상태**: ✅ 완료
|
||||
- **파일**: `backend/api/admin/role/`
|
||||
- **작업 내용**:
|
||||
- [x] GET /api/admin/role/list - 권한 목록 조회
|
||||
- [x] POST /api/admin/role/create - 권한 생성
|
||||
- [x] PUT /api/admin/role/[id]/update - 권한 수정
|
||||
- [x] DELETE /api/admin/role/[id]/delete - 권한 삭제
|
||||
- [x] session.ts에 requireAdmin, requireManager, getUserRoles, hasRole 함수 추가
|
||||
|
||||
### Phase 3: 사용자-권한 매핑 API 구현
|
||||
- **상태**: ✅ 완료
|
||||
- **파일**: `backend/api/admin/user/`
|
||||
- **작업 내용**:
|
||||
- [x] GET /api/admin/user/list - 사용자 목록 (권한 포함)
|
||||
- [x] PUT /api/admin/user/[id]/roles - 사용자 권한 일괄 변경
|
||||
- [x] POST /api/admin/user/[id]/toggle-role - 개별 권한 토글
|
||||
- [x] GET /api/auth/current-user 수정 - roles 배열 추가
|
||||
|
||||
### Phase 4: 메뉴 구조 변경
|
||||
- **상태**: ✅ 완료
|
||||
- **파일**: `frontend/components/layout/AppHeader.vue`, `frontend/composables/useAuth.ts`
|
||||
- **작업 내용**:
|
||||
- [x] 직원관리 메뉴 제거
|
||||
- [x] 관리자 메뉴 하위에 "사용자 관리" 추가
|
||||
- [x] isAdmin 로직을 role 기반으로 변경
|
||||
- [x] useAuth.ts에 hasRole(), isAdmin, isManager 추가
|
||||
|
||||
### Phase 5: 사용자 관리 페이지 생성
|
||||
- **상태**: ✅ 완료
|
||||
- **파일**: `frontend/admin/user/index.vue`
|
||||
- **작업 내용**:
|
||||
- [x] 사용자 목록 테이블
|
||||
- [x] 권한 체크박스 컬럼들 (동적 생성)
|
||||
- [x] 체크박스 클릭 시 권한 즉시 변경 (toggle-role API)
|
||||
- [x] 검색 기능
|
||||
|
||||
### Phase 6: 권한관리 팝업 컴포넌트
|
||||
- **상태**: ✅ 완료
|
||||
- **파일**: `frontend/components/common/RoleManageModal.vue`
|
||||
- **작업 내용**:
|
||||
- [x] 권한 목록 표시
|
||||
- [x] 권한 추가 기능 (신규 버튼)
|
||||
- [x] 권한 수정 기능 (인라인 편집)
|
||||
- [x] 권한 삭제 기능 (선택 삭제)
|
||||
- [x] 사용자 수 표시
|
||||
- [x] 기본 권한(ROLE_ADMIN, ROLE_MANAGER, ROLE_USER) 보호
|
||||
|
||||
### Phase 7: 기존 코드 리팩토링
|
||||
- **상태**: ✅ 완료
|
||||
- **작업 내용**:
|
||||
- [x] useAuth.ts에 roles, hasRole() 추가
|
||||
- [x] 기존 isAdmin 하드코딩 → hasRole('ROLE_ADMIN')으로 변경
|
||||
- [x] aggregate.vue 권한 체크 수정
|
||||
- [x] bulk-import.vue 권한 체크 추가
|
||||
- [x] [id].vue canEdit/canDelete 수정
|
||||
- [x] index.vue isAdmin 수정
|
||||
|
||||
### Phase 8: 기존 파일 정리
|
||||
- **상태**: ✅ 완료
|
||||
- **작업 내용**:
|
||||
- [x] frontend/employee/index.vue 삭제
|
||||
- [x] frontend/employee/[id].vue 삭제
|
||||
- [x] frontend/employee 폴더 삭제
|
||||
|
||||
---
|
||||
|
||||
## 📝 작업 로그
|
||||
|
||||
### 2025-01-10
|
||||
- [10:XX] 작업 계획 파일 생성
|
||||
- [XX:XX] Phase 1~6 완료
|
||||
- [XX:XX] Phase 7 완료 - bulk-import.vue에 isAdmin 권한 체크 추가
|
||||
- [XX:XX] Phase 8 완료 - frontend/employee 폴더 삭제
|
||||
- [XX:XX] ✅ 전체 작업 완료
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
- 기존 coziny@gmail.com 계정은 ROLE_ADMIN 자동 부여
|
||||
- 모든 API는 ROLE_ADMIN 권한 체크 필요
|
||||
- DB 마이그레이션 시 기존 데이터 영향 없음 확인
|
||||
|
||||
## 🔗 관련 파일
|
||||
- README.md - 프로젝트 문서
|
||||
- backend/utils/session.ts - 세션 관리
|
||||
- frontend/composables/useAuth.ts - 인증 상태 관리
|
||||
81
claude_temp/TASK_USER_CRUD.md
Normal file
81
claude_temp/TASK_USER_CRUD.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 사용자 관리 CRUD 개선 작업계획
|
||||
|
||||
## 작업 개요
|
||||
- 사용자 추가/수정을 모달에서 페이지 전환 방식으로 변경
|
||||
- 마이페이지(mypage/index.vue) 폼 필드 참고하여 동일하게 구성
|
||||
- 사용자 목록 컬럼 순서 변경 및 최근 로그인 일자 추가
|
||||
|
||||
## 참고: 마이페이지 필드
|
||||
| 필드 | DB 컬럼 | 타입 | 비고 |
|
||||
|------|---------|------|------|
|
||||
| 이름 | employee_name | text | 필수 |
|
||||
| 이메일 | employee_email | text | 필수, 변경불가 |
|
||||
| 소속사 | company | select | (주)터보소프트, (주)코쿤, (주)오솔정보기술 |
|
||||
| 직급 | employee_position | select | 일반/연구소 그룹 |
|
||||
| 연락처 | employee_phone | text | 010-0000-0000 |
|
||||
| 입사일 | join_date | date | |
|
||||
|
||||
## 사용자 목록 컬럼 (변경 후)
|
||||
No | 소속사 | 직급 | 이름 | 이메일 | 상태 | 최근로그인 | 권한(동적) | 관리
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend API 수정 - 사용자 목록에 최근 로그인 추가
|
||||
- [x] 시작: 2026-01-10 15:23:00
|
||||
- [x] 완료: 2026-01-10 15:24:30
|
||||
- 파일: backend/api/admin/user/list.get.ts
|
||||
- 내용: 최근 로그인 일자(last_login_at) 조회 추가
|
||||
|
||||
## Phase 2: 사용자 목록 페이지 수정
|
||||
- [x] 시작: 2026-01-10 15:24:35
|
||||
- [x] 완료: 2026-01-10 15:26:10
|
||||
- 파일: frontend/admin/user/index.vue
|
||||
- 내용:
|
||||
- 테이블 컬럼 순서 변경 (소속사, 직급, 이름, 이메일, 상태, 최근로그인, 권한, 관리)
|
||||
- 모달 코드 제거
|
||||
- 추가/수정 버튼 → 페이지 이동으로 변경
|
||||
|
||||
## Phase 3: 사용자 추가 페이지 생성
|
||||
- [x] 시작: 2026-01-10 15:26:15
|
||||
- [x] 완료: 2026-01-10 15:28:00
|
||||
- 파일: frontend/admin/user/create.vue
|
||||
- 내용:
|
||||
- 마이페이지 폼 구조 참고
|
||||
- 소속사: select (3개 회사)
|
||||
- 직급: select (일반/연구소 그룹)
|
||||
- 입사일: date picker
|
||||
|
||||
## Phase 4: 사용자 수정 페이지 생성
|
||||
- [x] 시작: 2026-01-10 15:28:05
|
||||
- [x] 완료: 2026-01-10 15:30:30
|
||||
- 파일: frontend/admin/user/[id].vue
|
||||
- 내용:
|
||||
- 마이페이지 폼 구조 참고
|
||||
- 이메일 변경 불가
|
||||
- 활성 상태 토글 추가
|
||||
- 삭제 버튼 포함
|
||||
|
||||
## Phase 5: 테스트 및 정리
|
||||
- [x] 시작: 2026-01-10 15:30:35
|
||||
- [x] 완료: 2026-01-10 15:31:45
|
||||
- 내용:
|
||||
- 목록 → 추가 → 저장 → 목록 복귀 테스트
|
||||
- 목록 → 수정 → 저장 → 목록 복귀 테스트
|
||||
- 삭제 테스트
|
||||
|
||||
---
|
||||
|
||||
## 변경 파일 목록
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| backend/api/admin/user/list.get.ts | 수정 (최근 로그인, 소속사 추가) |
|
||||
| frontend/admin/user/index.vue | 수정 (목록 컬럼 변경, 모달 제거) |
|
||||
| frontend/admin/user/create.vue | 신규 (추가 페이지) |
|
||||
| frontend/admin/user/[id].vue | 신규 (수정 페이지) |
|
||||
| frontend/mypage/index.vue | 수정 (직급 목록 확장)
|
||||
|
||||
## 직급 목록 (확장됨)
|
||||
- **일반**: 인턴, 사원, 주임, 대리, 과장, 차장, 부장
|
||||
- **연구소**: 연구원, 주임연구원, 선임연구원, 책임연구원, 수석연구원, 연구소장
|
||||
- **임원**: 이사, 상무이사, 전무이사, 부사장, 사장, 대표이사
|
||||
- **기타**: 팀장, 실장, 본부장, 고문, 감사
|
||||
@@ -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