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

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

1
.idea/vcs.xml generated
View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

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

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

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

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

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

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

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

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

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

View File

@@ -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 // 권한 코드 배열 추가
}
}
})

View File

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

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

View File

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

View File

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

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

View File

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

View 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 | 수정 |
---
### 메뉴별 권한 설정 현황
| 메뉴 | 관리자 | 매니저 | 일반사용자 |
|------|:------:|:------:|:----------:|
| 대시보드 | ✅ | ✅ | ✅ |
| 주간보고 | ✅ | ✅ | ✅ |
| 취합보고 | ✅ | ✅ | ❌ |
| 프로젝트 | ✅ | ✅ | ✅ |
| 개선의견 | ✅ | ✅ | ✅ |
| 관리자 | ✅ | ❌ | ❌ |
| └ 사용자 관리 | ✅ | ❌ | ❌ |
| └ 메뉴 관리 | ✅ | ❌ | ❌ |
| └ 주간보고 일괄등록 | ✅ | ❌ | ❌ |

View 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 - 인증 상태 관리

View 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 | 수정 (직급 목록 확장)
## 직급 목록 (확장됨)
- **일반**: 인턴, 사원, 주임, 대리, 과장, 차장, 부장
- **연구소**: 연구원, 주임연구원, 선임연구원, 책임연구원, 수석연구원, 연구소장
- **임원**: 이사, 상무이사, 전무이사, 부사장, 사장, 대표이사
- **기타**: 팀장, 실장, 본부장, 고문, 감사

View File

@@ -359,7 +359,7 @@
</template>
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const { fetchCurrentUser, hasMenuAccess } = useAuth()
const { getWeekInfo, getWeekDates, getLastWeekInfo, getActualCurrentWeekInfo, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter()
@@ -403,6 +403,12 @@ onMounted(async () => {
router.push('/login')
return
}
// 메뉴 권한 체크
if (!hasMenuAccess('ADMIN_BULK_IMPORT')) {
alert('접근 권한이 없습니다.')
router.push('/report/weekly')
return
}
})
function getHeaderClass(report: any) {

View File

@@ -0,0 +1,161 @@
<template>
<div>
<AppHeader />
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">
<i class="bi bi-list me-2"></i>메뉴 관리
</h4>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 50px">No</th>
<th style="width: 150px">메뉴코드</th>
<th>메뉴명</th>
<th style="width: 200px">경로</th>
<th v-for="role in roles" :key="role.roleId" style="width: 100px" class="text-center">
{{ role.roleName }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td :colspan="5 + roles.length" class="text-center py-4">
<span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</td>
</tr>
<template v-else>
<template v-for="(menu, idx) in menus" :key="menu.menuId">
<tr :class="{ 'table-secondary': !menu.parentMenuId }">
<td class="text-center">{{ idx + 1 }}</td>
<td>
<code class="small">{{ menu.menuCode }}</code>
</td>
<td>
<span v-if="menu.parentMenuId" class="text-muted me-2"></span>
<i :class="['bi', menu.menuIcon, 'me-1']" v-if="menu.menuIcon"></i>
<strong v-if="!menu.parentMenuId">{{ menu.menuName }}</strong>
<span v-else>{{ menu.menuName }}</span>
</td>
<td>
<code class="small text-muted">{{ menu.menuPath || '-' }}</code>
</td>
<td v-for="role in roles" :key="role.roleId" class="text-center">
<input
type="checkbox"
class="form-check-input"
:checked="menu.roleIds.includes(role.roleId)"
@change="toggleRole(menu.menuId, role.roleId, $event)"
/>
</td>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 토스트 메시지 -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1100">
<div v-if="toast.show" class="toast show" role="alert">
<div class="toast-header" :class="toast.type === 'success' ? 'bg-success text-white' : 'bg-danger text-white'">
<strong class="me-auto">{{ toast.type === 'success' ? '성공' : '오류' }}</strong>
<button type="button" class="btn-close btn-close-white" @click="toast.show = false"></button>
</div>
<div class="toast-body">{{ toast.message }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { fetchCurrentUser, hasMenuAccess } = useAuth()
const router = useRouter()
const isLoading = ref(true)
const menus = ref<any[]>([])
const roles = ref<any[]>([])
const toast = ref({
show: false,
type: 'success' as 'success' | 'error',
message: ''
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
if (!hasMenuAccess('ADMIN_MENU')) {
alert('접근 권한이 없습니다.')
router.push('/')
return
}
await loadMenus()
})
async function loadMenus() {
isLoading.value = true
try {
const res = await $fetch<any>('/api/admin/menu/list')
menus.value = res.menus
roles.value = res.roles
} catch (e: any) {
console.error(e)
showToast('error', '메뉴 목록을 불러오는데 실패했습니다.')
} finally {
isLoading.value = false
}
}
async function toggleRole(menuId: number, roleId: number, event: Event) {
const checkbox = event.target as HTMLInputElement
const enabled = checkbox.checked
try {
await $fetch(`/api/admin/menu/${menuId}/toggle-role`, {
method: 'POST',
body: { roleId, enabled }
})
// 로컬 상태 업데이트
const menu = menus.value.find(m => m.menuId === menuId)
if (menu) {
if (enabled) {
if (!menu.roleIds.includes(roleId)) {
menu.roleIds.push(roleId)
}
} else {
menu.roleIds = menu.roleIds.filter((id: number) => id !== roleId)
}
}
const roleName = roles.value.find(r => r.roleId === roleId)?.roleName || ''
showToast('success', `${enabled ? '권한 추가' : '권한 제거'}: ${roleName}`)
} catch (e: any) {
console.error(e)
checkbox.checked = !enabled // 롤백
showToast('error', '권한 변경에 실패했습니다.')
}
}
function showToast(type: 'success' | 'error', message: string) {
toast.value = { show: true, type, message }
setTimeout(() => {
toast.value.show = false
}, 2500)
}
</script>

View File

@@ -0,0 +1,359 @@
<template>
<div>
<AppHeader />
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- 헤더 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">
<i class="bi bi-pencil me-2"></i>사용자 수정
</h4>
<NuxtLink to="/admin/user" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>목록으로
</NuxtLink>
</div>
<div v-if="isLoading" class="text-center py-5">
<span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</div>
<template v-else-if="userInfo">
<!-- 기본 정보 -->
<div class="card mb-4">
<div class="card-header">
<strong>기본 정보</strong>
</div>
<div class="card-body">
<form @submit.prevent="saveUser">
<div class="row mb-3">
<label class="col-3 col-form-label">이름 <span class="text-danger">*</span></label>
<div class="col-9">
<input type="text" class="form-control" v-model="form.employeeName" required />
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">이메일</label>
<div class="col-9">
<input type="email" class="form-control" :value="userInfo.employeeEmail" disabled />
<small class="text-muted">이메일은 변경할 없습니다.</small>
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">소속사</label>
<div class="col-9">
<select class="form-select" v-model="form.company">
<option value="">(선택)</option>
<option value="(주)터보소프트">()터보소프트</option>
<option value="(주)코쿤">()코쿤</option>
<option value="(주)오솔정보기술">()오솔정보기술</option>
</select>
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">직급</label>
<div class="col-9">
<select class="form-select" v-model="form.employeePosition">
<option value="">선택</option>
<optgroup label="일반">
<option value="인턴">인턴</option>
<option value="사원">사원</option>
<option value="주임">주임</option>
<option value="대리">대리</option>
<option value="과장">과장</option>
<option value="차장">차장</option>
<option value="부장">부장</option>
</optgroup>
<optgroup label="연구소">
<option value="연구원">연구원</option>
<option value="주임연구원">주임연구원</option>
<option value="선임연구원">선임연구원</option>
<option value="책임연구원">책임연구원</option>
<option value="수석연구원">수석연구원</option>
<option value="연구소장">연구소장</option>
</optgroup>
<optgroup label="임원">
<option value="이사">이사</option>
<option value="상무이사">상무이사</option>
<option value="전무이사">전무이사</option>
<option value="부사장">부사장</option>
<option value="사장">사장</option>
<option value="대표이사">대표이사</option>
</optgroup>
<optgroup label="기타">
<option value="팀장">팀장</option>
<option value="실장">실장</option>
<option value="본부장">본부장</option>
<option value="고문">고문</option>
<option value="감사">감사</option>
</optgroup>
</select>
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">연락처</label>
<div class="col-9">
<input type="tel" class="form-control" v-model="form.employeePhone" placeholder="010-0000-0000" />
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">입사일</label>
<div class="col-9">
<input type="date" class="form-control" v-model="form.joinDate" />
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">상태</label>
<div class="col-9">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="isActiveSwitch" v-model="form.isActive" />
<label class="form-check-label" for="isActiveSwitch">
{{ form.isActive ? '활성' : '비활성' }}
</label>
</div>
</div>
</div>
<hr class="my-3" />
<div class="row mb-2">
<label class="col-3 col-form-label text-muted small">최초입력</label>
<div class="col-9">
<span class="form-control-plaintext small text-muted">
{{ formatDateTime(userInfo.createdAt) }}
<span v-if="userInfo.createdIp" class="ms-2"><code>{{ userInfo.createdIp }}</code></span>
</span>
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label text-muted small">최종수정</label>
<div class="col-9">
<span class="form-control-plaintext small text-muted">
{{ formatDateTime(userInfo.updatedAt) }}
<span v-if="userInfo.updatedIp" class="ms-2"><code>{{ userInfo.updatedIp }}</code></span>
</span>
</div>
</div>
<div class="text-end">
<NuxtLink to="/admin/user" class="btn btn-secondary me-2">취소</NuxtLink>
<button type="submit" class="btn btn-primary" :disabled="!canSave || isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1"></span>
저장
</button>
</div>
</form>
</div>
</div>
<!-- 로그인 이력 -->
<div class="card mb-4">
<div class="card-header">
<strong>로그인 이력</strong>
<small class="text-muted ms-2">(최근 20)</small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>로그인 시간</th>
<th>로그인 IP</th>
<th>로그아웃 시간</th>
</tr>
</thead>
<tbody>
<tr v-if="loginHistory.length === 0">
<td colspan="3" class="text-center text-muted py-3">로그인 이력이 없습니다.</td>
</tr>
<tr v-else v-for="h in loginHistory" :key="h.historyId">
<td>{{ formatDateTime(h.loginAt) }}</td>
<td><code>{{ h.loginIp || '-' }}</code></td>
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 위험 영역 -->
<div class="card border-danger" v-if="!isSelf">
<div class="card-header bg-danger text-white">
<strong><i class="bi bi-exclamation-triangle me-2"></i>위험 영역</strong>
</div>
<div class="card-body">
<p class="mb-2"> 사용자를 삭제합니다. 주간보고가 있는 경우 비활성화 처리됩니다.</p>
<button class="btn btn-danger" @click="confirmDelete" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>
<i class="bi bi-trash me-1"></i>사용자 삭제
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div v-if="showDeleteModal" class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">삭제 확인</h5>
<button type="button" class="btn-close btn-close-white" @click="showDeleteModal = false"></button>
</div>
<div class="modal-body">
<p class="mb-0"><strong>{{ userInfo?.employeeName }}</strong>님을 삭제하시겠습니까?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">취소</button>
<button type="button" class="btn btn-danger" @click="deleteUser" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>삭제
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { fetchCurrentUser, hasMenuAccess } = useAuth()
const router = useRouter()
const route = useRoute()
const employeeId = computed(() => route.params.id as string)
const isLoading = ref(true)
const isSaving = ref(false)
const isDeleting = ref(false)
const showDeleteModal = ref(false)
const currentUser = ref<any>(null)
const userInfo = ref<any>(null)
const loginHistory = ref<any[]>([])
const form = ref({
employeeName: '',
company: '',
employeePosition: '',
employeePhone: '',
joinDate: '',
isActive: true
})
const canSave = computed(() => form.value.employeeName.trim())
const isSelf = computed(() => currentUser.value?.employeeId === parseInt(employeeId.value))
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
currentUser.value = user
if (!hasMenuAccess('ADMIN_USER')) {
alert('접근 권한이 없습니다.')
router.push('/')
return
}
await loadUserInfo()
})
async function loadUserInfo() {
isLoading.value = true
try {
const res = await $fetch<any>(`/api/employee/${employeeId.value}/detail`)
userInfo.value = res.employee
loginHistory.value = res.loginHistory || []
// 폼에 기존 데이터 설정
form.value = {
employeeName: res.employee.employeeName || '',
company: res.employee.company || '',
employeePosition: res.employee.employeePosition || '',
employeePhone: res.employee.employeePhone || '',
joinDate: res.employee.joinDate ? res.employee.joinDate.split('T')[0] : '',
isActive: res.employee.isActive
}
} catch (e: any) {
console.error(e)
alert('사용자 정보를 불러오는데 실패했습니다.')
router.push('/admin/user')
} finally {
isLoading.value = false
}
}
async function saveUser() {
if (!canSave.value) return
isSaving.value = true
try {
await $fetch(`/api/employee/${employeeId.value}/update`, {
method: 'PUT',
body: {
employeeName: form.value.employeeName,
company: form.value.company || null,
employeePosition: form.value.employeePosition || null,
employeePhone: form.value.employeePhone || null,
joinDate: form.value.joinDate || null,
isActive: form.value.isActive
}
})
alert('저장되었습니다.')
router.push('/admin/user')
} catch (e: any) {
console.error(e)
alert(e.data?.message || '저장에 실패했습니다.')
} finally {
isSaving.value = false
}
}
function confirmDelete() {
showDeleteModal.value = true
}
async function deleteUser() {
isDeleting.value = true
try {
const response = await $fetch<any>(`/api/employee/${employeeId.value}/delete`, {
method: 'DELETE'
})
alert(response.message)
router.push('/admin/user')
} catch (e: any) {
console.error(e)
alert(e.data?.message || '삭제에 실패했습니다.')
} finally {
isDeleting.value = false
showDeleteModal.value = false
}
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
const second = String(d.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
</script>

View File

@@ -0,0 +1,177 @@
<template>
<div>
<AppHeader />
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- 헤더 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">
<i class="bi bi-person-plus me-2"></i>사용자 추가
</h4>
<NuxtLink to="/admin/user" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>목록으로
</NuxtLink>
</div>
<!-- -->
<div class="card">
<div class="card-body">
<form @submit.prevent="saveUser">
<div class="row mb-3">
<label class="col-3 col-form-label">이름 <span class="text-danger">*</span></label>
<div class="col-9">
<input type="text" class="form-control" v-model="form.employeeName" required />
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">이메일 <span class="text-danger">*</span></label>
<div class="col-9">
<input type="email" class="form-control" v-model="form.employeeEmail" required />
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">소속사</label>
<div class="col-9">
<select class="form-select" v-model="form.company">
<option value="">(선택)</option>
<option value="(주)터보소프트">()터보소프트</option>
<option value="(주)코쿤">()코쿤</option>
<option value="(주)오솔정보기술">()오솔정보기술</option>
</select>
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">직급</label>
<div class="col-9">
<select class="form-select" v-model="form.employeePosition">
<option value="">선택</option>
<optgroup label="일반">
<option value="인턴">인턴</option>
<option value="사원">사원</option>
<option value="주임">주임</option>
<option value="대리">대리</option>
<option value="과장">과장</option>
<option value="차장">차장</option>
<option value="부장">부장</option>
</optgroup>
<optgroup label="연구소">
<option value="연구원">연구원</option>
<option value="주임연구원">주임연구원</option>
<option value="선임연구원">선임연구원</option>
<option value="책임연구원">책임연구원</option>
<option value="수석연구원">수석연구원</option>
<option value="연구소장">연구소장</option>
</optgroup>
<optgroup label="임원">
<option value="이사">이사</option>
<option value="상무이사">상무이사</option>
<option value="전무이사">전무이사</option>
<option value="부사장">부사장</option>
<option value="사장">사장</option>
<option value="대표이사">대표이사</option>
</optgroup>
<optgroup label="기타">
<option value="팀장">팀장</option>
<option value="실장">실장</option>
<option value="본부장">본부장</option>
<option value="고문">고문</option>
<option value="감사">감사</option>
</optgroup>
</select>
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">연락처</label>
<div class="col-9">
<input type="tel" class="form-control" v-model="form.employeePhone" placeholder="010-0000-0000" />
</div>
</div>
<div class="row mb-3">
<label class="col-3 col-form-label">입사일</label>
<div class="col-9">
<input type="date" class="form-control" v-model="form.joinDate" />
</div>
</div>
<div class="text-end">
<NuxtLink to="/admin/user" class="btn btn-secondary me-2">취소</NuxtLink>
<button type="submit" class="btn btn-primary" :disabled="!canSave || isSaving">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1"></span>
저장
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { fetchCurrentUser, hasMenuAccess } = useAuth()
const router = useRouter()
const isSaving = ref(false)
const form = ref({
employeeName: '',
employeeEmail: '',
company: '(주)터보소프트',
employeePosition: '',
employeePhone: '',
joinDate: ''
})
const canSave = computed(() => {
return form.value.employeeName.trim() && form.value.employeeEmail.trim()
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
if (!hasMenuAccess('ADMIN_USER')) {
alert('접근 권한이 없습니다.')
router.push('/')
return
}
})
async function saveUser() {
if (!canSave.value) return
isSaving.value = true
try {
await $fetch('/api/employee/create', {
method: 'POST',
body: {
employeeName: form.value.employeeName,
employeeEmail: form.value.employeeEmail,
company: form.value.company || null,
employeePosition: form.value.employeePosition || null,
employeePhone: form.value.employeePhone || null,
joinDate: form.value.joinDate || null
}
})
alert('사용자가 등록되었습니다.')
router.push('/admin/user')
} catch (e: any) {
console.error(e)
alert(e.data?.message || '저장에 실패했습니다.')
} finally {
isSaving.value = false
}
}
</script>

View File

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

View File

@@ -0,0 +1,231 @@
<template>
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">
<i class="bi bi-shield-lock me-2"></i>권한관리
</h5>
<button type="button" class="btn-close btn-close-white" @click="$emit('close')"></button>
</div>
<div class="modal-body">
<!-- 상단 버튼 -->
<div class="d-flex justify-content-between align-items-center mb-3">
<span>권한 목록 <strong>{{ roles.length }}</strong></span>
<div>
<button class="btn btn-primary btn-sm me-2" @click="showAddForm = true" v-if="!showAddForm">
<i class="bi bi-plus-lg me-1"></i>신규
</button>
<button
class="btn btn-outline-danger btn-sm"
@click="deleteSelected"
:disabled="selectedIds.length === 0"
>
<i class="bi bi-trash me-1"></i>선택 삭제
</button>
</div>
</div>
<!-- 신규 추가 -->
<div v-if="showAddForm" class="card mb-3 border-primary">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">권한코드 *</label>
<input type="text" class="form-control form-control-sm" v-model="newRole.roleCode" placeholder="ROLE_XXX" />
</div>
<div class="col-md-4">
<label class="form-label small">권한명 *</label>
<input type="text" class="form-control form-control-sm" v-model="newRole.roleName" placeholder="권한명" />
</div>
<div class="col-md-4">
<button class="btn btn-primary btn-sm me-1" @click="createRole" :disabled="isCreating">
<span v-if="isCreating" class="spinner-border spinner-border-sm me-1"></span>
저장
</button>
<button class="btn btn-secondary btn-sm" @click="cancelAdd">취소</button>
</div>
</div>
</div>
</div>
<!-- 권한 목록 테이블 -->
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px" class="text-center">
<input type="checkbox" class="form-check-input" v-model="selectAll" @change="toggleSelectAll" />
</th>
<th style="width: 50px" class="text-center">No</th>
<th style="width: 180px">권한코드</th>
<th>권한명</th>
<th style="width: 80px" class="text-center">사용자 </th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="5" class="text-center py-4">
<span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</td>
</tr>
<tr v-else v-for="(role, idx) in roles" :key="role.role_id">
<td class="text-center">
<input
type="checkbox"
class="form-check-input"
:value="role.role_id"
v-model="selectedIds"
:disabled="isProtectedRole(role.role_code)"
/>
</td>
<td class="text-center">{{ idx + 1 }}</td>
<td>
<input
type="text"
class="form-control form-control-sm border-0 bg-transparent"
v-model="role.role_code"
:disabled="isProtectedRole(role.role_code)"
@blur="updateRole(role)"
/>
</td>
<td>
<input
type="text"
class="form-control form-control-sm border-0 bg-transparent"
v-model="role.role_name"
@blur="updateRole(role)"
/>
</td>
<td class="text-center">{{ role.user_count }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="$emit('close')">닫기</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits(['close', 'updated'])
const isLoading = ref(true)
const isCreating = ref(false)
const roles = ref<any[]>([])
const selectedIds = ref<number[]>([])
const selectAll = ref(false)
const showAddForm = ref(false)
const newRole = ref({
roleCode: '',
roleName: ''
})
const protectedRoles = ['ROLE_ADMIN', 'ROLE_MANAGER', 'ROLE_USER']
function isProtectedRole(code: string): boolean {
return protectedRoles.includes(code)
}
onMounted(() => {
loadRoles()
})
async function loadRoles() {
try {
isLoading.value = true
const response = await $fetch<any>('/api/admin/role/list')
roles.value = response.roles || []
} catch (e) {
console.error(e)
alert('권한 목록을 불러오는데 실패했습니다.')
} finally {
isLoading.value = false
}
}
function toggleSelectAll() {
if (selectAll.value) {
selectedIds.value = roles.value
.filter(r => !isProtectedRole(r.role_code))
.map(r => r.role_id)
} else {
selectedIds.value = []
}
}
function cancelAdd() {
showAddForm.value = false
newRole.value = { roleCode: '', roleName: '' }
}
async function createRole() {
if (!newRole.value.roleCode || !newRole.value.roleName) {
alert('권한코드와 권한명은 필수입니다.')
return
}
try {
isCreating.value = true
await $fetch('/api/admin/role/create', {
method: 'POST',
body: newRole.value
})
cancelAdd()
await loadRoles()
emit('updated')
} catch (e: any) {
alert(e.data?.message || '권한 생성에 실패했습니다.')
} finally {
isCreating.value = false
}
}
async function updateRole(role: any) {
try {
await $fetch(`/api/admin/role/${role.role_id}/update`, {
method: 'PUT',
body: {
roleName: role.role_name
}
})
emit('updated')
} catch (e: any) {
alert(e.data?.message || '권한 수정에 실패했습니다.')
await loadRoles()
}
}
async function deleteSelected() {
if (selectedIds.value.length === 0) return
if (!confirm(`선택한 ${selectedIds.value.length}개의 권한을 삭제하시겠습니까?`)) return
try {
for (const id of selectedIds.value) {
await $fetch(`/api/admin/role/${id}/delete`, { method: 'DELETE' })
}
selectedIds.value = []
selectAll.value = false
await loadRoles()
emit('updated')
} catch (e: any) {
alert(e.data?.message || '권한 삭제에 실패했습니다.')
await loadRoles()
}
}
</script>
<style scoped>
.form-check-input {
cursor: pointer;
}
.form-control:disabled {
background-color: transparent;
}
</style>

View File

@@ -12,49 +12,31 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<NuxtLink class="nav-link" to="/" active-class="active">
<i class="bi bi-house me-1"></i> 대시보드
<!-- 동적 메뉴 렌더링 -->
<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">
<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">
<!-- 자식이 있는 메뉴 (드롭다운) -->
<li class="nav-item dropdown" v-else>
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-gear me-1"></i> 관리자
<i :class="['bi', menu.menuIcon, 'me-1']" v-if="menu.menuIcon"></i>
{{ menu.menuName }}
</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>주간보고 일괄등록
<li v-for="child in menu.children" :key="child.menuId">
<NuxtLink class="dropdown-item" :to="child.menuPath || '/'">
<i :class="['bi', child.menuIcon, 'me-2']" v-if="child.menuIcon"></i>
{{ child.menuName }}
</NuxtLink>
</li>
</ul>
</li>
</template>
</ul>
<!-- 사용자 정보 -->
@@ -73,11 +55,9 @@
</template>
<script setup lang="ts">
const { currentUser, logout } = useAuth()
const { currentUser, userMenus, logout } = useAuth()
const router = useRouter()
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
async function handleLogout() {
await logout()
router.push('/login')

View File

@@ -7,10 +7,23 @@ interface User {
employeeName: string
employeeEmail: string
employeePosition: string | null
roles?: string[] // 권한 코드 배열 추가
}
interface MenuItem {
menuId: number
menuCode: string
menuName: string
menuPath: string | null
menuIcon: string | null
parentMenuId: number | null
sortOrder: number
children: MenuItem[]
}
// 전역 상태
const currentUser = ref<User | null>(null)
const userMenus = ref<MenuItem[]>([])
const isLoading = ref(false)
export function useAuth() {
@@ -22,15 +35,37 @@ export function useAuth() {
isLoading.value = true
const response = await $fetch<{ user: User | null }>('/api/auth/current-user')
currentUser.value = response.user
// 로그인된 경우 메뉴 정보도 가져오기
if (response.user) {
await fetchUserMenus()
} else {
userMenus.value = []
}
return response.user
} catch (error) {
currentUser.value = null
userMenus.value = []
return null
} finally {
isLoading.value = false
}
}
/**
* 사용자 메뉴 조회
*/
async function fetchUserMenus(): Promise<void> {
try {
const response = await $fetch<{ menus: MenuItem[] }>('/api/auth/menu')
userMenus.value = response.menus
} catch (error) {
console.error('메뉴 조회 실패:', error)
userMenus.value = []
}
}
/**
* 이메일+이름으로 로그인
*/
@@ -40,6 +75,7 @@ export function useAuth() {
body: { email, name }
})
currentUser.value = response.user
await fetchUserMenus()
return response.user
}
@@ -52,6 +88,7 @@ export function useAuth() {
body: { employeeId }
})
currentUser.value = response.user
await fetchUserMenus()
return response.user
}
@@ -69,6 +106,7 @@ export function useAuth() {
async function logout(): Promise<void> {
await $fetch('/api/auth/logout', { method: 'POST' })
currentUser.value = null
userMenus.value = []
}
/**
@@ -76,14 +114,66 @@ export function useAuth() {
*/
const isLoggedIn = computed(() => currentUser.value !== null)
/**
* 특정 권한 보유 여부 확인
*/
function hasRole(roleCode: string): boolean {
return currentUser.value?.roles?.includes(roleCode) ?? false
}
/**
* 특정 메뉴 접근 가능 여부 확인
*/
function hasMenuAccess(menuCode: string): boolean {
const findMenu = (menus: MenuItem[]): boolean => {
for (const menu of menus) {
if (menu.menuCode === menuCode) return true
if (menu.children && findMenu(menu.children)) return true
}
return false
}
return findMenu(userMenus.value)
}
/**
* 특정 경로 접근 가능 여부 확인
*/
function hasPathAccess(path: string): boolean {
const findPath = (menus: MenuItem[]): boolean => {
for (const menu of menus) {
if (menu.menuPath && path.startsWith(menu.menuPath)) return true
if (menu.children && findPath(menu.children)) return true
}
return false
}
return findPath(userMenus.value)
}
/**
* 관리자 여부 (ROLE_ADMIN)
*/
const isAdmin = computed(() => hasRole('ROLE_ADMIN'))
/**
* 매니저 이상 여부 (ROLE_MANAGER 또는 ROLE_ADMIN)
*/
const isManager = computed(() => hasRole('ROLE_MANAGER') || hasRole('ROLE_ADMIN'))
return {
currentUser: readonly(currentUser),
userMenus: readonly(userMenus),
isLoading: readonly(isLoading),
isLoggedIn,
isAdmin,
isManager,
fetchCurrentUser,
fetchUserMenus,
login,
selectUser,
getRecentUsers,
logout
logout,
hasRole,
hasMenuAccess,
hasPathAccess
}
}

View File

@@ -1,241 +0,0 @@
<template>
<div>
<AppHeader />
<div class="container-fluid py-4">
<div class="mb-4">
<NuxtLink to="/employee" class="text-decoration-none">
<i class="bi bi-arrow-left me-1"></i> 목록으로
</NuxtLink>
</div>
<div class="row" v-if="employee">
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-person me-2"></i>직원 정보
</h5>
<span :class="employee.isActive ? 'badge bg-success' : 'badge bg-secondary'">
{{ employee.isActive ? '재직' : '퇴직' }}
</span>
</div>
<div class="card-body">
<form @submit.prevent="updateEmployee">
<div class="mb-3">
<label class="form-label">이름 <span class="text-danger">*</span></label>
<input type="text" class="form-control" v-model="form.employeeName" required />
</div>
<div class="mb-3">
<label class="form-label">이메일 <span class="text-danger">*</span></label>
<input type="email" class="form-control" v-model="form.employeeEmail" required />
</div>
<div class="mb-3">
<label class="form-label">소속사</label>
<select class="form-select" v-model="form.company">
<option value="(주)터보소프트">()터보소프트</option>
<option value="(주)코쿤">()코쿤</option>
<option value="(주)오솔정보기술">()오솔정보기술</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">직급</label>
<select class="form-select" v-model="form.employeePosition">
<option value="">선택</option>
<optgroup label="일반">
<option value="사원">사원</option>
<option value="대리">대리</option>
<option value="과장">과장</option>
<option value="차장">차장</option>
<option value="부장">부장</option>
<option value="이사">이사</option>
</optgroup>
<optgroup label="연구소">
<option value="연구원">연구원</option>
<option value="주임연구원">주임연구원</option>
<option value="선임연구원">선임연구원</option>
<option value="책임연구원">책임연구원</option>
<option value="수석연구원">수석연구원</option>
<option value="소장">소장</option>
</optgroup>
</select>
</div>
<div class="mb-3">
<label class="form-label">연락처</label>
<input type="text" class="form-control" v-model="form.employeePhone" />
</div>
<div class="mb-3">
<label class="form-label">입사일</label>
<input type="date" class="form-control" v-model="form.joinDate" />
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="isActive" v-model="form.isActive" />
<label class="form-check-label" for="isActive">재직중</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
<i class="bi bi-save me-1"></i> 저장
</button>
<NuxtLink to="/employee" class="btn btn-outline-secondary">취소</NuxtLink>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<!-- 기본 활동 정보 -->
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-info-circle me-2"></i>기본 정보
</div>
<div class="card-body">
<div class="row">
<div class="col-6 mb-3">
<small class="text-muted d-block">등록일</small>
<span>{{ formatDateTime(employee.createdAt) }}</span>
</div>
<div class="col-6 mb-3">
<small class="text-muted d-block">최종 수정</small>
<span>{{ formatDateTime(employee.updatedAt) }}</span>
</div>
</div>
</div>
</div>
<!-- 로그인 이력 -->
<div class="card">
<div class="card-header">
<i class="bi bi-clock-history me-2"></i>로그인 이력
<small class="text-muted ms-2">(최근 20)</small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>로그인 시간</th>
<th>IP</th>
<th>로그아웃</th>
<th style="width: 80px">상태</th>
</tr>
</thead>
<tbody>
<tr v-if="loginHistory.length === 0">
<td colspan="4" class="text-center text-muted py-3">로그인 이력이 없습니다.</td>
</tr>
<tr v-for="h in loginHistory" :key="h.historyId">
<td>{{ formatDateTime(h.loginAt) }}</td>
<td><code class="small">{{ h.loginIp || '-' }}</code></td>
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
<td>
<span v-if="h.logoutAt" class="badge bg-secondary">로그아웃</span>
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
<span v-else class="badge bg-warning text-dark">세션만료</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="text-center py-5" v-else-if="isLoading">
<div class="spinner-border text-primary"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const router = useRouter()
const route = useRoute()
const employee = ref<any>(null)
const loginHistory = ref<any[]>([])
const isLoading = ref(true)
const isSubmitting = ref(false)
const form = ref({
employeeName: '',
employeeEmail: '',
company: '(주)터보소프트',
employeePosition: '',
employeePhone: '',
joinDate: '',
isActive: true
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
await loadEmployee()
})
async function loadEmployee() {
isLoading.value = true
try {
const res = await $fetch<{ employee: any, loginHistory: any[] }>(`/api/employee/${route.params.id}/detail`)
employee.value = res.employee
loginHistory.value = res.loginHistory || []
const e = res.employee
form.value = {
employeeName: e.employeeName || '',
employeeEmail: e.employeeEmail || '',
company: e.company || '(주)터보소프트',
employeePosition: e.employeePosition || '',
employeePhone: e.employeePhone || '',
joinDate: e.joinDate ? e.joinDate.split('T')[0] : '',
isActive: e.isActive
}
} catch (e: any) {
alert('직원 정보를 불러오는데 실패했습니다.')
router.push('/employee')
} finally {
isLoading.value = false
}
}
async function updateEmployee() {
if (!form.value.employeeName || !form.value.employeeEmail) {
alert('이름과 이메일은 필수입니다.')
return
}
isSubmitting.value = true
try {
await $fetch(`/api/employee/${route.params.id}/update`, {
method: 'PUT',
body: {
...form.value,
joinDate: form.value.joinDate || null,
employeePhone: form.value.employeePhone || null,
employeePosition: form.value.employeePosition || null
}
})
alert('저장되었습니다.')
router.push('/employee')
} catch (e: any) {
alert(e.data?.message || e.message || '저장에 실패했습니다.')
} finally {
isSubmitting.value = false
}
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleString('ko-KR')
}
</script>

View File

@@ -1,320 +0,0 @@
<template>
<div>
<AppHeader />
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4><i class="bi bi-people me-2"></i>직원 관리</h4>
<p class="text-muted mb-0"> {{ employees.length }}</p>
</div>
<button class="btn btn-primary" @click="showCreateModal = true">
<i class="bi bi-plus-lg me-1"></i> 직원 등록
</button>
</div>
<!-- 검색 -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<input
type="text"
class="form-control"
v-model="searchKeyword"
placeholder="이름 또는 이메일 검색"
/>
</div>
<div class="col-md-2">
<select class="form-select" v-model="filterStatus">
<option value="">전체</option>
<option value="active">재직</option>
<option value="inactive">퇴직</option>
</select>
</div>
</div>
</div>
</div>
<!-- 사원 목록 -->
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>이름</th>
<th>이메일</th>
<th style="width: 150px">소속사</th>
<th style="width: 120px">직급</th>
<th style="width: 100px">상태</th>
<th style="width: 120px">관리</th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="6" class="text-center py-4">
<span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</td>
</tr>
<tr v-else v-for="emp in filteredEmployees" :key="emp.employeeId">
<td><strong>{{ emp.employeeName }}</strong></td>
<td>{{ emp.employeeEmail }}</td>
<td>{{ emp.company || '-' }}</td>
<td>{{ emp.employeePosition || '-' }}</td>
<td>
<span :class="emp.isActive !== false ? 'badge bg-success' : 'badge bg-secondary'">
{{ emp.isActive !== false ? '재직' : '퇴직' }}
</span>
</td>
<td>
<NuxtLink
:to="`/employee/${emp.employeeId}`"
class="btn btn-sm btn-outline-primary me-1"
title="상세보기"
>
<i class="bi bi-eye"></i>
</NuxtLink>
<button
class="btn btn-sm btn-outline-danger"
@click.stop="confirmDelete(emp)"
title="삭제"
>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
<tr v-if="!isLoading && filteredEmployees.length === 0">
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">직원 정보가 없습니다.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 사원 등록 모달 -->
<div class="modal fade" :class="{ show: showCreateModal }" :style="{ display: showCreateModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">직원 등록</h5>
<button type="button" class="btn-close" @click="showCreateModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">이름 <span class="text-danger">*</span></label>
<input type="text" class="form-control" v-model="newEmployee.employeeName" required />
</div>
<div class="mb-3">
<label class="form-label">이메일 <span class="text-danger">*</span></label>
<input type="email" class="form-control" v-model="newEmployee.employeeEmail" required />
</div>
<div class="mb-3">
<label class="form-label">소속사</label>
<select class="form-select" v-model="newEmployee.company">
<option value="(주)터보소프트">()터보소프트</option>
<option value="(주)코쿤">()코쿤</option>
<option value="(주)오솔정보기술">()오솔정보기술</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">직급</label>
<select class="form-select" v-model="newEmployee.employeePosition">
<option value="">선택</option>
<optgroup label="일반">
<option value="사원">사원</option>
<option value="대리">대리</option>
<option value="과장">과장</option>
<option value="차장">차장</option>
<option value="부장">부장</option>
<option value="이사">이사</option>
</optgroup>
<optgroup label="연구소">
<option value="연구원">연구원</option>
<option value="주임연구원">주임연구원</option>
<option value="선임연구원">선임연구원</option>
<option value="책임연구원">책임연구원</option>
<option value="수석연구원">수석연구원</option>
<option value="소장">소장</option>
</optgroup>
</select>
</div>
<div class="mb-3">
<label class="form-label">연락처</label>
<input type="text" class="form-control" v-model="newEmployee.employeePhone" />
</div>
<div class="mb-3">
<label class="form-label">입사일</label>
<input type="date" class="form-control" v-model="newEmployee.joinDate" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">취소</button>
<button type="button" class="btn btn-primary" @click="createEmployee">
<i class="bi bi-check-lg me-1"></i> 등록
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" v-if="showCreateModal"></div>
<!-- 삭제 확인 모달 -->
<div class="modal fade" :class="{ show: showDeleteModal }" :style="{ display: showDeleteModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle me-2"></i>직원 삭제
</h5>
<button type="button" class="btn-close" @click="showDeleteModal = false"></button>
</div>
<div class="modal-body">
<p>
<strong>{{ deleteTarget?.employeeName }}</strong> ({{ deleteTarget?.employeeEmail }}) 님을 삭제하시겠습니까?
</p>
<div class="alert alert-warning small">
<i class="bi bi-info-circle me-1"></i>
주간보고가 있는 경우 비활성화 처리됩니다.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">취소</button>
<button type="button" class="btn btn-danger" @click="deleteEmployee" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-trash me-1"></i>삭제
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" v-if="showDeleteModal"></div>
</div>
</template>
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const router = useRouter()
const employees = ref<any[]>([])
const searchKeyword = ref('')
const filterStatus = ref('')
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const deleteTarget = ref<any>(null)
const isLoading = ref(true)
const isDeleting = ref(false)
const newEmployee = ref({
employeeName: '',
employeeEmail: '',
company: '(주)터보소프트',
employeePosition: '',
employeePhone: '',
joinDate: ''
})
// 검색어/필터로 자동 필터링
const filteredEmployees = computed(() => {
let list = employees.value
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
list = list.filter(e =>
e.employeeName?.toLowerCase().includes(keyword) ||
e.employeeEmail?.toLowerCase().includes(keyword)
)
}
if (filterStatus.value === 'active') {
list = list.filter(e => e.isActive !== false)
} else if (filterStatus.value === 'inactive') {
list = list.filter(e => e.isActive === false)
}
return list
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
await loadEmployees()
})
async function loadEmployees() {
isLoading.value = true
try {
const res = await $fetch<{ employees: any[] }>('/api/employee/list')
employees.value = res.employees || []
} catch (e) {
console.error('Load employees error:', e)
} finally {
isLoading.value = false
}
}
async function createEmployee() {
if (!newEmployee.value.employeeName || !newEmployee.value.employeeEmail) {
alert('이름과 이메일은 필수입니다.')
return
}
try {
await $fetch('/api/employee/create', {
method: 'POST',
body: newEmployee.value
})
showCreateModal.value = false
newEmployee.value = {
employeeName: '',
employeeEmail: '',
company: '(주)터보소프트',
employeePosition: '',
employeePhone: '',
joinDate: ''
}
await loadEmployees()
} catch (e: any) {
alert(e.data?.message || e.message || '등록에 실패했습니다.')
}
}
function confirmDelete(emp: any) {
deleteTarget.value = emp
showDeleteModal.value = true
}
async function deleteEmployee() {
if (!deleteTarget.value) return
isDeleting.value = true
try {
const res = await $fetch<{ success: boolean; action: string; message: string }>(
`/api/employee/${deleteTarget.value.employeeId}/delete`,
{ method: 'DELETE' }
)
alert(res.message)
showDeleteModal.value = false
deleteTarget.value = null
await loadEmployees()
} catch (e: any) {
alert(e.data?.message || e.message || '삭제에 실패했습니다.')
} finally {
isDeleting.value = false
}
}
</script>
<style scoped>
.modal.show {
background: rgba(0, 0, 0, 0.5);
}
</style>

View File

@@ -44,6 +44,21 @@
<div class="col-3 text-muted">입사일</div>
<div class="col-9">{{ userInfo.joinDate ? userInfo.joinDate.split('T')[0] : '-' }}</div>
</div>
<hr class="my-2" />
<div class="row mb-2">
<div class="col-3 text-muted small">최초입력</div>
<div class="col-9 small text-muted">
{{ formatDateTime(userInfo.createdAt) }}
<code v-if="userInfo.createdIp" class="ms-2">{{ userInfo.createdIp }}</code>
</div>
</div>
<div class="row mb-2">
<div class="col-3 text-muted small">최종수정</div>
<div class="col-9 small text-muted">
{{ formatDateTime(userInfo.updatedAt) }}
<code v-if="userInfo.updatedIp" class="ms-2">{{ userInfo.updatedIp }}</code>
</div>
</div>
</div>
<!-- 수정 모드 -->
@@ -77,12 +92,13 @@
<select class="form-select" v-model="editForm.employeePosition">
<option value="">선택</option>
<optgroup label="일반">
<option value="인턴">인턴</option>
<option value="사원">사원</option>
<option value="주임">주임</option>
<option value="대리">대리</option>
<option value="과장">과장</option>
<option value="차장">차장</option>
<option value="부장">부장</option>
<option value="이사">이사</option>
</optgroup>
<optgroup label="연구소">
<option value="연구원">연구원</option>
@@ -90,7 +106,22 @@
<option value="선임연구원">선임연구원</option>
<option value="책임연구원">책임연구원</option>
<option value="수석연구원">수석연구원</option>
<option value="소장">소장</option>
<option value="연구소장">연구소장</option>
</optgroup>
<optgroup label="임원">
<option value="이사">이사</option>
<option value="상무이사">상무이사</option>
<option value="전무이사">전무이사</option>
<option value="부사장">부사장</option>
<option value="사장">사장</option>
<option value="대표이사">대표이사</option>
</optgroup>
<optgroup label="기타">
<option value="팀장">팀장</option>
<option value="실장">실장</option>
<option value="본부장">본부장</option>
<option value="고문">고문</option>
<option value="감사">감사</option>
</optgroup>
</select>
</div>
@@ -255,13 +286,12 @@ async function saveProfile() {
function formatDateTime(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
const second = String(d.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
</script>

View File

@@ -189,7 +189,7 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
const { fetchCurrentUser } = useAuth()
const { fetchCurrentUser, hasMenuAccess } = useAuth()
const { getCurrentWeekInfo, getWeekDates } = useWeekCalc()
const router = useRouter()
@@ -221,6 +221,12 @@ onMounted(async () => {
return
}
if (!hasMenuAccess('REPORT_SUMMARY')) {
alert('접근 권한이 없습니다.')
router.push('/')
return
}
await loadWeeklyList()
})

View File

@@ -690,7 +690,7 @@
<script setup lang="ts">
import { nextTick } from 'vue'
const { currentUser, fetchCurrentUser } = useAuth()
const { currentUser, fetchCurrentUser, hasRole } = useAuth()
const { getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter()
const route = useRoute()
@@ -721,7 +721,7 @@ const aiIsDragging = ref(false)
const isAiParsing = ref(false)
const aiParsedResult = ref<any>(null)
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
const isAdmin = computed(() => hasRole('ROLE_ADMIN'))
interface EditTask {
projectId: number

View File

@@ -138,7 +138,7 @@
</template>
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const { fetchCurrentUser, hasRole } = useAuth()
const { getCurrentWeekInfo, getActualCurrentWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter()
const route = useRoute()
@@ -147,7 +147,7 @@ const reports = ref<any[]>([])
const employees = ref<any[]>([])
const projects = ref<any[]>([])
const isLoading = ref(true)
const isAdmin = ref(false)
const isAdmin = computed(() => hasRole('ROLE_ADMIN'))
const currentWeek = getCurrentWeekInfo()
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
@@ -214,8 +214,6 @@ onMounted(async () => {
return
}
isAdmin.value = user.employeeEmail === 'coziny@gmail.com'
// URL 쿼리 파라미터가 있으면 필터에 적용
if (route.query.year && route.query.week) {
filters.value.year = parseInt(route.query.year as string)