추가
This commit is contained in:
32
backend/api/auth/current-user.get.ts
Normal file
32
backend/api/auth/current-user.get.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { queryOne } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자 정보
|
||||
* GET /api/auth/current-user
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
|
||||
if (!userId) {
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info
|
||||
WHERE employee_id = $1 AND is_active = true
|
||||
`, [parseInt(userId)])
|
||||
|
||||
if (!employee) {
|
||||
deleteCookie(event, 'user_id')
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePosition: employee.employee_position
|
||||
}
|
||||
}
|
||||
})
|
||||
62
backend/api/auth/login.post.ts
Normal file
62
backend/api/auth/login.post.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { query, insertReturning, execute } from '../../utils/db'
|
||||
|
||||
interface LoginBody {
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일+이름 로그인 (임시)
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LoginBody>(event)
|
||||
|
||||
if (!body.email || !body.name) {
|
||||
throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' })
|
||||
}
|
||||
|
||||
// 이메일 형식 검증
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
throw createError({ statusCode: 400, message: '올바른 이메일 형식이 아닙니다.' })
|
||||
}
|
||||
|
||||
// 기존 사원 조회
|
||||
let employee = await query<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_email = $1
|
||||
`, [body.email.toLowerCase()])
|
||||
|
||||
let employeeData = employee[0]
|
||||
|
||||
// 없으면 자동 등록
|
||||
if (!employeeData) {
|
||||
employeeData = await insertReturning(`
|
||||
INSERT INTO wr_employee_info (employee_name, employee_email)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *
|
||||
`, [body.name, body.email.toLowerCase()])
|
||||
}
|
||||
|
||||
// 로그인 이력 추가
|
||||
await execute(`
|
||||
INSERT INTO wr_login_history (employee_id) VALUES ($1)
|
||||
`, [employeeData.employee_id])
|
||||
|
||||
// 쿠키에 사용자 정보 저장 (간단한 임시 세션)
|
||||
setCookie(event, 'user_id', String(employeeData.employee_id), {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7일
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
employeeId: employeeData.employee_id,
|
||||
employeeName: employeeData.employee_name,
|
||||
employeeEmail: employeeData.employee_email,
|
||||
employeePosition: employeeData.employee_position
|
||||
}
|
||||
}
|
||||
})
|
||||
8
backend/api/auth/logout.post.ts
Normal file
8
backend/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 로그아웃
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
deleteCookie(event, 'user_id')
|
||||
return { success: true }
|
||||
})
|
||||
23
backend/api/auth/recent-users.get.ts
Normal file
23
backend/api/auth/recent-users.get.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 최근 로그인 사용자 목록
|
||||
* GET /api/auth/recent-users
|
||||
*/
|
||||
export default defineEventHandler(async () => {
|
||||
const users = await query(`
|
||||
SELECT * FROM wr_recent_login_users
|
||||
ORDER BY last_active_at DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return {
|
||||
users: users.map((u: any) => ({
|
||||
employeeId: u.employee_id,
|
||||
employeeName: u.employee_name,
|
||||
employeeEmail: u.employee_email,
|
||||
employeePosition: u.employee_position,
|
||||
lastActiveAt: u.last_active_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
49
backend/api/auth/select-user.post.ts
Normal file
49
backend/api/auth/select-user.post.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { queryOne, execute } from '../../utils/db'
|
||||
|
||||
interface SelectUserBody {
|
||||
employeeId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 사용자 선택 로그인
|
||||
* POST /api/auth/select-user
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<SelectUserBody>(event)
|
||||
|
||||
if (!body.employeeId) {
|
||||
throw createError({ statusCode: 400, message: '사용자를 선택해주세요.' })
|
||||
}
|
||||
|
||||
// 사원 조회
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info
|
||||
WHERE employee_id = $1 AND is_active = true
|
||||
`, [body.employeeId])
|
||||
|
||||
if (!employee) {
|
||||
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 로그인 이력 추가
|
||||
await execute(`
|
||||
INSERT INTO wr_login_history (employee_id) VALUES ($1)
|
||||
`, [employee.employee_id])
|
||||
|
||||
// 쿠키 설정
|
||||
setCookie(event, 'user_id', String(employee.employee_id), {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePosition: employee.employee_position
|
||||
}
|
||||
}
|
||||
})
|
||||
30
backend/api/employee/[id]/detail.get.ts
Normal file
30
backend/api/employee/[id]/detail.get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사원 상세 조회
|
||||
* GET /api/employee/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
if (!employee) {
|
||||
throw createError({ statusCode: 404, message: '사원을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
employeeId: employee.employee_id,
|
||||
employeeNumber: employee.employee_number,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePhone: employee.employee_phone,
|
||||
employeePosition: employee.employee_position,
|
||||
joinDate: employee.join_date,
|
||||
isActive: employee.is_active,
|
||||
createdAt: employee.created_at,
|
||||
updatedAt: employee.updated_at
|
||||
}
|
||||
})
|
||||
49
backend/api/employee/[id]/update.put.ts
Normal file
49
backend/api/employee/[id]/update.put.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
interface UpdateEmployeeBody {
|
||||
employeeNumber?: string
|
||||
employeeName?: string
|
||||
employeePhone?: string
|
||||
employeePosition?: string
|
||||
joinDate?: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 정보 수정
|
||||
* PUT /api/employee/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
const body = await readBody<UpdateEmployeeBody>(event)
|
||||
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '사원을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_employee_info SET
|
||||
employee_number = $1,
|
||||
employee_name = $2,
|
||||
employee_phone = $3,
|
||||
employee_position = $4,
|
||||
join_date = $5,
|
||||
is_active = $6,
|
||||
updated_at = NOW()
|
||||
WHERE employee_id = $7
|
||||
`, [
|
||||
body.employeeNumber ?? existing.employee_number,
|
||||
body.employeeName ?? existing.employee_name,
|
||||
body.employeePhone ?? existing.employee_phone,
|
||||
body.employeePosition ?? existing.employee_position,
|
||||
body.joinDate ?? existing.join_date,
|
||||
body.isActive ?? existing.is_active,
|
||||
employeeId
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
55
backend/api/employee/create.post.ts
Normal file
55
backend/api/employee/create.post.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { insertReturning, queryOne } from '../../utils/db'
|
||||
|
||||
interface CreateEmployeeBody {
|
||||
employeeNumber?: string
|
||||
employeeName: string
|
||||
employeeEmail: string
|
||||
employeePhone?: string
|
||||
employeePosition?: string
|
||||
joinDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 등록
|
||||
* POST /api/employee/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateEmployeeBody>(event)
|
||||
|
||||
if (!body.employeeName || !body.employeeEmail) {
|
||||
throw createError({ statusCode: 400, message: '이름과 이메일은 필수입니다.' })
|
||||
}
|
||||
|
||||
// 이메일 중복 체크
|
||||
const existing = await queryOne(`
|
||||
SELECT employee_id FROM wr_employee_info WHERE employee_email = $1
|
||||
`, [body.employeeEmail.toLowerCase()])
|
||||
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: '이미 등록된 이메일입니다.' })
|
||||
}
|
||||
|
||||
const employee = await insertReturning(`
|
||||
INSERT INTO wr_employee_info (
|
||||
employee_number, employee_name, employee_email,
|
||||
employee_phone, employee_position, join_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.employeeNumber || null,
|
||||
body.employeeName,
|
||||
body.employeeEmail.toLowerCase(),
|
||||
body.employeePhone || null,
|
||||
body.employeePosition || null,
|
||||
body.joinDate || null
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employee: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email
|
||||
}
|
||||
}
|
||||
})
|
||||
30
backend/api/employee/list.get.ts
Normal file
30
backend/api/employee/list.get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 사원 목록 조회
|
||||
* GET /api/employee/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const activeOnly = queryParams.activeOnly !== 'false'
|
||||
|
||||
let sql = `
|
||||
SELECT * FROM wr_employee_info
|
||||
${activeOnly ? 'WHERE is_active = true' : ''}
|
||||
ORDER BY employee_name
|
||||
`
|
||||
|
||||
const employees = await query(sql)
|
||||
|
||||
return employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeNumber: e.employee_number,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email,
|
||||
employeePhone: e.employee_phone,
|
||||
employeePosition: e.employee_position,
|
||||
joinDate: e.join_date,
|
||||
isActive: e.is_active,
|
||||
createdAt: e.created_at
|
||||
}))
|
||||
})
|
||||
29
backend/api/employee/search.get.ts
Normal file
29
backend/api/employee/search.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 사원 검색 (이름/이메일)
|
||||
* GET /api/employee/search?q=keyword
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const keyword = queryParams.q as string
|
||||
|
||||
if (!keyword || keyword.length < 1) {
|
||||
return []
|
||||
}
|
||||
|
||||
const employees = await query(`
|
||||
SELECT * FROM wr_employee_info
|
||||
WHERE is_active = true
|
||||
AND (employee_name ILIKE $1 OR employee_email ILIKE $1)
|
||||
ORDER BY employee_name
|
||||
LIMIT 20
|
||||
`, [`%${keyword}%`])
|
||||
|
||||
return employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email,
|
||||
employeePosition: e.employee_position
|
||||
}))
|
||||
})
|
||||
41
backend/api/project/[id]/detail.get.ts
Normal file
41
backend/api/project/[id]/detail.get.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { queryOne, query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 프로젝트 상세 조회
|
||||
* GET /api/project/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const projectId = getRouterParam(event, 'id')
|
||||
|
||||
const project = await queryOne<any>(`
|
||||
SELECT * FROM wr_project_info WHERE project_id = $1
|
||||
`, [projectId])
|
||||
|
||||
if (!project) {
|
||||
throw createError({ statusCode: 404, message: '프로젝트를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 현재 PM/PL
|
||||
const managers = await query(`
|
||||
SELECT * FROM wr_project_manager_current WHERE project_id = $1
|
||||
`, [projectId])
|
||||
|
||||
const pm = managers.find((m: any) => m.role_type === 'PM')
|
||||
const pl = managers.find((m: any) => m.role_type === 'PL')
|
||||
|
||||
return {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
clientName: project.client_name,
|
||||
projectDescription: project.project_description,
|
||||
startDate: project.start_date,
|
||||
endDate: project.end_date,
|
||||
contractAmount: project.contract_amount,
|
||||
projectStatus: project.project_status,
|
||||
createdAt: project.created_at,
|
||||
updatedAt: project.updated_at,
|
||||
currentPm: pm ? { employeeId: pm.employee_id, employeeName: pm.employee_name } : null,
|
||||
currentPl: pl ? { employeeId: pl.employee_id, employeeName: pl.employee_name } : null
|
||||
}
|
||||
})
|
||||
50
backend/api/project/[id]/manager-assign.post.ts
Normal file
50
backend/api/project/[id]/manager-assign.post.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { execute, queryOne, insertReturning } from '../../../utils/db'
|
||||
import { formatDate } from '../../../utils/week-calc'
|
||||
|
||||
interface AssignManagerBody {
|
||||
employeeId: number
|
||||
roleType: 'PM' | 'PL'
|
||||
startDate?: string
|
||||
changeReason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PM/PL 지정 (기존 담당자 종료 + 신규 등록)
|
||||
* POST /api/project/[id]/manager-assign
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const projectId = getRouterParam(event, 'id')
|
||||
const body = await readBody<AssignManagerBody>(event)
|
||||
|
||||
if (!body.employeeId || !body.roleType) {
|
||||
throw createError({ statusCode: 400, message: '담당자와 역할을 선택해주세요.' })
|
||||
}
|
||||
|
||||
if (!['PM', 'PL'].includes(body.roleType)) {
|
||||
throw createError({ statusCode: 400, message: '역할은 PM 또는 PL만 가능합니다.' })
|
||||
}
|
||||
|
||||
const today = formatDate(new Date())
|
||||
const startDate = body.startDate || today
|
||||
|
||||
// 기존 담당자 종료 (end_date가 NULL인 경우)
|
||||
await execute(`
|
||||
UPDATE wr_project_manager_history SET
|
||||
end_date = $1,
|
||||
change_reason = COALESCE(change_reason || ' / ', '') || '신규 담당자 지정으로 종료'
|
||||
WHERE project_id = $2 AND role_type = $3 AND end_date IS NULL
|
||||
`, [startDate, projectId, body.roleType])
|
||||
|
||||
// 신규 담당자 등록
|
||||
const history = await insertReturning(`
|
||||
INSERT INTO wr_project_manager_history (
|
||||
project_id, employee_id, role_type, start_date, change_reason
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
`, [projectId, body.employeeId, body.roleType, startDate, body.changeReason || null])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
historyId: history.history_id
|
||||
}
|
||||
})
|
||||
30
backend/api/project/[id]/manager-history.get.ts
Normal file
30
backend/api/project/[id]/manager-history.get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 프로젝트 PM/PL 이력 조회
|
||||
* GET /api/project/[id]/manager-history
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const projectId = getRouterParam(event, 'id')
|
||||
|
||||
const history = await query(`
|
||||
SELECT h.*, e.employee_name, e.employee_email
|
||||
FROM wr_project_manager_history h
|
||||
JOIN wr_employee_info e ON h.employee_id = e.employee_id
|
||||
WHERE h.project_id = $1
|
||||
ORDER BY h.role_type, h.start_date DESC
|
||||
`, [projectId])
|
||||
|
||||
return history.map((h: any) => ({
|
||||
historyId: h.history_id,
|
||||
projectId: h.project_id,
|
||||
employeeId: h.employee_id,
|
||||
employeeName: h.employee_name,
|
||||
employeeEmail: h.employee_email,
|
||||
roleType: h.role_type,
|
||||
startDate: h.start_date,
|
||||
endDate: h.end_date,
|
||||
changeReason: h.change_reason,
|
||||
createdAt: h.created_at
|
||||
}))
|
||||
})
|
||||
55
backend/api/project/[id]/update.put.ts
Normal file
55
backend/api/project/[id]/update.put.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
interface UpdateProjectBody {
|
||||
projectCode?: string
|
||||
projectName?: string
|
||||
clientName?: string
|
||||
projectDescription?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
contractAmount?: number
|
||||
projectStatus?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 정보 수정
|
||||
* PUT /api/project/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const projectId = getRouterParam(event, 'id')
|
||||
const body = await readBody<UpdateProjectBody>(event)
|
||||
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT * FROM wr_project_info WHERE project_id = $1
|
||||
`, [projectId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '프로젝트를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_project_info SET
|
||||
project_code = $1,
|
||||
project_name = $2,
|
||||
client_name = $3,
|
||||
project_description = $4,
|
||||
start_date = $5,
|
||||
end_date = $6,
|
||||
contract_amount = $7,
|
||||
project_status = $8,
|
||||
updated_at = NOW()
|
||||
WHERE project_id = $9
|
||||
`, [
|
||||
body.projectCode ?? existing.project_code,
|
||||
body.projectName ?? existing.project_name,
|
||||
body.clientName ?? existing.client_name,
|
||||
body.projectDescription ?? existing.project_description,
|
||||
body.startDate ?? existing.start_date,
|
||||
body.endDate ?? existing.end_date,
|
||||
body.contractAmount ?? existing.contract_amount,
|
||||
body.projectStatus ?? existing.project_status,
|
||||
projectId
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
47
backend/api/project/create.post.ts
Normal file
47
backend/api/project/create.post.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
|
||||
interface CreateProjectBody {
|
||||
projectCode?: string
|
||||
projectName: string
|
||||
clientName?: string
|
||||
projectDescription?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
contractAmount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 등록
|
||||
* POST /api/project/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateProjectBody>(event)
|
||||
|
||||
if (!body.projectName) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트명을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const project = await insertReturning(`
|
||||
INSERT INTO wr_project_info (
|
||||
project_code, project_name, client_name, project_description,
|
||||
start_date, end_date, contract_amount
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.projectCode || null,
|
||||
body.projectName,
|
||||
body.clientName || null,
|
||||
body.projectDescription || null,
|
||||
body.startDate || null,
|
||||
body.endDate || null,
|
||||
body.contractAmount || null
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
project: {
|
||||
projectId: project.project_id,
|
||||
projectName: project.project_name
|
||||
}
|
||||
}
|
||||
})
|
||||
52
backend/api/project/list.get.ts
Normal file
52
backend/api/project/list.get.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 프로젝트 목록 조회
|
||||
* GET /api/project/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const status = queryParams.status as string || null
|
||||
|
||||
let sql = `
|
||||
SELECT p.*,
|
||||
(SELECT employee_name FROM wr_employee_info e
|
||||
JOIN wr_project_manager_history pm ON e.employee_id = pm.employee_id
|
||||
WHERE pm.project_id = p.project_id AND pm.role_type = 'PM'
|
||||
AND (pm.end_date IS NULL OR pm.end_date >= CURRENT_DATE)
|
||||
LIMIT 1) as pm_name,
|
||||
(SELECT employee_name FROM wr_employee_info e
|
||||
JOIN wr_project_manager_history pm ON e.employee_id = pm.employee_id
|
||||
WHERE pm.project_id = p.project_id AND pm.role_type = 'PL'
|
||||
AND (pm.end_date IS NULL OR pm.end_date >= CURRENT_DATE)
|
||||
LIMIT 1) as pl_name
|
||||
FROM wr_project_info p
|
||||
`
|
||||
|
||||
const params: any[] = []
|
||||
if (status) {
|
||||
sql += ' WHERE p.project_status = $1'
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY p.created_at DESC'
|
||||
|
||||
const projects = await query(sql, params)
|
||||
|
||||
return {
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name,
|
||||
clientName: p.client_name,
|
||||
projectDescription: p.project_description,
|
||||
startDate: p.start_date,
|
||||
endDate: p.end_date,
|
||||
contractAmount: p.contract_amount,
|
||||
projectStatus: p.project_status,
|
||||
pmName: p.pm_name,
|
||||
plName: p.pl_name,
|
||||
createdAt: p.created_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
30
backend/api/project/my-projects.get.ts
Normal file
30
backend/api/project/my-projects.get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 내가 보고서 작성한 프로젝트 목록
|
||||
* GET /api/project/my-projects
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
// 내가 주간보고를 작성한 프로젝트 + 전체 활성 프로젝트
|
||||
const projects = await query(`
|
||||
SELECT DISTINCT p.*,
|
||||
CASE WHEN r.author_id IS NOT NULL THEN true ELSE false END as has_my_report
|
||||
FROM wr_project_info p
|
||||
LEFT JOIN wr_weekly_report_detail r ON p.project_id = r.project_id AND r.author_id = $1
|
||||
WHERE p.project_status = 'ACTIVE'
|
||||
ORDER BY has_my_report DESC, p.project_name
|
||||
`, [parseInt(userId)])
|
||||
|
||||
return projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name,
|
||||
clientName: p.client_name,
|
||||
hasMyReport: p.has_my_report
|
||||
}))
|
||||
})
|
||||
65
backend/api/report/summary/[id]/detail.get.ts
Normal file
65
backend/api/report/summary/[id]/detail.get.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { queryOne, query } from '../../../../utils/db'
|
||||
|
||||
/**
|
||||
* 취합 보고서 상세 (개별 보고서 포함)
|
||||
* GET /api/report/summary/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const summaryId = getRouterParam(event, 'id')
|
||||
|
||||
// 취합 보고서 조회
|
||||
const summary = await queryOne<any>(`
|
||||
SELECT s.*, p.project_name, p.project_code,
|
||||
e.employee_name as reviewer_name
|
||||
FROM wr_aggregated_report_summary s
|
||||
JOIN wr_project_info p ON s.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON s.reviewer_id = e.employee_id
|
||||
WHERE s.summary_id = $1
|
||||
`, [summaryId])
|
||||
|
||||
if (!summary) {
|
||||
throw createError({ statusCode: 404, message: '취합 보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 개별 보고서 목록
|
||||
const reports = await query(`
|
||||
SELECT r.*, e.employee_name as author_name, e.employee_position
|
||||
FROM wr_weekly_report_detail r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.project_id = $1 AND r.report_year = $2 AND r.report_week = $3
|
||||
ORDER BY e.employee_name
|
||||
`, [summary.project_id, summary.report_year, summary.report_week])
|
||||
|
||||
return {
|
||||
summary: {
|
||||
summaryId: summary.summary_id,
|
||||
projectId: summary.project_id,
|
||||
projectName: summary.project_name,
|
||||
projectCode: summary.project_code,
|
||||
reportYear: summary.report_year,
|
||||
reportWeek: summary.report_week,
|
||||
weekStartDate: summary.week_start_date,
|
||||
weekEndDate: summary.week_end_date,
|
||||
memberCount: summary.member_count,
|
||||
totalWorkHours: summary.total_work_hours,
|
||||
reviewerId: summary.reviewer_id,
|
||||
reviewerName: summary.reviewer_name,
|
||||
reviewerComment: summary.reviewer_comment,
|
||||
reviewedAt: summary.reviewed_at,
|
||||
summaryStatus: summary.summary_status
|
||||
},
|
||||
reports: reports.map((r: any) => ({
|
||||
reportId: r.report_id,
|
||||
authorId: r.author_id,
|
||||
authorName: r.author_name,
|
||||
authorPosition: r.employee_position,
|
||||
workDescription: r.work_description,
|
||||
planDescription: r.plan_description,
|
||||
issueDescription: r.issue_description,
|
||||
remarkDescription: r.remark_description,
|
||||
workHours: r.work_hours,
|
||||
reportStatus: r.report_status,
|
||||
submittedAt: r.submitted_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
39
backend/api/report/summary/[id]/review.put.ts
Normal file
39
backend/api/report/summary/[id]/review.put.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { execute, queryOne } from '../../../../utils/db'
|
||||
|
||||
interface ReviewBody {
|
||||
reviewerComment?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PM 검토/코멘트 작성
|
||||
* PUT /api/report/summary/[id]/review
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const summaryId = getRouterParam(event, 'id')
|
||||
const body = await readBody<ReviewBody>(event)
|
||||
|
||||
const summary = await queryOne<any>(`
|
||||
SELECT * FROM wr_aggregated_report_summary WHERE summary_id = $1
|
||||
`, [summaryId])
|
||||
|
||||
if (!summary) {
|
||||
throw createError({ statusCode: 404, message: '취합 보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_aggregated_report_summary SET
|
||||
reviewer_id = $1,
|
||||
reviewer_comment = $2,
|
||||
reviewed_at = NOW(),
|
||||
summary_status = 'REVIEWED',
|
||||
updated_at = NOW()
|
||||
WHERE summary_id = $3
|
||||
`, [parseInt(userId), body.reviewerComment || null, summaryId])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
54
backend/api/report/summary/list.get.ts
Normal file
54
backend/api/report/summary/list.get.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 취합 보고서 목록
|
||||
* GET /api/report/summary/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null
|
||||
const year = queryParams.year ? parseInt(queryParams.year as string) : null
|
||||
|
||||
let sql = `
|
||||
SELECT s.*, p.project_name, p.project_code,
|
||||
e.employee_name as reviewer_name
|
||||
FROM wr_aggregated_report_summary s
|
||||
JOIN wr_project_info p ON s.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON s.reviewer_id = e.employee_id
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (projectId) {
|
||||
sql += ` AND s.project_id = $${paramIndex++}`
|
||||
params.push(projectId)
|
||||
}
|
||||
if (year) {
|
||||
sql += ` AND s.report_year = $${paramIndex++}`
|
||||
params.push(year)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY s.report_year DESC, s.report_week DESC, p.project_name'
|
||||
|
||||
const summaries = await query(sql, params)
|
||||
|
||||
return summaries.map((s: any) => ({
|
||||
summaryId: s.summary_id,
|
||||
projectId: s.project_id,
|
||||
projectName: s.project_name,
|
||||
projectCode: s.project_code,
|
||||
reportYear: s.report_year,
|
||||
reportWeek: s.report_week,
|
||||
weekStartDate: s.week_start_date,
|
||||
weekEndDate: s.week_end_date,
|
||||
memberCount: s.member_count,
|
||||
totalWorkHours: s.total_work_hours,
|
||||
reviewerId: s.reviewer_id,
|
||||
reviewerName: s.reviewer_name,
|
||||
reviewerComment: s.reviewer_comment,
|
||||
reviewedAt: s.reviewed_at,
|
||||
summaryStatus: s.summary_status,
|
||||
aggregatedAt: s.aggregated_at
|
||||
}))
|
||||
})
|
||||
48
backend/api/report/weekly/[id]/detail.get.ts
Normal file
48
backend/api/report/weekly/[id]/detail.get.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { queryOne } from '../../../../utils/db'
|
||||
|
||||
/**
|
||||
* 주간보고 상세 조회
|
||||
* GET /api/report/weekly/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
|
||||
const report = await queryOne<any>(`
|
||||
SELECT r.*, p.project_name, p.project_code, e.employee_name as author_name
|
||||
FROM wr_weekly_report_detail r
|
||||
JOIN wr_project_info p ON r.project_id = p.project_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
reportId: report.report_id,
|
||||
projectId: report.project_id,
|
||||
projectName: report.project_name,
|
||||
projectCode: report.project_code,
|
||||
authorId: report.author_id,
|
||||
authorName: report.author_name,
|
||||
reportYear: report.report_year,
|
||||
reportWeek: report.report_week,
|
||||
weekStartDate: report.week_start_date,
|
||||
weekEndDate: report.week_end_date,
|
||||
workDescription: report.work_description,
|
||||
planDescription: report.plan_description,
|
||||
issueDescription: report.issue_description,
|
||||
remarkDescription: report.remark_description,
|
||||
workHours: report.work_hours,
|
||||
reportStatus: report.report_status,
|
||||
submittedAt: report.submitted_at,
|
||||
createdAt: report.created_at,
|
||||
updatedAt: report.updated_at
|
||||
}
|
||||
})
|
||||
37
backend/api/report/weekly/[id]/submit.post.ts
Normal file
37
backend/api/report/weekly/[id]/submit.post.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { execute, queryOne } from '../../../../utils/db'
|
||||
|
||||
/**
|
||||
* 주간보고 제출
|
||||
* POST /api/report/weekly/[id]/submit
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
|
||||
// 보고서 조회 및 권한 확인
|
||||
const report = await queryOne<any>(`
|
||||
SELECT * FROM wr_weekly_report_detail WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (report.author_id !== parseInt(userId)) {
|
||||
throw createError({ statusCode: 403, message: '본인의 보고서만 제출할 수 있습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report_detail SET
|
||||
report_status = 'SUBMITTED',
|
||||
submitted_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
56
backend/api/report/weekly/[id]/update.put.ts
Normal file
56
backend/api/report/weekly/[id]/update.put.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { execute, queryOne } from '../../../../utils/db'
|
||||
|
||||
interface UpdateReportBody {
|
||||
workDescription?: string
|
||||
planDescription?: string
|
||||
issueDescription?: string
|
||||
remarkDescription?: string
|
||||
workHours?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 수정
|
||||
* PUT /api/report/weekly/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
const body = await readBody<UpdateReportBody>(event)
|
||||
|
||||
// 보고서 조회 및 권한 확인
|
||||
const report = await queryOne<any>(`
|
||||
SELECT * FROM wr_weekly_report_detail WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (report.author_id !== parseInt(userId)) {
|
||||
throw createError({ statusCode: 403, message: '본인의 보고서만 수정할 수 있습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report_detail SET
|
||||
work_description = $1,
|
||||
plan_description = $2,
|
||||
issue_description = $3,
|
||||
remark_description = $4,
|
||||
work_hours = $5,
|
||||
updated_at = NOW()
|
||||
WHERE report_id = $6
|
||||
`, [
|
||||
body.workDescription ?? report.work_description,
|
||||
body.planDescription ?? report.plan_description,
|
||||
body.issueDescription ?? report.issue_description,
|
||||
body.remarkDescription ?? report.remark_description,
|
||||
body.workHours ?? report.work_hours,
|
||||
reportId
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
75
backend/api/report/weekly/create.post.ts
Normal file
75
backend/api/report/weekly/create.post.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { insertReturning, queryOne } from '../../../utils/db'
|
||||
import { getWeekInfo } from '../../../utils/week-calc'
|
||||
|
||||
interface CreateReportBody {
|
||||
projectId: number
|
||||
reportYear?: number
|
||||
reportWeek?: number
|
||||
workDescription?: string
|
||||
planDescription?: string
|
||||
issueDescription?: string
|
||||
remarkDescription?: string
|
||||
workHours?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 작성
|
||||
* POST /api/report/weekly/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<CreateReportBody>(event)
|
||||
|
||||
if (!body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' })
|
||||
}
|
||||
|
||||
// 주차 정보 (기본값: 이번 주)
|
||||
const weekInfo = getWeekInfo()
|
||||
const year = body.reportYear || weekInfo.year
|
||||
const week = body.reportWeek || weekInfo.week
|
||||
|
||||
// 중복 체크
|
||||
const existing = await queryOne(`
|
||||
SELECT report_id FROM wr_weekly_report_detail
|
||||
WHERE project_id = $1 AND author_id = $2 AND report_year = $3 AND report_week = $4
|
||||
`, [body.projectId, parseInt(userId), year, week])
|
||||
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: '이미 해당 주차 보고서가 존재합니다.' })
|
||||
}
|
||||
|
||||
// 주차 날짜 계산
|
||||
const dates = getWeekInfo(new Date(year, 0, 4 + (week - 1) * 7))
|
||||
|
||||
const report = await insertReturning(`
|
||||
INSERT INTO wr_weekly_report_detail (
|
||||
project_id, author_id, report_year, report_week,
|
||||
week_start_date, week_end_date,
|
||||
work_description, plan_description, issue_description, remark_description,
|
||||
work_hours, report_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'DRAFT')
|
||||
RETURNING *
|
||||
`, [
|
||||
body.projectId,
|
||||
parseInt(userId),
|
||||
year,
|
||||
week,
|
||||
dates.startDateStr,
|
||||
dates.endDateStr,
|
||||
body.workDescription || null,
|
||||
body.planDescription || null,
|
||||
body.issueDescription || null,
|
||||
body.remarkDescription || null,
|
||||
body.workHours || null
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reportId: report.report_id
|
||||
}
|
||||
})
|
||||
46
backend/api/report/weekly/current-week.get.ts
Normal file
46
backend/api/report/weekly/current-week.get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { getWeekInfo, formatWeekString } from '../../../utils/week-calc'
|
||||
|
||||
/**
|
||||
* 이번 주 보고서 현황 조회
|
||||
* GET /api/report/weekly/current-week
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const weekInfo = getWeekInfo()
|
||||
|
||||
// 이번 주 내 보고서 목록
|
||||
const reports = await query(`
|
||||
SELECT r.*, p.project_name, p.project_code
|
||||
FROM wr_weekly_report_detail r
|
||||
JOIN wr_project_info p ON r.project_id = p.project_id
|
||||
WHERE r.author_id = $1 AND r.report_year = $2 AND r.report_week = $3
|
||||
ORDER BY p.project_name
|
||||
`, [parseInt(userId), weekInfo.year, weekInfo.week])
|
||||
|
||||
return {
|
||||
weekInfo: {
|
||||
year: weekInfo.year,
|
||||
week: weekInfo.week,
|
||||
weekString: formatWeekString(weekInfo.year, weekInfo.week),
|
||||
startDate: weekInfo.startDateStr,
|
||||
endDate: weekInfo.endDateStr
|
||||
},
|
||||
reports: reports.map((r: any) => ({
|
||||
reportId: r.report_id,
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
projectCode: r.project_code,
|
||||
reportStatus: r.report_status,
|
||||
workDescription: r.work_description,
|
||||
planDescription: r.plan_description,
|
||||
issueDescription: r.issue_description,
|
||||
workHours: r.work_hours,
|
||||
updatedAt: r.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
60
backend/api/report/weekly/list.get.ts
Normal file
60
backend/api/report/weekly/list.get.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 내 주간보고 목록
|
||||
* GET /api/report/weekly/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const queryParams = getQuery(event)
|
||||
const year = queryParams.year ? parseInt(queryParams.year as string) : null
|
||||
const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null
|
||||
|
||||
let sql = `
|
||||
SELECT r.*, p.project_name, p.project_code
|
||||
FROM wr_weekly_report_detail r
|
||||
JOIN wr_project_info p ON r.project_id = p.project_id
|
||||
WHERE r.author_id = $1
|
||||
`
|
||||
const params: any[] = [parseInt(userId)]
|
||||
let paramIndex = 2
|
||||
|
||||
if (year) {
|
||||
sql += ` AND r.report_year = $${paramIndex++}`
|
||||
params.push(year)
|
||||
}
|
||||
if (projectId) {
|
||||
sql += ` AND r.project_id = $${paramIndex++}`
|
||||
params.push(projectId)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY r.report_year DESC, r.report_week DESC'
|
||||
|
||||
const reports = await query(sql, params)
|
||||
|
||||
return {
|
||||
reports: reports.map((r: any) => ({
|
||||
reportId: r.report_id,
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
projectCode: r.project_code,
|
||||
reportYear: r.report_year,
|
||||
reportWeek: r.report_week,
|
||||
weekStartDate: r.week_start_date,
|
||||
weekEndDate: r.week_end_date,
|
||||
workDescription: r.work_description,
|
||||
planDescription: r.plan_description,
|
||||
issueDescription: r.issue_description,
|
||||
remarkDescription: r.remark_description,
|
||||
workHours: r.work_hours,
|
||||
reportStatus: r.report_status,
|
||||
submittedAt: r.submitted_at,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
9
backend/api/scheduler/status.get.ts
Normal file
9
backend/api/scheduler/status.get.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getSchedulerStatus } from '../../utils/report-scheduler'
|
||||
|
||||
/**
|
||||
* 스케줄러 상태 조회
|
||||
* GET /api/scheduler/status
|
||||
*/
|
||||
export default defineEventHandler(async () => {
|
||||
return getSchedulerStatus()
|
||||
})
|
||||
27
backend/api/scheduler/trigger-aggregate.post.ts
Normal file
27
backend/api/scheduler/trigger-aggregate.post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { aggregateWeeklyReports } from '../../utils/report-scheduler'
|
||||
|
||||
interface TriggerBody {
|
||||
year?: number
|
||||
week?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 취합 트리거
|
||||
* POST /api/scheduler/trigger-aggregate
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<TriggerBody>(event)
|
||||
|
||||
try {
|
||||
const result = await aggregateWeeklyReports(body.year, body.week)
|
||||
return {
|
||||
success: true,
|
||||
...result
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `취합 실패: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
71
backend/utils/db.ts
Normal file
71
backend/utils/db.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
let pool: pg.Pool | null = null
|
||||
|
||||
/**
|
||||
* PostgreSQL 연결 풀 가져오기
|
||||
*/
|
||||
export function getPool(): pg.Pool {
|
||||
if (!pool) {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const poolConfig = {
|
||||
host: config.dbHost,
|
||||
port: parseInt(config.dbPort as string),
|
||||
database: config.dbName,
|
||||
user: config.dbUser,
|
||||
password: config.dbPassword,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
}
|
||||
|
||||
console.log(`[DB] Connecting to ${poolConfig.host}:${poolConfig.port}/${poolConfig.database}`)
|
||||
|
||||
pool = new Pool(poolConfig)
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected pool error:', err)
|
||||
})
|
||||
|
||||
console.log('[DB] PostgreSQL pool created')
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행
|
||||
*/
|
||||
export async function query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
||||
const pool = getPool()
|
||||
const result = await pool.query(sql, params)
|
||||
return result.rows as T[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 행 조회
|
||||
*/
|
||||
export async function queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
||||
const rows = await query<T>(sql, params)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT/UPDATE/DELETE 실행
|
||||
*/
|
||||
export async function execute(sql: string, params?: any[]): Promise<number> {
|
||||
const pool = getPool()
|
||||
const result = await pool.query(sql, params)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT 후 반환
|
||||
*/
|
||||
export async function insertReturning<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
||||
const pool = getPool()
|
||||
const result = await pool.query(sql, params)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
99
backend/utils/report-scheduler.ts
Normal file
99
backend/utils/report-scheduler.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { query, execute, insertReturning } from './db'
|
||||
import { getLastWeekInfo, formatDate } from './week-calc'
|
||||
|
||||
let isRunning = false
|
||||
|
||||
/**
|
||||
* 주간보고 취합 실행
|
||||
*/
|
||||
export async function aggregateWeeklyReports(targetYear?: number, targetWeek?: number) {
|
||||
const weekInfo = targetYear && targetWeek
|
||||
? { year: targetYear, week: targetWeek }
|
||||
: getLastWeekInfo()
|
||||
|
||||
console.log(`[Aggregator] 취합 시작: ${weekInfo.year}-W${weekInfo.week}`)
|
||||
|
||||
// 해당 주차에 제출된 보고서가 있는 프로젝트 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT DISTINCT project_id
|
||||
FROM wr_weekly_report_detail
|
||||
WHERE report_year = $1 AND report_week = $2 AND report_status = 'SUBMITTED'
|
||||
`, [weekInfo.year, weekInfo.week])
|
||||
|
||||
let aggregatedCount = 0
|
||||
|
||||
for (const { project_id } of projects) {
|
||||
// 해당 프로젝트의 제출된 보고서들
|
||||
const reports = await query<any>(`
|
||||
SELECT report_id, work_hours
|
||||
FROM wr_weekly_report_detail
|
||||
WHERE project_id = $1 AND report_year = $2 AND report_week = $3
|
||||
AND report_status = 'SUBMITTED'
|
||||
`, [project_id, weekInfo.year, weekInfo.week])
|
||||
|
||||
const reportIds = reports.map((r: any) => r.report_id)
|
||||
const totalHours = reports.reduce((sum: number, r: any) => sum + (parseFloat(r.work_hours) || 0), 0)
|
||||
|
||||
// 주차 날짜 계산
|
||||
const jan4 = new Date(weekInfo.year, 0, 4)
|
||||
const firstMonday = new Date(jan4)
|
||||
firstMonday.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7))
|
||||
const targetMonday = new Date(firstMonday)
|
||||
targetMonday.setDate(firstMonday.getDate() + (weekInfo.week - 1) * 7)
|
||||
const targetSunday = new Date(targetMonday)
|
||||
targetSunday.setDate(targetMonday.getDate() + 6)
|
||||
|
||||
// UPSERT 취합 보고서
|
||||
await execute(`
|
||||
INSERT INTO wr_aggregated_report_summary (
|
||||
project_id, report_year, report_week,
|
||||
week_start_date, week_end_date,
|
||||
report_ids, member_count, total_work_hours
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (project_id, report_year, report_week)
|
||||
DO UPDATE SET
|
||||
report_ids = $6,
|
||||
member_count = $7,
|
||||
total_work_hours = $8,
|
||||
aggregated_at = NOW(),
|
||||
updated_at = NOW()
|
||||
`, [
|
||||
project_id,
|
||||
weekInfo.year,
|
||||
weekInfo.week,
|
||||
formatDate(targetMonday),
|
||||
formatDate(targetSunday),
|
||||
reportIds,
|
||||
reportIds.length,
|
||||
totalHours || null
|
||||
])
|
||||
|
||||
// 개별 보고서 상태 변경
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report_detail SET
|
||||
report_status = 'AGGREGATED',
|
||||
updated_at = NOW()
|
||||
WHERE report_id = ANY($1)
|
||||
`, [reportIds])
|
||||
|
||||
aggregatedCount++
|
||||
console.log(`[Aggregator] 프로젝트 ${project_id}: ${reportIds.length}건 취합`)
|
||||
}
|
||||
|
||||
console.log(`[Aggregator] 취합 완료: ${aggregatedCount}개 프로젝트`)
|
||||
|
||||
return {
|
||||
year: weekInfo.year,
|
||||
week: weekInfo.week,
|
||||
projectCount: aggregatedCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 상태
|
||||
*/
|
||||
export function getSchedulerStatus() {
|
||||
return {
|
||||
isRunning
|
||||
}
|
||||
}
|
||||
95
backend/utils/week-calc.ts
Normal file
95
backend/utils/week-calc.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* ISO 8601 주차 계산 유틸리티
|
||||
*/
|
||||
|
||||
export interface WeekInfo {
|
||||
year: number
|
||||
week: number
|
||||
startDate: Date // 월요일
|
||||
endDate: Date // 일요일
|
||||
startDateStr: string // YYYY-MM-DD
|
||||
endDateStr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜를 YYYY-MM-DD 문자열로 변환
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜의 ISO 주차 정보 반환
|
||||
*/
|
||||
export function getWeekInfo(date: Date = new Date()): WeekInfo {
|
||||
const target = new Date(date)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
|
||||
// 목요일 기준으로 연도 판단 (ISO 규칙)
|
||||
const thursday = new Date(target)
|
||||
thursday.setDate(target.getDate() - ((target.getDay() + 6) % 7) + 3)
|
||||
|
||||
const year = thursday.getFullYear()
|
||||
const firstThursday = new Date(year, 0, 4)
|
||||
firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3)
|
||||
|
||||
const week = Math.ceil((thursday.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1
|
||||
|
||||
// 해당 주의 월요일
|
||||
const monday = new Date(target)
|
||||
monday.setDate(target.getDate() - ((target.getDay() + 6) % 7))
|
||||
|
||||
// 해당 주의 일요일
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
|
||||
return {
|
||||
year,
|
||||
week,
|
||||
startDate: monday,
|
||||
endDate: sunday,
|
||||
startDateStr: formatDate(monday),
|
||||
endDateStr: formatDate(sunday)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 주차 문자열 반환 (예: "2026-W01")
|
||||
*/
|
||||
export function formatWeekString(year: number, week: number): string {
|
||||
return `${year}-W${week.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 지난 주 정보
|
||||
*/
|
||||
export function getLastWeekInfo(): WeekInfo {
|
||||
const lastWeek = new Date()
|
||||
lastWeek.setDate(lastWeek.getDate() - 7)
|
||||
return getWeekInfo(lastWeek)
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 연도/주차의 날짜 범위 반환
|
||||
*/
|
||||
export function getWeekDates(year: number, week: number): { startDate: Date, endDate: Date } {
|
||||
// 해당 연도의 1월 4일 (1주차에 반드시 포함)
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
|
||||
// 1월 4일이 포함된 주의 월요일
|
||||
const firstMonday = new Date(jan4)
|
||||
firstMonday.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7))
|
||||
|
||||
// 원하는 주차의 월요일
|
||||
const targetMonday = new Date(firstMonday)
|
||||
targetMonday.setDate(firstMonday.getDate() + (week - 1) * 7)
|
||||
|
||||
// 일요일
|
||||
const targetSunday = new Date(targetMonday)
|
||||
targetSunday.setDate(targetMonday.getDate() + 6)
|
||||
|
||||
return { startDate: targetMonday, endDate: targetSunday }
|
||||
}
|
||||
Reference in New Issue
Block a user