기능구현중
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
import { query, execute, queryOne } from '../../utils/db'
|
||||
import { requireAdmin } from '../../utils/session'
|
||||
|
||||
interface TaskInput {
|
||||
description: string
|
||||
hours: number
|
||||
isCompleted?: boolean
|
||||
}
|
||||
|
||||
interface ProjectInput {
|
||||
projectId: number | null
|
||||
projectName: string
|
||||
workTasks: TaskInput[]
|
||||
planTasks: TaskInput[]
|
||||
}
|
||||
|
||||
interface ReportInput {
|
||||
employeeId: number | null
|
||||
employeeName: string
|
||||
employeeEmail: string
|
||||
projects: ProjectInput[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 일괄 등록
|
||||
* POST /api/admin/bulk-register
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
const userId = await requireAdmin(event)
|
||||
|
||||
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
|
||||
|
||||
// 관리자 이메일 조회
|
||||
const currentUser = await queryOne<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
const adminEmail = currentUser?.employee_email || ''
|
||||
|
||||
const body = await readBody<{
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ReportInput[]
|
||||
}>(event)
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
for (const report of body.reports) {
|
||||
try {
|
||||
let employeeId = report.employeeId
|
||||
let isNewEmployee = false
|
||||
const newProjects: string[] = []
|
||||
|
||||
// 신규 직원 생성
|
||||
if (!employeeId && report.employeeName && report.employeeEmail) {
|
||||
const newEmp = await queryOne<any>(`
|
||||
INSERT INTO wr_employee_info (employee_name, employee_email, is_active, created_ip, created_email, updated_ip, updated_email)
|
||||
VALUES ($1, $2, true, $3, $4, $3, $4)
|
||||
RETURNING employee_id
|
||||
`, [report.employeeName, report.employeeEmail, clientIp, adminEmail])
|
||||
employeeId = newEmp.employee_id
|
||||
isNewEmployee = true
|
||||
}
|
||||
|
||||
if (!employeeId) {
|
||||
results.push({
|
||||
success: false,
|
||||
employeeName: report.employeeName,
|
||||
employeeEmail: report.employeeEmail,
|
||||
error: '직원 정보가 없습니다.'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 기존 보고서 확인 및 삭제 (덮어쓰기)
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [employeeId, body.reportYear, body.reportWeek])
|
||||
|
||||
let isUpdate = false
|
||||
if (existing) {
|
||||
await execute(`DELETE FROM wr_weekly_report_task WHERE report_id = $1`, [existing.report_id])
|
||||
await execute(`DELETE FROM wr_weekly_report WHERE report_id = $1`, [existing.report_id])
|
||||
isUpdate = true
|
||||
}
|
||||
|
||||
// 주간보고 마스터 등록
|
||||
const newReport = await queryOne<any>(`
|
||||
INSERT INTO wr_weekly_report (
|
||||
author_id, report_year, report_week, week_start_date, week_end_date,
|
||||
issue_description, vacation_description, remark_description,
|
||||
report_status, submitted_at, created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'SUBMITTED', NOW(), $9, $10, $9, $10)
|
||||
RETURNING report_id
|
||||
`, [
|
||||
employeeId, body.reportYear, body.reportWeek, body.weekStartDate, body.weekEndDate,
|
||||
report.issueDescription || null, report.vacationDescription || null, report.remarkDescription || null,
|
||||
clientIp, adminEmail
|
||||
])
|
||||
|
||||
const reportId = newReport.report_id
|
||||
|
||||
// 프로젝트별 Task 등록
|
||||
for (const proj of report.projects) {
|
||||
let projectId = proj.projectId
|
||||
|
||||
// 신규 프로젝트 생성
|
||||
if (!projectId && proj.projectName) {
|
||||
const year = new Date().getFullYear()
|
||||
const codeResult = await queryOne<any>(`
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING(project_code FROM 6) AS INTEGER)), 0) + 1 as next_num
|
||||
FROM wr_project_info WHERE project_code LIKE $1
|
||||
`, [`${year}-%`])
|
||||
const projectCode = `${year}-${String(codeResult.next_num).padStart(3, '0')}`
|
||||
|
||||
const newProj = await queryOne<any>(`
|
||||
INSERT INTO wr_project_info (project_code, project_name, project_status, created_ip, created_email, updated_ip, updated_email)
|
||||
VALUES ($1, $2, 'IN_PROGRESS', $3, $4, $3, $4)
|
||||
RETURNING project_id
|
||||
`, [projectCode, proj.projectName, clientIp, adminEmail])
|
||||
projectId = newProj.project_id
|
||||
newProjects.push(proj.projectName)
|
||||
}
|
||||
|
||||
if (!projectId) continue
|
||||
|
||||
// 금주실적 Task 등록
|
||||
for (const task of proj.workTasks || []) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours, is_completed,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, 'WORK', $3, $4, $5, $6, $7, $6, $7)
|
||||
`, [reportId, projectId, task.description, task.hours || 0, task.isCompleted !== false, clientIp, adminEmail])
|
||||
}
|
||||
|
||||
// 차주계획 Task 등록
|
||||
for (const task of proj.planTasks || []) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, 'PLAN', $3, $4, $5, $6, $5, $6)
|
||||
`, [reportId, projectId, task.description, task.hours || 0, clientIp, adminEmail])
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
success: true,
|
||||
employeeId,
|
||||
employeeName: report.employeeName,
|
||||
employeeEmail: report.employeeEmail,
|
||||
reportId,
|
||||
isUpdate,
|
||||
isNewEmployee,
|
||||
newProjects
|
||||
})
|
||||
|
||||
} catch (e: any) {
|
||||
results.push({
|
||||
success: false,
|
||||
employeeName: report.employeeName,
|
||||
employeeEmail: report.employeeEmail,
|
||||
error: e.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount: results.length,
|
||||
successCount: results.filter(r => r.success).length,
|
||||
results
|
||||
}
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
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
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAIVision, REPORT_PARSE_SYSTEM_PROMPT } from '../../utils/openai'
|
||||
import { requireAdmin } from '../../utils/session'
|
||||
|
||||
interface ParsedTask {
|
||||
description: string
|
||||
hours: number
|
||||
}
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
workTasks: ParsedTask[]
|
||||
planTasks: ParsedTask[]
|
||||
}
|
||||
|
||||
interface ParsedReport {
|
||||
employeeName: string
|
||||
employeeEmail: string | null
|
||||
projects: ParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ParsedReport[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지에서 주간보고 분석 (OpenAI Vision)
|
||||
* POST /api/admin/parse-image
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
await requireAdmin(event)
|
||||
|
||||
const body = await readBody<{ images: string[] }>(event)
|
||||
|
||||
if (!body.images || body.images.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '분석할 이미지를 업로드해주세요.' })
|
||||
}
|
||||
|
||||
if (body.images.length > 10) {
|
||||
throw createError({ statusCode: 400, message: '이미지는 최대 10장까지 업로드 가능합니다.' })
|
||||
}
|
||||
|
||||
// OpenAI Vision 분석
|
||||
const aiResponse = await callOpenAIVision(REPORT_PARSE_SYSTEM_PROMPT, body.images)
|
||||
|
||||
let parsed: ParsedResult
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 주차 정보 기본값 설정 (AI가 파싱 못한 경우)
|
||||
const now = new Date()
|
||||
if (!parsed.reportYear) {
|
||||
parsed.reportYear = now.getFullYear()
|
||||
}
|
||||
if (!parsed.reportWeek) {
|
||||
// ISO 주차 계산
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||
const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000))
|
||||
parsed.reportWeek = Math.ceil((days + startOfYear.getDay() + 1) / 7)
|
||||
}
|
||||
if (!parsed.weekStartDate || !parsed.weekEndDate) {
|
||||
// 현재 주의 월요일~일요일 계산
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1))
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
parsed.weekStartDate = monday.toISOString().split('T')[0]
|
||||
parsed.weekEndDate = sunday.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// 기존 직원 목록 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT employee_id, employee_name, employee_email
|
||||
FROM wr_employee_info
|
||||
WHERE is_active = true
|
||||
`)
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status != 'COMPLETED'
|
||||
`)
|
||||
|
||||
// 직원 및 프로젝트 매칭
|
||||
const matchedReports = parsed.reports.map(report => {
|
||||
let matchedEmployee = null
|
||||
if (report.employeeEmail) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_email.toLowerCase() === report.employeeEmail?.toLowerCase()
|
||||
)
|
||||
}
|
||||
if (!matchedEmployee) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_name === report.employeeName
|
||||
)
|
||||
}
|
||||
|
||||
const matchedProjects = report.projects.map(proj => {
|
||||
const existingProject = projects.find((p: any) =>
|
||||
p.project_name.includes(proj.projectName) ||
|
||||
proj.projectName.includes(p.project_name)
|
||||
)
|
||||
|
||||
return {
|
||||
...proj,
|
||||
matchedProjectId: existingProject?.project_id || null,
|
||||
matchedProjectCode: existingProject?.project_code || null,
|
||||
matchedProjectName: existingProject?.project_name || null,
|
||||
isNewProject: !existingProject
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...report,
|
||||
matchedEmployeeId: matchedEmployee?.employee_id || null,
|
||||
matchedEmployeeName: matchedEmployee?.employee_name || null,
|
||||
matchedEmployeeEmail: matchedEmployee?.employee_email || null,
|
||||
isEmployeeMatched: !!matchedEmployee,
|
||||
isNewEmployee: !matchedEmployee && !!report.employeeEmail,
|
||||
projects: matchedProjects
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed: {
|
||||
reportYear: parsed.reportYear,
|
||||
reportWeek: parsed.reportWeek,
|
||||
weekStartDate: parsed.weekStartDate,
|
||||
weekEndDate: parsed.weekEndDate,
|
||||
reports: matchedReports
|
||||
},
|
||||
employees: employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email
|
||||
})),
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAI, buildParseReportPrompt } from '../../utils/openai'
|
||||
import { requireAdmin } from '../../utils/session'
|
||||
|
||||
interface ParsedTask {
|
||||
description: string
|
||||
hours: number
|
||||
}
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
workTasks: ParsedTask[]
|
||||
planTasks: ParsedTask[]
|
||||
}
|
||||
|
||||
interface ParsedReport {
|
||||
employeeName: string
|
||||
employeeEmail: string | null
|
||||
projects: ParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ParsedReport[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 텍스트 분석 (OpenAI)
|
||||
* POST /api/admin/parse-report
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
await requireAdmin(event)
|
||||
|
||||
const body = await readBody<{ rawText: string }>(event)
|
||||
|
||||
if (!body.rawText || body.rawText.trim().length < 10) {
|
||||
throw createError({ statusCode: 400, message: '분석할 텍스트를 입력해주세요.' })
|
||||
}
|
||||
|
||||
// OpenAI 분석
|
||||
const messages = buildParseReportPrompt(body.rawText)
|
||||
const aiResponse = await callOpenAI(messages, true)
|
||||
|
||||
let parsed: ParsedResult
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 주차 정보 기본값 설정 (AI가 파싱 못한 경우)
|
||||
const now = new Date()
|
||||
if (!parsed.reportYear) {
|
||||
parsed.reportYear = now.getFullYear()
|
||||
}
|
||||
if (!parsed.reportWeek) {
|
||||
// ISO 주차 계산
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||
const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000))
|
||||
parsed.reportWeek = Math.ceil((days + startOfYear.getDay() + 1) / 7)
|
||||
}
|
||||
if (!parsed.weekStartDate || !parsed.weekEndDate) {
|
||||
// 현재 주의 월요일~일요일 계산
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1))
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
parsed.weekStartDate = monday.toISOString().split('T')[0]
|
||||
parsed.weekEndDate = sunday.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// 기존 직원 목록 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT employee_id, employee_name, employee_email
|
||||
FROM wr_employee_info
|
||||
WHERE is_active = true
|
||||
`)
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status != 'COMPLETED'
|
||||
`)
|
||||
|
||||
// 직원 및 프로젝트 매칭
|
||||
const matchedReports = parsed.reports.map(report => {
|
||||
// 이메일로 정확 매칭 시도
|
||||
let matchedEmployee = null
|
||||
if (report.employeeEmail) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_email.toLowerCase() === report.employeeEmail?.toLowerCase()
|
||||
)
|
||||
}
|
||||
// 이메일 매칭 실패시 이름으로 매칭
|
||||
if (!matchedEmployee) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_name === report.employeeName
|
||||
)
|
||||
}
|
||||
|
||||
// 프로젝트 매칭
|
||||
const matchedProjects = report.projects.map(proj => {
|
||||
const existingProject = projects.find((p: any) =>
|
||||
p.project_name.includes(proj.projectName) ||
|
||||
proj.projectName.includes(p.project_name)
|
||||
)
|
||||
|
||||
return {
|
||||
...proj,
|
||||
matchedProjectId: existingProject?.project_id || null,
|
||||
matchedProjectCode: existingProject?.project_code || null,
|
||||
matchedProjectName: existingProject?.project_name || null,
|
||||
isNewProject: !existingProject
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...report,
|
||||
matchedEmployeeId: matchedEmployee?.employee_id || null,
|
||||
matchedEmployeeName: matchedEmployee?.employee_name || null,
|
||||
matchedEmployeeEmail: matchedEmployee?.employee_email || null,
|
||||
isEmployeeMatched: !!matchedEmployee,
|
||||
isNewEmployee: !matchedEmployee && !!report.employeeEmail,
|
||||
projects: matchedProjects
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed: {
|
||||
reportYear: parsed.reportYear,
|
||||
reportWeek: parsed.reportWeek,
|
||||
weekStartDate: parsed.weekStartDate,
|
||||
weekEndDate: parsed.weekEndDate,
|
||||
reports: matchedReports
|
||||
},
|
||||
employees: employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email
|
||||
})),
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
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 }
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { requireAdmin } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 사용자 목록 조회 (권한 정보 + 최근 로그인 포함)
|
||||
* GET /api/admin/user/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const queryParams = getQuery(event)
|
||||
const company = queryParams.company as string || ''
|
||||
const name = queryParams.name as string || ''
|
||||
const email = queryParams.email as string || ''
|
||||
const phone = queryParams.phone as string || ''
|
||||
const status = queryParams.status as string || 'active' // 기본값: 활성
|
||||
|
||||
// 1. 사용자 목록 조회 (최근 로그인 포함)
|
||||
let userQuery = `
|
||||
SELECT
|
||||
e.employee_id,
|
||||
e.employee_name,
|
||||
e.employee_email,
|
||||
e.employee_phone,
|
||||
e.employee_position,
|
||||
e.company,
|
||||
e.join_date,
|
||||
e.is_active,
|
||||
e.created_at,
|
||||
(
|
||||
SELECT MAX(login_at)
|
||||
FROM wr_login_history
|
||||
WHERE employee_id = e.employee_id
|
||||
) as last_login_at
|
||||
FROM wr_employee_info e
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: any[] = []
|
||||
|
||||
// 소속사 검색
|
||||
if (company) {
|
||||
params.push(`%${company}%`)
|
||||
userQuery += ` AND e.company ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 이름 검색
|
||||
if (name) {
|
||||
params.push(`%${name}%`)
|
||||
userQuery += ` AND e.employee_name ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 이메일 검색
|
||||
if (email) {
|
||||
params.push(`%${email}%`)
|
||||
userQuery += ` AND e.employee_email ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 전화번호 검색
|
||||
if (phone) {
|
||||
params.push(`%${phone}%`)
|
||||
userQuery += ` AND e.employee_phone ILIKE $${params.length}`
|
||||
}
|
||||
|
||||
// 상태 검색
|
||||
if (status === 'active') {
|
||||
userQuery += ` AND e.is_active = true`
|
||||
} else if (status === 'inactive') {
|
||||
userQuery += ` AND e.is_active = false`
|
||||
}
|
||||
// status === 'all' 이면 조건 없음
|
||||
|
||||
userQuery += ` ORDER BY e.company, e.employee_position, e.employee_name`
|
||||
|
||||
const users = await query<any>(userQuery, params)
|
||||
|
||||
// 2. 모든 권한 목록 조회
|
||||
const roles = await query<any>(`
|
||||
SELECT role_id, role_code, role_name, sort_order
|
||||
FROM wr_role
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order
|
||||
`)
|
||||
|
||||
// 3. 사용자별 권한 매핑 조회
|
||||
const userRoles = await query<any>(`
|
||||
SELECT employee_id, role_id
|
||||
FROM wr_employee_role
|
||||
`)
|
||||
|
||||
// 4. 사용자별 권한 배열 생성
|
||||
const userRoleMap = new Map<number, number[]>()
|
||||
for (const ur of userRoles) {
|
||||
if (!userRoleMap.has(ur.employee_id)) {
|
||||
userRoleMap.set(ur.employee_id, [])
|
||||
}
|
||||
userRoleMap.get(ur.employee_id)!.push(ur.role_id)
|
||||
}
|
||||
|
||||
// 5. 사용자 데이터에 권한 정보 추가
|
||||
const usersWithRoles = users.map(u => ({
|
||||
...u,
|
||||
roleIds: userRoleMap.get(u.employee_id) || []
|
||||
}))
|
||||
|
||||
return {
|
||||
users: usersWithRoles,
|
||||
roles,
|
||||
total: users.length
|
||||
}
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAIVision } from '../../utils/openai'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 개인 주간보고 이미지 분석 (OpenAI Vision)
|
||||
* POST /api/ai/parse-my-report-image
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const body = await readBody<{ images: string[] }>(event)
|
||||
|
||||
if (!body.images || body.images.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '분석할 이미지를 업로드해주세요.' })
|
||||
}
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status = 'IN_PROGRESS'
|
||||
`)
|
||||
|
||||
// 프로젝트 목록을 ID 포함해서 전달
|
||||
const projectList = projects.map(p => `[ID:${p.project_id}] ${p.project_code}: ${p.project_name}`).join('\n')
|
||||
|
||||
// OpenAI Vision 분석
|
||||
const prompt = buildImagePrompt(projectList)
|
||||
console.log('=== AI 이미지 분석 시작 ===')
|
||||
console.log('이미지 개수:', body.images.length)
|
||||
console.log('프로젝트 목록:', projectList)
|
||||
|
||||
const aiResponse = await callOpenAIVision(prompt, body.images)
|
||||
console.log('=== AI 응답 (raw) ===')
|
||||
console.log(aiResponse)
|
||||
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
console.log('=== AI 응답 (parsed) ===')
|
||||
console.log(JSON.stringify(parsed, null, 2))
|
||||
} catch (e) {
|
||||
console.error('=== JSON 파싱 실패 ===')
|
||||
console.error(e)
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 프로젝트 매칭
|
||||
if (parsed.projects) {
|
||||
for (const proj of parsed.projects) {
|
||||
if (!proj.matchedProjectId && proj.projectName) {
|
||||
const matched = projects.find((p: any) =>
|
||||
p.project_name.toLowerCase().includes(proj.projectName.toLowerCase()) ||
|
||||
proj.projectName.toLowerCase().includes(p.project_name.toLowerCase()) ||
|
||||
p.project_code.toLowerCase() === proj.projectName.toLowerCase()
|
||||
)
|
||||
if (matched) {
|
||||
proj.matchedProjectId = matched.project_id
|
||||
proj.projectName = matched.project_name
|
||||
}
|
||||
}
|
||||
|
||||
proj.workTasks = (proj.workTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0,
|
||||
isCompleted: t.isCompleted !== false
|
||||
}))
|
||||
|
||||
proj.planTasks = (proj.planTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0
|
||||
}))
|
||||
}
|
||||
|
||||
// 내용 없는 프로젝트 제외 (workTasks, planTasks 모두 비어있으면 제외)
|
||||
parsed.projects = parsed.projects.filter((proj: any) =>
|
||||
(proj.workTasks && proj.workTasks.length > 0) ||
|
||||
(proj.planTasks && proj.planTasks.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed,
|
||||
projects
|
||||
}
|
||||
})
|
||||
|
||||
function buildImagePrompt(projectList: string): string {
|
||||
return `당신은 주간보고 내용을 분석하는 AI입니다.
|
||||
이미지에서 주간보고 내용을 추출하여 JSON으로 반환해주세요.
|
||||
이미지에 여러 사람의 내용이 있어도 모두 추출하여 하나의 보고서로 통합해주세요.
|
||||
|
||||
현재 등록된 프로젝트 목록 (형식: [ID:숫자] 코드: 이름):
|
||||
${projectList}
|
||||
|
||||
⚠️ 중요: 이미지에서 추출한 프로젝트명과 위 목록을 비교하여 가장 유사한 프로젝트의 ID를 matchedProjectId에 반환하세요.
|
||||
- 유사도 판단: 키워드 일치, 약어, 부분 문자열 등 고려
|
||||
- 예: "한우 유전체" → "보은 한우 온라인 유전체 분석" 매칭 가능
|
||||
- 예: "HEIS" → "보건환경연구원 HEIS" 매칭 가능
|
||||
- 매칭되는 프로젝트가 없으면 matchedProjectId는 null
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만 출력하세요:
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"projectName": "이미지에서 추출한 원본 프로젝트명",
|
||||
"matchedProjectId": 5,
|
||||
"workTasks": [
|
||||
{"description": "작업내용", "hours": 8, "isCompleted": true}
|
||||
],
|
||||
"planTasks": [
|
||||
{"description": "계획내용", "hours": 8}
|
||||
]
|
||||
}
|
||||
],
|
||||
"issueDescription": "이슈/리스크 내용 또는 null",
|
||||
"vacationDescription": "휴가일정 내용 또는 null",
|
||||
"remarkDescription": "기타사항 내용 또는 null"
|
||||
}
|
||||
|
||||
규칙:
|
||||
1. 이미지에서 모든 주간보고 내용을 추출
|
||||
2. projectName은 이미지에서 추출한 원본 텍스트 그대로
|
||||
3. matchedProjectId는 위 프로젝트 목록에서 가장 유사한 프로젝트의 ID (숫자)
|
||||
4. "금주 실적", "이번주", "완료" 등은 workTasks로 분류
|
||||
5. "차주 계획", "다음주", "예정" 등은 planTasks로 분류
|
||||
6. 시간이 명시되지 않은 경우 hours는 0으로
|
||||
7. JSON 외의 텍스트는 절대 출력하지 마세요`
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAI } from '../../utils/openai'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
|
||||
interface ParsedTask {
|
||||
description: string
|
||||
hours: number
|
||||
isCompleted?: boolean
|
||||
}
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
matchedProjectId: number | null
|
||||
workTasks: ParsedTask[]
|
||||
planTasks: ParsedTask[]
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
projects: ParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 개인 주간보고 텍스트 분석 (OpenAI)
|
||||
* POST /api/ai/parse-my-report
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const body = await readBody<{ rawText: string }>(event)
|
||||
|
||||
if (!body.rawText || body.rawText.trim().length < 5) {
|
||||
throw createError({ statusCode: 400, message: '분석할 텍스트를 입력해주세요.' })
|
||||
}
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status = 'IN_PROGRESS'
|
||||
`)
|
||||
|
||||
// 프로젝트 목록을 ID 포함해서 전달
|
||||
const projectList = projects.map(p => `[ID:${p.project_id}] ${p.project_code}: ${p.project_name}`).join('\n')
|
||||
|
||||
// OpenAI 분석
|
||||
const prompt = buildMyReportPrompt(body.rawText, projectList)
|
||||
const aiResponse = await callOpenAI(prompt, true)
|
||||
|
||||
let parsed: ParsedResult
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 프로젝트 매칭
|
||||
if (parsed.projects) {
|
||||
for (const proj of parsed.projects) {
|
||||
if (!proj.matchedProjectId && proj.projectName) {
|
||||
const matched = projects.find((p: any) =>
|
||||
p.project_name.toLowerCase().includes(proj.projectName.toLowerCase()) ||
|
||||
proj.projectName.toLowerCase().includes(p.project_name.toLowerCase()) ||
|
||||
p.project_code.toLowerCase() === proj.projectName.toLowerCase()
|
||||
)
|
||||
if (matched) {
|
||||
proj.matchedProjectId = matched.project_id
|
||||
proj.projectName = matched.project_name
|
||||
}
|
||||
}
|
||||
|
||||
// workTasks 기본값
|
||||
proj.workTasks = (proj.workTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0,
|
||||
isCompleted: t.isCompleted !== false
|
||||
}))
|
||||
|
||||
// planTasks 기본값
|
||||
proj.planTasks = (proj.planTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0
|
||||
}))
|
||||
}
|
||||
|
||||
// 내용 없는 프로젝트 제외 (workTasks, planTasks 모두 비어있으면 제외)
|
||||
parsed.projects = parsed.projects.filter((proj: any) =>
|
||||
(proj.workTasks && proj.workTasks.length > 0) ||
|
||||
(proj.planTasks && proj.planTasks.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed,
|
||||
projects
|
||||
}
|
||||
})
|
||||
|
||||
function buildMyReportPrompt(rawText: string, projectList: string): any[] {
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: `당신은 주간보고 내용을 분석하는 AI입니다.
|
||||
사용자가 입력한 텍스트를 분석하여 프로젝트별 업무 내용을 추출해주세요.
|
||||
|
||||
현재 등록된 프로젝트 목록 (형식: [ID:숫자] 코드: 이름):
|
||||
${projectList}
|
||||
|
||||
⚠️ 중요: 입력 텍스트에서 추출한 프로젝트명과 위 목록을 비교하여 가장 유사한 프로젝트의 ID를 matchedProjectId에 반환하세요.
|
||||
- 유사도 판단: 키워드 일치, 약어, 부분 문자열 등 고려
|
||||
- 예: "한우 유전체" → "보은 한우 온라인 유전체 분석" 매칭 가능
|
||||
- 예: "HEIS" → "보건환경연구원 HEIS" 매칭 가능
|
||||
- 매칭되는 프로젝트가 없으면 matchedProjectId는 null
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만 출력하세요:
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"projectName": "입력에서 추출한 원본 프로젝트명",
|
||||
"matchedProjectId": 5,
|
||||
"workTasks": [
|
||||
{"description": "작업내용", "hours": 8, "isCompleted": true}
|
||||
],
|
||||
"planTasks": [
|
||||
{"description": "계획내용", "hours": 8}
|
||||
]
|
||||
}
|
||||
],
|
||||
"issueDescription": "이슈/리스크 내용 또는 null",
|
||||
"vacationDescription": "휴가일정 내용 또는 null",
|
||||
"remarkDescription": "기타사항 내용 또는 null"
|
||||
}
|
||||
|
||||
규칙:
|
||||
1. projectName은 입력 텍스트에서 추출한 원본 그대로
|
||||
2. matchedProjectId는 위 프로젝트 목록에서 가장 유사한 프로젝트의 ID (숫자)
|
||||
3. "금주 실적", "이번주", "완료" 등은 workTasks로 분류
|
||||
4. "차주 계획", "다음주", "예정" 등은 planTasks로 분류
|
||||
5. 시간이 명시되지 않은 경우 hours는 0으로
|
||||
6. JSON 외의 텍스트는 절대 출력하지 마세요`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: rawText
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { query, queryOne, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
import { hashPassword, verifyPassword } from '../../utils/password'
|
||||
|
||||
interface ChangePasswordBody {
|
||||
currentPassword?: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
* POST /api/auth/change-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const employeeId = await requireAuth(event)
|
||||
|
||||
const body = await readBody<ChangePasswordBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (!body.newPassword || !body.confirmPassword) {
|
||||
throw createError({ statusCode: 400, message: '새 비밀번호를 입력해주세요.' })
|
||||
}
|
||||
|
||||
if (body.newPassword !== body.confirmPassword) {
|
||||
throw createError({ statusCode: 400, message: '비밀번호가 일치하지 않습니다.' })
|
||||
}
|
||||
|
||||
if (body.newPassword.length < 8) {
|
||||
throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' })
|
||||
}
|
||||
|
||||
// 현재 직원 정보 조회
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT password_hash, employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
if (!employee) {
|
||||
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 기존 비밀번호가 있으면 현재 비밀번호 검증
|
||||
if (employee.password_hash) {
|
||||
if (!body.currentPassword) {
|
||||
throw createError({ statusCode: 400, message: '현재 비밀번호를 입력해주세요.' })
|
||||
}
|
||||
const isValid = await verifyPassword(body.currentPassword, employee.password_hash)
|
||||
if (!isValid) {
|
||||
throw createError({ statusCode: 401, message: '현재 비밀번호가 올바르지 않습니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
// 새 비밀번호 해시
|
||||
const newHash = await hashPassword(body.newPassword)
|
||||
|
||||
// 비밀번호 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET password_hash = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3
|
||||
WHERE employee_id = $4
|
||||
`, [newHash, clientIp, employee.employee_email, employeeId])
|
||||
|
||||
return { success: true, message: '비밀번호가 변경되었습니다.' }
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
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) => {
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!sessionId) {
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
// DB에서 세션 조회
|
||||
const session = await getDbSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
// 세션이 만료되었거나 없음 → 쿠키 삭제
|
||||
deleteSessionCookie(event)
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
// 사용자 정보 조회
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info
|
||||
WHERE employee_id = $1 AND is_active = true
|
||||
`, [session.employeeId])
|
||||
|
||||
if (!employee) {
|
||||
deleteSessionCookie(event)
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
// 세션 갱신 (Sliding Expiration - 10분 연장)
|
||||
await refreshSession(sessionId)
|
||||
|
||||
// 로그인 이력의 last_active_at도 업데이트
|
||||
if (session.loginHistoryId) {
|
||||
await execute(`
|
||||
UPDATE wr_login_history
|
||||
SET last_active_at = NOW()
|
||||
WHERE history_id = $1
|
||||
`, [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,
|
||||
roles // 권한 코드 배열 추가
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { query, execute, insertReturning } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* Google OAuth 콜백
|
||||
* GET /api/auth/google/callback
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const params = getQuery(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
const code = params.code as string
|
||||
const state = params.state as string
|
||||
const error = params.error as string
|
||||
|
||||
if (error) {
|
||||
return sendRedirect(event, '/login?error=oauth_denied')
|
||||
}
|
||||
|
||||
// State 검증
|
||||
const savedState = getCookie(event, 'oauth_state')
|
||||
if (!savedState || savedState !== state) {
|
||||
return sendRedirect(event, '/login?error=invalid_state')
|
||||
}
|
||||
deleteCookie(event, 'oauth_state')
|
||||
|
||||
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
|
||||
const clientSecret = config.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET
|
||||
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return sendRedirect(event, '/login?error=oauth_not_configured')
|
||||
}
|
||||
|
||||
try {
|
||||
// 토큰 교환
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
})
|
||||
})
|
||||
|
||||
const tokenData = await tokenRes.json()
|
||||
if (!tokenData.access_token) {
|
||||
console.error('Token error:', tokenData)
|
||||
return sendRedirect(event, '/login?error=token_failed')
|
||||
}
|
||||
|
||||
// 사용자 정보 조회
|
||||
const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` }
|
||||
})
|
||||
const googleUser = await userRes.json()
|
||||
|
||||
if (!googleUser.email) {
|
||||
return sendRedirect(event, '/login?error=no_email')
|
||||
}
|
||||
|
||||
const googleId = googleUser.id
|
||||
const googleEmail = googleUser.email.toLowerCase()
|
||||
const googleName = googleUser.name || googleEmail.split('@')[0]
|
||||
|
||||
// 기존 사용자 조회 (이메일 기준)
|
||||
let employees = await query<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_email = $1
|
||||
`, [googleEmail])
|
||||
|
||||
let employee = employees[0]
|
||||
|
||||
if (employee) {
|
||||
// 기존 사용자 - Google 정보 연결
|
||||
await execute(`
|
||||
UPDATE wr_employee_info SET
|
||||
google_id = $1,
|
||||
google_email = $2,
|
||||
google_linked_at = NOW(),
|
||||
google_access_token = $3,
|
||||
google_refresh_token = $4,
|
||||
google_token_expires_at = NOW() + INTERVAL '${tokenData.expires_in} seconds',
|
||||
last_login_at = NOW(),
|
||||
last_login_ip = $5,
|
||||
updated_at = NOW()
|
||||
WHERE employee_id = $6
|
||||
`, [googleId, googleEmail, tokenData.access_token, tokenData.refresh_token || null, clientIp, employee.employee_id])
|
||||
} else {
|
||||
// 신규 사용자 자동 등록
|
||||
employee = await insertReturning(`
|
||||
INSERT INTO wr_employee_info (
|
||||
employee_name, employee_email, google_id, google_email, google_linked_at,
|
||||
google_access_token, google_refresh_token, google_token_expires_at,
|
||||
last_login_at, last_login_ip, created_ip, created_email
|
||||
) VALUES ($1, $2, $3, $2, NOW(), $4, $5, NOW() + INTERVAL '${tokenData.expires_in} seconds', NOW(), $6, $6, $2)
|
||||
RETURNING *
|
||||
`, [googleName, googleEmail, googleId, tokenData.access_token, tokenData.refresh_token || null, clientIp])
|
||||
}
|
||||
|
||||
// 로그인 이력 추가
|
||||
const loginHistory = await insertReturning(`
|
||||
INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type)
|
||||
VALUES ($1, $2, $3, 'GOOGLE')
|
||||
RETURNING history_id
|
||||
`, [employee.employee_id, clientIp, googleEmail])
|
||||
|
||||
// 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employee.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return sendRedirect(event, '/')
|
||||
} catch (e) {
|
||||
console.error('Google OAuth error:', e)
|
||||
return sendRedirect(event, '/login?error=oauth_failed')
|
||||
}
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Google OAuth 시작
|
||||
* GET /api/auth/google
|
||||
*
|
||||
* Query params:
|
||||
* - extend: 'groups' - 구글 그룹 접근 권한 추가 요청
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const query = getQuery(event)
|
||||
|
||||
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
|
||||
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
|
||||
|
||||
if (!clientId) {
|
||||
throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' })
|
||||
}
|
||||
|
||||
// 기본 scope + 확장 scope
|
||||
let scopes = ['openid', 'email', 'profile']
|
||||
|
||||
// 구글 그룹 권한 요청 시 추가 scope
|
||||
if (query.extend === 'groups') {
|
||||
scopes.push(
|
||||
'https://www.googleapis.com/auth/gmail.readonly', // 그룹 메일 읽기
|
||||
'https://www.googleapis.com/auth/cloud-identity.groups.readonly' // 그룹 정보 읽기
|
||||
)
|
||||
}
|
||||
|
||||
const scope = encodeURIComponent(scopes.join(' '))
|
||||
const state = Math.random().toString(36).substring(7) // CSRF 방지
|
||||
|
||||
// state를 쿠키에 저장
|
||||
setCookie(event, 'oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 300 // 5분
|
||||
})
|
||||
|
||||
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||
`client_id=${clientId}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${scope}` +
|
||||
`&state=${state}` +
|
||||
`&access_type=offline` +
|
||||
`&prompt=consent`
|
||||
|
||||
return sendRedirect(event, authUrl)
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { getDbSession, getSessionIdFromCookie, deleteSessionCookie, SESSION_TIMEOUT_MINUTES } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 본인 로그인 이력 조회
|
||||
* GET /api/auth/login-history
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const session = await getDbSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
deleteSessionCookie(event)
|
||||
throw createError({ statusCode: 401, message: '세션이 만료되었습니다.' })
|
||||
}
|
||||
|
||||
// 현재 활성 세션 ID 목록 조회
|
||||
const activeSessions = await query<any>(`
|
||||
SELECT login_history_id FROM wr_session
|
||||
WHERE employee_id = $1 AND expires_at > NOW()
|
||||
`, [session.employeeId])
|
||||
|
||||
const activeHistoryIds = new Set(activeSessions.map(s => s.login_history_id))
|
||||
|
||||
// 로그인 이력 조회
|
||||
const history = await query<any>(`
|
||||
SELECT
|
||||
history_id,
|
||||
login_at,
|
||||
login_ip,
|
||||
logout_at,
|
||||
logout_ip,
|
||||
last_active_at
|
||||
FROM wr_login_history
|
||||
WHERE employee_id = $1
|
||||
ORDER BY login_at DESC
|
||||
LIMIT 50
|
||||
`, [session.employeeId])
|
||||
|
||||
const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
history: history.map(h => {
|
||||
const isCurrentSession = h.history_id === session.loginHistoryId
|
||||
const isActiveSession = activeHistoryIds.has(h.history_id)
|
||||
|
||||
// 세션 상태 판단
|
||||
let sessionStatus: 'active' | 'logout' | 'expired'
|
||||
if (h.logout_at) {
|
||||
sessionStatus = 'logout'
|
||||
} else if (isActiveSession) {
|
||||
sessionStatus = 'active'
|
||||
} else {
|
||||
// 활성 세션에 없으면 만료
|
||||
sessionStatus = 'expired'
|
||||
}
|
||||
|
||||
return {
|
||||
historyId: h.history_id,
|
||||
loginAt: h.login_at,
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
lastActiveAt: h.last_active_at,
|
||||
isCurrentSession,
|
||||
sessionStatus
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { query, execute, insertReturning } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../utils/session'
|
||||
import { verifyPassword } from '../../utils/password'
|
||||
|
||||
interface LoginBody {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 로그인
|
||||
* POST /api/auth/login-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LoginBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
if (!body.email || !body.password) {
|
||||
throw createError({ statusCode: 400, message: '이메일과 비밀번호를 입력해주세요.' })
|
||||
}
|
||||
|
||||
const emailLower = body.email.toLowerCase()
|
||||
|
||||
// 직원 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_email = $1 AND is_active = true
|
||||
`, [emailLower])
|
||||
|
||||
if (employees.length === 0) {
|
||||
throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' })
|
||||
}
|
||||
|
||||
const employee = employees[0]
|
||||
|
||||
// 비밀번호 미설정
|
||||
if (!employee.password_hash) {
|
||||
throw createError({ statusCode: 401, message: '비밀번호가 설정되지 않았습니다. 관리자에게 문의하세요.' })
|
||||
}
|
||||
|
||||
// 비밀번호 검증
|
||||
const isValid = await verifyPassword(body.password, employee.password_hash)
|
||||
if (!isValid) {
|
||||
throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' })
|
||||
}
|
||||
|
||||
// 마지막 로그인 시간 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET last_login_at = NOW(), last_login_ip = $1, updated_at = NOW()
|
||||
WHERE employee_id = $2
|
||||
`, [clientIp, employee.employee_id])
|
||||
|
||||
// 로그인 이력 추가
|
||||
const loginHistory = await insertReturning(`
|
||||
INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type)
|
||||
VALUES ($1, $2, $3, 'PASSWORD')
|
||||
RETURNING history_id
|
||||
`, [employee.employee_id, clientIp, emailLower])
|
||||
|
||||
// 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employee.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePosition: employee.employee_position,
|
||||
company: employee.company
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../utils/session'
|
||||
|
||||
interface LoginBody {
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일+이름 로그인
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LoginBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
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: '올바른 이메일 형식이 아닙니다.' })
|
||||
}
|
||||
|
||||
const emailLower = body.email.toLowerCase()
|
||||
const nameTrimmed = body.name.trim()
|
||||
|
||||
// 기존 직원 조회
|
||||
let employee = await query<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_email = $1
|
||||
`, [emailLower])
|
||||
|
||||
let employeeData = employee[0]
|
||||
|
||||
if (employeeData) {
|
||||
// 기존 직원 - 이름이 다르면 업데이트
|
||||
if (employeeData.employee_name !== nameTrimmed) {
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET employee_name = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3
|
||||
WHERE employee_id = $4
|
||||
`, [nameTrimmed, clientIp, emailLower, employeeData.employee_id])
|
||||
employeeData.employee_name = nameTrimmed
|
||||
}
|
||||
} else {
|
||||
// 신규 직원 자동 등록
|
||||
employeeData = await insertReturning(`
|
||||
INSERT INTO wr_employee_info (employee_name, employee_email, created_ip, created_email, updated_ip, updated_email)
|
||||
VALUES ($1, $2, $3, $2, $3, $2)
|
||||
RETURNING *
|
||||
`, [nameTrimmed, emailLower, clientIp])
|
||||
}
|
||||
|
||||
// 로그인 이력 추가
|
||||
const loginHistory = await insertReturning(`
|
||||
INSERT INTO wr_login_history (employee_id, login_ip, login_email)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING history_id
|
||||
`, [employeeData.employee_id, clientIp, emailLower])
|
||||
|
||||
// DB 기반 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employeeData.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
|
||||
// 세션 쿠키 설정
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
employeeId: employeeData.employee_id,
|
||||
employeeName: employeeData.employee_name,
|
||||
employeeEmail: employeeData.employee_email,
|
||||
employeePosition: employeeData.employee_position
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getDbSession, deleteSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (sessionId) {
|
||||
// 세션 정보 조회
|
||||
const session = await getDbSession(sessionId)
|
||||
|
||||
// 로그아웃 이력 기록
|
||||
if (session?.loginHistoryId) {
|
||||
await execute(`
|
||||
UPDATE wr_login_history
|
||||
SET logout_at = NOW(), logout_ip = $1
|
||||
WHERE history_id = $2
|
||||
`, [clientIp, session.loginHistoryId])
|
||||
}
|
||||
|
||||
// DB에서 세션 삭제
|
||||
await deleteSession(sessionId)
|
||||
}
|
||||
|
||||
// 세션 쿠키 삭제
|
||||
deleteSessionCookie(event)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { getDbSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 로그인된 사용자 상세 정보 조회
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
// DB에서 세션 조회
|
||||
const session = await getDbSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
deleteSessionCookie(event)
|
||||
throw createError({ statusCode: 401, message: '세션이 만료되었습니다. 다시 로그인해주세요.' })
|
||||
}
|
||||
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT
|
||||
employee_id,
|
||||
employee_name,
|
||||
employee_email,
|
||||
employee_phone,
|
||||
employee_position,
|
||||
company,
|
||||
join_date,
|
||||
is_active,
|
||||
created_at,
|
||||
created_ip,
|
||||
updated_at,
|
||||
updated_ip,
|
||||
password_hash,
|
||||
google_id,
|
||||
google_email,
|
||||
synology_id,
|
||||
synology_email
|
||||
FROM wr_employee_info
|
||||
WHERE employee_id = $1
|
||||
`, [session.employeeId])
|
||||
|
||||
if (!employee) {
|
||||
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePhone: employee.employee_phone,
|
||||
employeePosition: employee.employee_position,
|
||||
company: employee.company,
|
||||
joinDate: employee.join_date,
|
||||
isActive: employee.is_active,
|
||||
createdAt: employee.created_at,
|
||||
createdIp: employee.created_ip,
|
||||
updatedAt: employee.updated_at,
|
||||
updatedIp: employee.updated_ip,
|
||||
hasPassword: !!employee.password_hash,
|
||||
googleId: employee.google_id,
|
||||
googleEmail: employee.google_email,
|
||||
synologyId: employee.synology_id,
|
||||
synologyEmail: employee.synology_email
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { getDbSession, getSessionIdFromCookie } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 현재 사용자 접근 가능 메뉴 조회
|
||||
* GET /api/auth/menu
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const session = await getDbSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 401, message: '세션이 만료되었습니다.' })
|
||||
}
|
||||
|
||||
// 사용자의 권한 목록 조회
|
||||
const userRoles = await query<any>(`
|
||||
SELECT r.role_id, r.role_code
|
||||
FROM wr_employee_role er
|
||||
JOIN wr_role r ON er.role_id = r.role_id
|
||||
WHERE er.employee_id = $1
|
||||
`, [session.employeeId])
|
||||
|
||||
const roleIds = userRoles.map(r => r.role_id)
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return { menus: [] }
|
||||
}
|
||||
|
||||
// 접근 가능한 메뉴 조회
|
||||
const menus = await query<any>(`
|
||||
SELECT DISTINCT
|
||||
m.menu_id,
|
||||
m.menu_code,
|
||||
m.menu_name,
|
||||
m.menu_path,
|
||||
m.menu_icon,
|
||||
m.parent_menu_id,
|
||||
m.sort_order
|
||||
FROM wr_menu m
|
||||
JOIN wr_menu_role mr ON m.menu_id = mr.menu_id
|
||||
WHERE mr.role_id = ANY($1)
|
||||
AND m.is_active = true
|
||||
ORDER BY m.parent_menu_id NULLS FIRST, m.sort_order
|
||||
`, [roleIds])
|
||||
|
||||
// 계층 구조로 변환
|
||||
const menuMap = new Map<number, any>()
|
||||
const rootMenus: any[] = []
|
||||
|
||||
for (const m of menus) {
|
||||
const menuItem = {
|
||||
menuId: m.menu_id,
|
||||
menuCode: m.menu_code,
|
||||
menuName: m.menu_name,
|
||||
menuPath: m.menu_path,
|
||||
menuIcon: m.menu_icon,
|
||||
parentMenuId: m.parent_menu_id,
|
||||
sortOrder: m.sort_order,
|
||||
children: []
|
||||
}
|
||||
menuMap.set(m.menu_id, menuItem)
|
||||
}
|
||||
|
||||
for (const m of menus) {
|
||||
const menuItem = menuMap.get(m.menu_id)
|
||||
if (m.parent_menu_id && menuMap.has(m.parent_menu_id)) {
|
||||
menuMap.get(m.parent_menu_id).children.push(menuItem)
|
||||
} else if (!m.parent_menu_id) {
|
||||
rootMenus.push(menuItem)
|
||||
}
|
||||
}
|
||||
|
||||
return { menus: rootMenus }
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { query, execute } from '../../utils/db'
|
||||
import { hashPassword, generateTempPassword } from '../../utils/password'
|
||||
import { sendTempPasswordEmail } from '../../utils/email'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
|
||||
interface ResetPasswordBody {
|
||||
email: string
|
||||
name: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 찾기 (임시 비밀번호 발급)
|
||||
* POST /api/auth/reset-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<ResetPasswordBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (!body.email || !body.name) {
|
||||
throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const emailLower = body.email.toLowerCase()
|
||||
const nameTrimmed = body.name.trim()
|
||||
|
||||
// 사용자 조회 (이메일 + 이름)
|
||||
let sql = `SELECT * FROM wr_employee_info WHERE employee_email = $1 AND employee_name = $2`
|
||||
const params: any[] = [emailLower, nameTrimmed]
|
||||
|
||||
// 핸드폰 번호도 제공된 경우 추가 검증
|
||||
if (body.phone) {
|
||||
const phoneClean = body.phone.replace(/[^0-9]/g, '')
|
||||
sql += ` AND REPLACE(employee_phone, '-', '') = $3`
|
||||
params.push(phoneClean)
|
||||
}
|
||||
|
||||
const employees = await query<any>(sql, params)
|
||||
|
||||
if (employees.length === 0) {
|
||||
// 보안상 동일한 메시지 반환
|
||||
throw createError({ statusCode: 400, message: '입력하신 정보와 일치하는 계정을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const employee = employees[0]
|
||||
|
||||
// 임시 비밀번호 생성
|
||||
const tempPassword = generateTempPassword()
|
||||
const hash = await hashPassword(tempPassword)
|
||||
|
||||
// 비밀번호 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET password_hash = $1, updated_at = NOW(), updated_ip = $2
|
||||
WHERE employee_id = $3
|
||||
`, [hash, clientIp, employee.employee_id])
|
||||
|
||||
// 이메일 발송
|
||||
const emailSent = await sendTempPasswordEmail(
|
||||
employee.employee_email,
|
||||
employee.employee_name,
|
||||
tempPassword
|
||||
)
|
||||
|
||||
if (!emailSent) {
|
||||
// 이메일 발송 실패해도 비밀번호는 이미 변경됨
|
||||
console.warn('임시 비밀번호 이메일 발송 실패:', employee.employee_email)
|
||||
}
|
||||
|
||||
// 보안상 이메일 일부만 표시
|
||||
const maskedEmail = employee.employee_email.replace(/(.{2})(.*)(@.*)/, '$1***$3')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `임시 비밀번호가 ${maskedEmail}로 발송되었습니다.`,
|
||||
emailSent
|
||||
}
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../utils/session'
|
||||
|
||||
interface SelectUserBody {
|
||||
employeeId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 사용자 선택 로그인
|
||||
* POST /api/auth/select-user
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<SelectUserBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
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: '사용자를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 로그인 이력 추가
|
||||
const loginHistory = await insertReturning(`
|
||||
INSERT INTO wr_login_history (employee_id, login_ip, login_email)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING history_id
|
||||
`, [employee.employee_id, clientIp, employee.employee_email])
|
||||
|
||||
// DB 기반 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employee.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
|
||||
// 세션 쿠키 설정
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePosition: employee.employee_position
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { query, queryOne, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
import { hashPassword, generateTempPassword } from '../../utils/password'
|
||||
|
||||
interface SetPasswordBody {
|
||||
password?: string
|
||||
employeeId?: number // 관리자가 다른 사용자 설정 시
|
||||
generateTemp?: boolean // 임시 비밀번호 생성
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 설정
|
||||
* - 본인: password만 전송
|
||||
* - 관리자: employeeId + (password 또는 generateTemp)
|
||||
* POST /api/auth/set-password
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const currentUserId = await requireAuth(event)
|
||||
|
||||
const body = await readBody<SetPasswordBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
// 대상 직원 ID 결정 (없으면 본인)
|
||||
let targetEmployeeId = body.employeeId || currentUserId
|
||||
|
||||
// 다른 사람 비밀번호 설정 시 관리자 권한 확인
|
||||
if (body.employeeId && body.employeeId !== currentUserId) {
|
||||
const roles = 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
|
||||
`, [currentUserId])
|
||||
|
||||
const isAdmin = roles.some((r: any) => r.role_code === 'ROLE_ADMIN')
|
||||
if (!isAdmin) {
|
||||
throw createError({ statusCode: 403, message: '관리자 권한이 필요합니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
// 대상 직원 조회
|
||||
const targetEmployee = await queryOne<any>(`
|
||||
SELECT employee_id, employee_name, employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [targetEmployeeId])
|
||||
|
||||
if (!targetEmployee) {
|
||||
throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 비밀번호 결정
|
||||
let password = body.password
|
||||
if (body.generateTemp || !password) {
|
||||
password = generateTempPassword()
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' })
|
||||
}
|
||||
|
||||
// 비밀번호 해시
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
// 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET password_hash = $1, updated_at = NOW(), updated_ip = $2
|
||||
WHERE employee_id = $3
|
||||
`, [hash, clientIp, targetEmployeeId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employeeId: targetEmployee.employee_id,
|
||||
employeeName: targetEmployee.employee_name,
|
||||
tempPassword: body.generateTemp ? password : undefined,
|
||||
message: body.generateTemp ? '임시 비밀번호가 생성되었습니다.' : '비밀번호가 설정되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 확정
|
||||
* PUT /api/business-report/[id]/confirm
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessReportId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT * FROM wr_business_weekly_report WHERE business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (existing.status === 'confirmed') {
|
||||
throw createError({ statusCode: 400, message: '이미 확정된 보고서입니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_business_weekly_report SET
|
||||
status = 'confirmed',
|
||||
updated_at = NOW()
|
||||
WHERE business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
return { success: true, message: '보고서가 확정되었습니다.' }
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { query, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 상세 조회
|
||||
* GET /api/business-report/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessReportId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const report = await queryOne(`
|
||||
SELECT
|
||||
br.*,
|
||||
b.business_name,
|
||||
e.employee_name as created_by_name
|
||||
FROM wr_business_weekly_report br
|
||||
JOIN wr_business b ON br.business_id = b.business_id
|
||||
LEFT JOIN wr_employee_info e ON br.created_by = e.employee_id
|
||||
WHERE br.business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 소속 프로젝트 목록
|
||||
const projects = await query(`
|
||||
SELECT project_id, project_name FROM wr_project_info WHERE business_id = $1
|
||||
`, [report.business_id])
|
||||
|
||||
const projectIds = projects.map((p: any) => p.project_id)
|
||||
|
||||
// 해당 주차 실적 목록
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_id,
|
||||
t.task_description,
|
||||
t.task_type,
|
||||
t.task_hours,
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
e.employee_id,
|
||||
e.employee_name
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE t.project_id = ANY($1)
|
||||
AND r.report_year = $2
|
||||
AND r.report_week = $3
|
||||
ORDER BY p.project_name, e.employee_name
|
||||
`, [projectIds, report.report_year, report.report_week])
|
||||
|
||||
return {
|
||||
report: {
|
||||
businessReportId: report.business_report_id,
|
||||
businessId: report.business_id,
|
||||
businessName: report.business_name,
|
||||
reportYear: report.report_year,
|
||||
reportWeek: report.report_week,
|
||||
weekStartDate: report.week_start_date,
|
||||
weekEndDate: report.week_end_date,
|
||||
aiSummary: report.ai_summary,
|
||||
manualSummary: report.manual_summary,
|
||||
status: report.status,
|
||||
createdByName: report.created_by_name,
|
||||
createdAt: report.created_at,
|
||||
updatedAt: report.updated_at
|
||||
},
|
||||
tasks: tasks.map((t: any) => ({
|
||||
taskId: t.task_id,
|
||||
taskDescription: t.task_description,
|
||||
taskType: t.task_type,
|
||||
taskHours: t.task_hours,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
employeeId: t.employee_id,
|
||||
employeeName: t.employee_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 수정
|
||||
* PUT /api/business-report/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessReportId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<{ manualSummary: string }>(event)
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT * FROM wr_business_weekly_report WHERE business_report_id = $1
|
||||
`, [businessReportId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (existing.status === 'confirmed') {
|
||||
throw createError({ statusCode: 400, message: '확정된 보고서는 수정할 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_business_weekly_report SET
|
||||
manual_summary = $1,
|
||||
updated_at = NOW()
|
||||
WHERE business_report_id = $2
|
||||
`, [body.manualSummary, businessReportId])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
import { query, queryOne, insertReturning, execute } from '../../utils/db'
|
||||
import { callOpenAI } from '../../utils/openai'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface GenerateBody {
|
||||
businessId: number
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업 주간보고 취합 생성
|
||||
* POST /api/business-report/generate
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<GenerateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.businessId || !body.reportYear || !body.reportWeek) {
|
||||
throw createError({ statusCode: 400, message: '필수 파라미터가 누락되었습니다.' })
|
||||
}
|
||||
|
||||
// 기존 보고서 확인
|
||||
const existing = await queryOne(`
|
||||
SELECT * FROM wr_business_weekly_report
|
||||
WHERE business_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [body.businessId, body.reportYear, body.reportWeek])
|
||||
|
||||
// 사업에 속한 프로젝트 목록
|
||||
const projects = await query(`
|
||||
SELECT project_id, project_name FROM wr_project_info WHERE business_id = $1
|
||||
`, [body.businessId])
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '해당 사업에 속한 프로젝트가 없습니다.' })
|
||||
}
|
||||
|
||||
const projectIds = projects.map((p: any) => p.project_id)
|
||||
|
||||
// 해당 주차 주간보고 실적 조회
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_description,
|
||||
t.task_type,
|
||||
t.task_hours,
|
||||
p.project_name,
|
||||
e.employee_name
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE t.project_id = ANY($1)
|
||||
AND r.report_year = $2
|
||||
AND r.report_week = $3
|
||||
ORDER BY p.project_name, e.employee_name
|
||||
`, [projectIds, body.reportYear, body.reportWeek])
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '해당 주차에 등록된 실적이 없습니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트별로 그룹화
|
||||
const groupedTasks: Record<string, any[]> = {}
|
||||
for (const task of tasks) {
|
||||
const key = task.project_name
|
||||
if (!groupedTasks[key]) groupedTasks[key] = []
|
||||
groupedTasks[key].push(task)
|
||||
}
|
||||
|
||||
// OpenAI 프롬프트 생성
|
||||
let taskText = ''
|
||||
for (const [projectName, projectTasks] of Object.entries(groupedTasks)) {
|
||||
taskText += `\n[${projectName}]\n`
|
||||
for (const t of projectTasks) {
|
||||
taskText += `- ${t.employee_name}: ${t.task_description}\n`
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = `다음은 사업의 주간 실적입니다. 이를 경영진에게 보고하기 위한 간결한 요약문을 작성해주세요.
|
||||
|
||||
${taskText}
|
||||
|
||||
요약 작성 가이드:
|
||||
1. 프로젝트별로 구분하여 작성
|
||||
2. 핵심 성과와 진행 상황 중심
|
||||
3. 한국어로 작성
|
||||
4. 불릿 포인트 형식
|
||||
5. 200자 이내로 간결하게
|
||||
|
||||
JSON 형식으로 응답해주세요:
|
||||
{
|
||||
"summary": "요약 내용"
|
||||
}`
|
||||
|
||||
let aiSummary = ''
|
||||
try {
|
||||
const response = await callOpenAI([
|
||||
{ role: 'system', content: '당신은 프로젝트 관리 전문가입니다. 주간 실적을 간결하게 요약합니다.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], true)
|
||||
|
||||
const parsed = JSON.parse(response)
|
||||
aiSummary = parsed.summary || response
|
||||
} catch (e) {
|
||||
console.error('OpenAI error:', e)
|
||||
aiSummary = '(AI 요약 생성 실패)'
|
||||
}
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_business_weekly_report SET
|
||||
ai_summary = $1,
|
||||
updated_at = NOW()
|
||||
WHERE business_report_id = $2
|
||||
`, [aiSummary, existing.business_report_id])
|
||||
result = { ...existing, ai_summary: aiSummary }
|
||||
} else {
|
||||
// 신규 생성
|
||||
result = await insertReturning(`
|
||||
INSERT INTO wr_business_weekly_report (
|
||||
business_id, report_year, report_week, week_start_date, week_end_date,
|
||||
ai_summary, status, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.businessId,
|
||||
body.reportYear,
|
||||
body.reportWeek,
|
||||
body.weekStartDate,
|
||||
body.weekEndDate,
|
||||
aiSummary,
|
||||
userId
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
report: {
|
||||
businessReportId: result.business_report_id,
|
||||
aiSummary: result.ai_summary || aiSummary,
|
||||
status: result.status || 'draft'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 주간보고 목록 조회
|
||||
* GET /api/business-report/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const businessId = params.businessId ? Number(params.businessId) : null
|
||||
const year = params.year ? Number(params.year) : new Date().getFullYear()
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
br.*,
|
||||
b.business_name,
|
||||
e.employee_name as created_by_name
|
||||
FROM wr_business_weekly_report br
|
||||
JOIN wr_business b ON br.business_id = b.business_id
|
||||
LEFT JOIN wr_employee_info e ON br.created_by = e.employee_id
|
||||
WHERE br.report_year = $1
|
||||
`
|
||||
const queryParams: any[] = [year]
|
||||
let paramIndex = 2
|
||||
|
||||
if (businessId) {
|
||||
sql += ` AND br.business_id = $${paramIndex++}`
|
||||
queryParams.push(businessId)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY br.report_week DESC, br.business_id'
|
||||
|
||||
const reports = await query(sql, queryParams)
|
||||
|
||||
return {
|
||||
reports: reports.map((r: any) => ({
|
||||
businessReportId: r.business_report_id,
|
||||
businessId: r.business_id,
|
||||
businessName: r.business_name,
|
||||
reportYear: r.report_year,
|
||||
reportWeek: r.report_week,
|
||||
weekStartDate: r.week_start_date,
|
||||
weekEndDate: r.week_end_date,
|
||||
aiSummary: r.ai_summary,
|
||||
manualSummary: r.manual_summary,
|
||||
status: r.status,
|
||||
createdByName: r.created_by_name,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 삭제 (상태를 suspended로 변경)
|
||||
* DELETE /api/business/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!businessId) {
|
||||
throw createError({ statusCode: 400, message: '사업 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT business_id FROM wr_business WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '사업을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 소속 프로젝트 체크
|
||||
const projectCount = await queryOne(`
|
||||
SELECT COUNT(*) as cnt FROM wr_project_info WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (Number(projectCount?.cnt) > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `소속된 프로젝트가 ${projectCount.cnt}개 있습니다. 먼저 프로젝트를 해제하세요.`
|
||||
})
|
||||
}
|
||||
|
||||
// 완전 삭제 대신 상태 변경
|
||||
await execute(`
|
||||
UPDATE wr_business SET business_status = 'suspended', updated_at = NOW()
|
||||
WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '사업이 삭제(중단) 처리되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { query, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 상세 조회
|
||||
* GET /api/business/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!businessId) {
|
||||
throw createError({ statusCode: 400, message: '사업 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const business = await queryOne(`
|
||||
SELECT
|
||||
b.*,
|
||||
e1.employee_name as created_by_name,
|
||||
e2.employee_name as updated_by_name
|
||||
FROM wr_business b
|
||||
LEFT JOIN wr_employee_info e1 ON b.created_by = e1.employee_id
|
||||
LEFT JOIN wr_employee_info e2 ON b.updated_by = e2.employee_id
|
||||
WHERE b.business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (!business) {
|
||||
throw createError({ statusCode: 404, message: '사업을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 소속 프로젝트 목록
|
||||
const projects = await query(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
p.project_code,
|
||||
p.project_type,
|
||||
p.project_status,
|
||||
p.start_date,
|
||||
p.end_date
|
||||
FROM wr_project_info p
|
||||
WHERE p.business_id = $1
|
||||
ORDER BY p.project_name
|
||||
`, [businessId])
|
||||
|
||||
return {
|
||||
business: {
|
||||
businessId: business.business_id,
|
||||
businessName: business.business_name,
|
||||
businessCode: business.business_code,
|
||||
clientName: business.client_name,
|
||||
contractStartDate: business.contract_start_date,
|
||||
contractEndDate: business.contract_end_date,
|
||||
businessStatus: business.business_status,
|
||||
description: business.description,
|
||||
createdBy: business.created_by,
|
||||
createdByName: business.created_by_name,
|
||||
updatedBy: business.updated_by,
|
||||
updatedByName: business.updated_by_name,
|
||||
createdAt: business.created_at,
|
||||
updatedAt: business.updated_at
|
||||
},
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectName: p.project_name,
|
||||
projectCode: p.project_code,
|
||||
projectType: p.project_type,
|
||||
projectStatus: p.project_status,
|
||||
startDate: p.start_date,
|
||||
endDate: p.end_date
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
interface UpdateBusinessBody {
|
||||
businessName: string
|
||||
businessCode?: string
|
||||
clientName?: string
|
||||
contractStartDate?: string
|
||||
contractEndDate?: string
|
||||
businessStatus?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업 수정
|
||||
* PUT /api/business/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const businessId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateBusinessBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!businessId) {
|
||||
throw createError({ statusCode: 400, message: '사업 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
if (!body.businessName) {
|
||||
throw createError({ statusCode: 400, message: '사업명은 필수입니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT business_id FROM wr_business WHERE business_id = $1
|
||||
`, [businessId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '사업을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_business SET
|
||||
business_name = $1,
|
||||
business_code = $2,
|
||||
client_name = $3,
|
||||
contract_start_date = $4,
|
||||
contract_end_date = $5,
|
||||
business_status = $6,
|
||||
description = $7,
|
||||
updated_at = NOW(),
|
||||
updated_by = $8
|
||||
WHERE business_id = $9
|
||||
`, [
|
||||
body.businessName,
|
||||
body.businessCode || null,
|
||||
body.clientName || null,
|
||||
body.contractStartDate || null,
|
||||
body.contractEndDate || null,
|
||||
body.businessStatus || 'active',
|
||||
body.description || null,
|
||||
userId,
|
||||
businessId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
businessId,
|
||||
message: '사업이 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface CreateBusinessBody {
|
||||
businessName: string
|
||||
businessCode?: string
|
||||
clientName?: string
|
||||
contractStartDate?: string
|
||||
contractEndDate?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업 생성
|
||||
* POST /api/business/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateBusinessBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.businessName) {
|
||||
throw createError({ statusCode: 400, message: '사업명은 필수입니다.' })
|
||||
}
|
||||
|
||||
const business = await insertReturning(`
|
||||
INSERT INTO wr_business (
|
||||
business_name, business_code, client_name,
|
||||
contract_start_date, contract_end_date, description,
|
||||
business_status, created_by, updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.businessName,
|
||||
body.businessCode || null,
|
||||
body.clientName || null,
|
||||
body.contractStartDate || null,
|
||||
body.contractEndDate || null,
|
||||
body.description || null,
|
||||
userId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
businessId: business.business_id,
|
||||
message: '사업이 등록되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 사업 목록 조회
|
||||
* GET /api/business/list
|
||||
*
|
||||
* Query params:
|
||||
* - status: 상태 필터 (active, completed, suspended)
|
||||
* - businessName: 사업명 검색
|
||||
* - businessCode: 사업코드 검색
|
||||
* - clientName: 발주처 검색
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
|
||||
const status = params.status as string | null
|
||||
const businessName = params.businessName as string | null
|
||||
const businessCode = params.businessCode as string | null
|
||||
const clientName = params.clientName as string | null
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (status) {
|
||||
conditions.push(`b.business_status = $${paramIndex++}`)
|
||||
values.push(status)
|
||||
}
|
||||
|
||||
if (businessName) {
|
||||
conditions.push(`b.business_name ILIKE $${paramIndex++}`)
|
||||
values.push(`%${businessName}%`)
|
||||
}
|
||||
|
||||
if (businessCode) {
|
||||
conditions.push(`b.business_code ILIKE $${paramIndex++}`)
|
||||
values.push(`%${businessCode}%`)
|
||||
}
|
||||
|
||||
if (clientName) {
|
||||
conditions.push(`b.client_name ILIKE $${paramIndex++}`)
|
||||
values.push(`%${clientName}%`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
b.*,
|
||||
e.employee_name as created_by_name,
|
||||
(SELECT COUNT(*) FROM wr_project_info WHERE business_id = b.business_id) as project_count
|
||||
FROM wr_business b
|
||||
LEFT JOIN wr_employee_info e ON b.created_by = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY b.created_at DESC
|
||||
`
|
||||
|
||||
const businesses = await query(sql, values)
|
||||
|
||||
return {
|
||||
businesses: businesses.map((b: any) => ({
|
||||
businessId: b.business_id,
|
||||
businessName: b.business_name,
|
||||
businessCode: b.business_code,
|
||||
clientName: b.client_name,
|
||||
contractStartDate: b.contract_start_date,
|
||||
contractEndDate: b.contract_end_date,
|
||||
businessStatus: b.business_status,
|
||||
description: b.description,
|
||||
projectCount: Number(b.project_count),
|
||||
createdBy: b.created_by,
|
||||
createdByName: b.created_by_name,
|
||||
createdAt: b.created_at,
|
||||
updatedAt: b.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 대시보드 통계 API
|
||||
* GET /api/dashboard/stats
|
||||
*
|
||||
* - 인원별 이번 주 실적/차주 계획 시간
|
||||
* - 프로젝트별 투입 인원/시간
|
||||
* - 제출 현황
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const q = getQuery(event)
|
||||
const year = parseInt(q.year as string) || new Date().getFullYear()
|
||||
const week = parseInt(q.week as string) || getISOWeek(new Date())
|
||||
|
||||
// 1. 인원별 실적/계획 현황
|
||||
const employeeStats = await query<any>(`
|
||||
SELECT
|
||||
e.employee_id,
|
||||
e.employee_name,
|
||||
e.company,
|
||||
r.report_id,
|
||||
r.report_status,
|
||||
COALESCE(SUM(CASE WHEN t.task_type = 'WORK' THEN t.task_hours ELSE 0 END), 0) as work_hours,
|
||||
COALESCE(SUM(CASE WHEN t.task_type = 'PLAN' THEN t.task_hours ELSE 0 END), 0) as plan_hours,
|
||||
COUNT(DISTINCT CASE WHEN t.task_type = 'WORK' THEN t.project_id END) as work_project_count,
|
||||
COUNT(DISTINCT CASE WHEN t.task_type = 'PLAN' THEN t.project_id END) as plan_project_count
|
||||
FROM wr_employee_info e
|
||||
LEFT JOIN wr_weekly_report r ON e.employee_id = r.author_id
|
||||
AND r.report_year = $1 AND r.report_week = $2
|
||||
LEFT JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
WHERE e.is_active = true
|
||||
GROUP BY e.employee_id, e.employee_name, e.company, r.report_id, r.report_status
|
||||
ORDER BY work_hours DESC, e.employee_name
|
||||
`, [year, week])
|
||||
|
||||
// 2. 프로젝트별 투입 현황
|
||||
const projectStats = await query<any>(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_code,
|
||||
p.project_name,
|
||||
COUNT(DISTINCT r.author_id) as member_count,
|
||||
COALESCE(SUM(CASE WHEN t.task_type = 'WORK' THEN t.task_hours ELSE 0 END), 0) as work_hours,
|
||||
COALESCE(SUM(CASE WHEN t.task_type = 'PLAN' THEN t.task_hours ELSE 0 END), 0) as plan_hours,
|
||||
ARRAY_AGG(DISTINCT e.employee_name) as members
|
||||
FROM wr_project_info p
|
||||
JOIN wr_weekly_report_task t ON p.project_id = t.project_id
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
AND r.report_year = $1 AND r.report_week = $2
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
GROUP BY p.project_id, p.project_code, p.project_name
|
||||
ORDER BY work_hours DESC
|
||||
`, [year, week])
|
||||
|
||||
// 3. 전체 요약
|
||||
const activeEmployees = employeeStats.length
|
||||
const submittedCount = employeeStats.filter((e: any) =>
|
||||
e.report_status === 'SUBMITTED' || e.report_status === 'AGGREGATED'
|
||||
).length
|
||||
const totalWorkHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.work_hours || 0), 0)
|
||||
const totalPlanHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.plan_hours || 0), 0)
|
||||
|
||||
// 4. TODO 현황
|
||||
const todoStats = await query<any>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'PENDING') as pending,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_PROGRESS') as in_progress,
|
||||
COUNT(*) FILTER (WHERE status = 'COMPLETED' AND completed_at >= NOW() - INTERVAL '7 days') as completed_this_week,
|
||||
COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('COMPLETED', 'CANCELLED')) as overdue
|
||||
FROM wr_todo
|
||||
`, [])
|
||||
|
||||
// 5. 유지보수 현황
|
||||
const maintenanceStats = await query<any>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'PENDING') as pending,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_PROGRESS') as in_progress,
|
||||
COUNT(*) FILTER (WHERE status = 'COMPLETED' AND updated_at >= NOW() - INTERVAL '7 days') as completed_this_week
|
||||
FROM wr_maintenance_task
|
||||
`, [])
|
||||
|
||||
// 6. 최근 회의 현황
|
||||
const meetingStats = await query<any>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE meeting_date >= CURRENT_DATE - INTERVAL '7 days') as this_week,
|
||||
COUNT(*) FILTER (WHERE meeting_date >= CURRENT_DATE - INTERVAL '30 days') as this_month
|
||||
FROM wr_meeting
|
||||
`, [])
|
||||
|
||||
return {
|
||||
year,
|
||||
week,
|
||||
summary: {
|
||||
activeEmployees,
|
||||
submittedCount,
|
||||
notSubmittedCount: activeEmployees - submittedCount,
|
||||
totalWorkHours,
|
||||
totalPlanHours,
|
||||
projectCount: projectStats.length
|
||||
},
|
||||
todo: {
|
||||
pending: parseInt(todoStats[0]?.pending) || 0,
|
||||
inProgress: parseInt(todoStats[0]?.in_progress) || 0,
|
||||
completedThisWeek: parseInt(todoStats[0]?.completed_this_week) || 0,
|
||||
overdue: parseInt(todoStats[0]?.overdue) || 0
|
||||
},
|
||||
maintenance: {
|
||||
pending: parseInt(maintenanceStats[0]?.pending) || 0,
|
||||
inProgress: parseInt(maintenanceStats[0]?.in_progress) || 0,
|
||||
completedThisWeek: parseInt(maintenanceStats[0]?.completed_this_week) || 0
|
||||
},
|
||||
meeting: {
|
||||
thisWeek: parseInt(meetingStats[0]?.this_week) || 0,
|
||||
thisMonth: parseInt(meetingStats[0]?.this_month) || 0
|
||||
},
|
||||
employees: employeeStats.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
company: e.company,
|
||||
reportId: e.report_id,
|
||||
reportStatus: e.report_status,
|
||||
workHours: parseFloat(e.work_hours) || 0,
|
||||
planHours: parseFloat(e.plan_hours) || 0,
|
||||
workProjectCount: parseInt(e.work_project_count) || 0,
|
||||
planProjectCount: parseInt(e.plan_project_count) || 0,
|
||||
isSubmitted: e.report_status === 'SUBMITTED' || e.report_status === 'AGGREGATED'
|
||||
})),
|
||||
projects: projectStats.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name,
|
||||
memberCount: parseInt(p.member_count) || 0,
|
||||
workHours: parseFloat(p.work_hours) || 0,
|
||||
planHours: parseFloat(p.plan_hours) || 0,
|
||||
members: p.members || []
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
function getISOWeek(date: Date): number {
|
||||
const target = new Date(date)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
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)
|
||||
return Math.ceil((thursday.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { query, execute } from '../../../utils/db'
|
||||
import { requireAdmin } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 직원 삭제
|
||||
* DELETE /api/employee/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크 (role 기반)
|
||||
const currentUserId = await requireAdmin(event)
|
||||
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
if (!employeeId) {
|
||||
throw createError({ statusCode: 400, message: '직원 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 본인 삭제 방지
|
||||
if (parseInt(employeeId) === currentUserId) {
|
||||
throw createError({ statusCode: 400, message: '본인은 삭제할 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 직원 존재 여부 확인
|
||||
const employee = await query<any>(`
|
||||
SELECT employee_id, employee_name FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
if (!employee[0]) {
|
||||
throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 주간보고 존재 여부 확인
|
||||
const reports = await query<any>(`
|
||||
SELECT COUNT(*) as cnt FROM wr_weekly_report WHERE author_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
const reportCount = parseInt(reports[0].cnt)
|
||||
|
||||
if (reportCount > 0) {
|
||||
// 주간보고가 있으면 비활성화만
|
||||
await execute(`
|
||||
UPDATE wr_employee_info
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'deactivated',
|
||||
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])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'deleted',
|
||||
message: `${employee[0].employee_name}님이 삭제되었습니다.`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { queryOne, query } from '../../../utils/db'
|
||||
import { requireAuth, getSessionIdFromCookie, getDbSession } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 직원 상세 조회
|
||||
* GET /api/employee/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuth(event)
|
||||
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
|
||||
// 세션에서 현재 로그인 히스토리 ID 가져오기
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
const session = sessionId ? await getDbSession(sessionId) : null
|
||||
const currentHistoryId = session?.loginHistoryId || null
|
||||
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [employeeId])
|
||||
|
||||
if (!employee) {
|
||||
throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 로그인 이력 조회 (최근 20건)
|
||||
const loginHistory = await query<any>(`
|
||||
SELECT
|
||||
history_id,
|
||||
login_at,
|
||||
login_ip,
|
||||
logout_at,
|
||||
logout_ip
|
||||
FROM wr_login_history
|
||||
WHERE employee_id = $1
|
||||
ORDER BY login_at DESC
|
||||
LIMIT 20
|
||||
`, [employeeId])
|
||||
|
||||
return {
|
||||
employee: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email,
|
||||
employeePhone: employee.employee_phone,
|
||||
employeePosition: employee.employee_position,
|
||||
company: employee.company,
|
||||
joinDate: employee.join_date,
|
||||
isActive: employee.is_active,
|
||||
createdAt: employee.created_at,
|
||||
createdIp: employee.created_ip,
|
||||
updatedAt: employee.updated_at,
|
||||
updatedIp: employee.updated_ip,
|
||||
// 계정 관련 필드
|
||||
hasPassword: !!employee.password_hash,
|
||||
googleId: employee.google_id,
|
||||
googleEmail: employee.google_email,
|
||||
googleLinkedAt: employee.google_linked_at,
|
||||
lastLoginAt: employee.last_login_at,
|
||||
lastLoginIp: employee.last_login_ip
|
||||
},
|
||||
loginHistory: loginHistory.map(h => ({
|
||||
historyId: h.history_id,
|
||||
loginAt: h.login_at,
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
// 현재 세션인지 여부
|
||||
isCurrentSession: currentHistoryId && h.history_id === parseInt(currentHistoryId)
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
|
||||
// 빈 문자열을 null로 변환 (date 타입 등에서 필요)
|
||||
const emptyToNull = (value: any) => (value === '' ? null : value)
|
||||
|
||||
interface UpdateEmployeeBody {
|
||||
employeeName?: string
|
||||
employeePhone?: string
|
||||
employeePosition?: string
|
||||
company?: 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 clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(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_name = $1,
|
||||
employee_phone = $2,
|
||||
employee_position = $3,
|
||||
company = $4,
|
||||
join_date = $5,
|
||||
is_active = $6,
|
||||
updated_at = NOW(),
|
||||
updated_ip = $7,
|
||||
updated_email = $8
|
||||
WHERE employee_id = $9
|
||||
`, [
|
||||
body.employeeName ?? existing.employee_name,
|
||||
emptyToNull(body.employeePhone) ?? existing.employee_phone,
|
||||
emptyToNull(body.employeePosition) ?? existing.employee_position,
|
||||
body.company ?? existing.company,
|
||||
emptyToNull(body.joinDate) ?? existing.join_date,
|
||||
body.isActive ?? existing.is_active,
|
||||
clientIp,
|
||||
userEmail,
|
||||
employeeId
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { insertReturning, queryOne } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../utils/user'
|
||||
|
||||
interface CreateEmployeeBody {
|
||||
employeeName: string
|
||||
employeeEmail: string
|
||||
employeePhone?: string
|
||||
employeePosition?: string
|
||||
company?: string
|
||||
joinDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 등록
|
||||
* POST /api/employee/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateEmployeeBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(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_name, employee_email, employee_phone,
|
||||
employee_position, company, join_date,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.employeeName,
|
||||
body.employeeEmail.toLowerCase(),
|
||||
body.employeePhone || null,
|
||||
body.employeePosition || null,
|
||||
body.company || '(주)터보소프트',
|
||||
body.joinDate || null,
|
||||
clientIp,
|
||||
userEmail
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employee: {
|
||||
employeeId: employee.employee_id,
|
||||
employeeName: employee.employee_name,
|
||||
employeeEmail: employee.employee_email
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
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: employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email,
|
||||
employeePhone: e.employee_phone,
|
||||
employeePosition: e.employee_position,
|
||||
company: e.company,
|
||||
joinDate: e.join_date,
|
||||
isActive: e.is_active,
|
||||
createdAt: e.created_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
}))
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { query, execute } from '../../../utils/db'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 개선의견 삭제
|
||||
* DELETE /api/feedback/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const feedbackId = getRouterParam(event, 'id')
|
||||
if (!feedbackId) {
|
||||
throw createError({ statusCode: 400, message: '피드백 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 본인 확인
|
||||
const feedback = await query<any>(`
|
||||
SELECT author_id FROM wr_feedback WHERE feedback_id = $1
|
||||
`, [feedbackId])
|
||||
|
||||
if (!feedback[0]) {
|
||||
throw createError({ statusCode: 404, message: '의견을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (feedback[0].author_id !== userId) {
|
||||
throw createError({ statusCode: 403, message: '본인의 의견만 삭제할 수 있습니다.' })
|
||||
}
|
||||
|
||||
// 공감 먼저 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
|
||||
await execute(`DELETE FROM wr_feedback_like WHERE feedback_id = $1`, [feedbackId])
|
||||
await execute(`DELETE FROM wr_feedback WHERE feedback_id = $1`, [feedbackId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '삭제되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { query, execute, queryOne } from '../../../utils/db'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 개선의견 공감 토글
|
||||
* POST /api/feedback/[id]/like
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const feedbackId = getRouterParam(event, 'id')
|
||||
if (!feedbackId) {
|
||||
throw createError({ statusCode: 400, message: '피드백 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 피드백 존재 확인
|
||||
const feedback = await query<any>(`
|
||||
SELECT feedback_id FROM wr_feedback WHERE feedback_id = $1
|
||||
`, [feedbackId])
|
||||
|
||||
if (!feedback[0]) {
|
||||
throw createError({ statusCode: 404, message: '의견을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 이미 공감했는지 확인
|
||||
const existing = await query<any>(`
|
||||
SELECT 1 FROM wr_feedback_like WHERE feedback_id = $1 AND employee_id = $2
|
||||
`, [feedbackId, userId])
|
||||
|
||||
let isLiked: boolean
|
||||
let likeCount: number
|
||||
|
||||
if (existing[0]) {
|
||||
// 공감 취소
|
||||
await execute(`
|
||||
DELETE FROM wr_feedback_like WHERE feedback_id = $1 AND employee_id = $2
|
||||
`, [feedbackId, userId])
|
||||
await execute(`
|
||||
UPDATE wr_feedback SET like_count = like_count - 1 WHERE feedback_id = $1
|
||||
`, [feedbackId])
|
||||
isLiked = false
|
||||
} else {
|
||||
// 공감 추가
|
||||
await execute(`
|
||||
INSERT INTO wr_feedback_like (feedback_id, employee_id) VALUES ($1, $2)
|
||||
`, [feedbackId, userId])
|
||||
await execute(`
|
||||
UPDATE wr_feedback SET like_count = like_count + 1 WHERE feedback_id = $1
|
||||
`, [feedbackId])
|
||||
isLiked = true
|
||||
}
|
||||
|
||||
// 최신 카운트 조회
|
||||
const updated = await queryOne<any>(`
|
||||
SELECT like_count FROM wr_feedback WHERE feedback_id = $1
|
||||
`, [feedbackId])
|
||||
likeCount = updated.like_count
|
||||
|
||||
return {
|
||||
success: true,
|
||||
isLiked,
|
||||
likeCount
|
||||
}
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { query, execute } from '../../../utils/db'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 개선의견 수정
|
||||
* PUT /api/feedback/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const feedbackId = getRouterParam(event, 'id')
|
||||
if (!feedbackId) {
|
||||
throw createError({ statusCode: 400, message: '피드백 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 본인 확인
|
||||
const feedback = await query<any>(`
|
||||
SELECT author_id FROM wr_feedback WHERE feedback_id = $1
|
||||
`, [feedbackId])
|
||||
|
||||
if (!feedback[0]) {
|
||||
throw createError({ statusCode: 404, message: '의견을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (feedback[0].author_id !== userId) {
|
||||
throw createError({ statusCode: 403, message: '본인의 의견만 수정할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{
|
||||
category?: string
|
||||
content?: string
|
||||
}>(event)
|
||||
|
||||
if (!body.content?.trim()) {
|
||||
throw createError({ statusCode: 400, message: '내용을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const validCategories = ['FEATURE', 'UI', 'BUG', 'ETC']
|
||||
if (body.category && !validCategories.includes(body.category)) {
|
||||
throw createError({ statusCode: 400, message: '올바른 카테고리를 선택해주세요.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_feedback
|
||||
SET category = COALESCE($1, category),
|
||||
content = $2,
|
||||
updated_at = NOW()
|
||||
WHERE feedback_id = $3
|
||||
`, [body.category, body.content.trim(), feedbackId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '수정되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { query, queryOne } from '../../utils/db'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 개선의견 작성
|
||||
* POST /api/feedback/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const body = await readBody<{
|
||||
category: string
|
||||
content: string
|
||||
}>(event)
|
||||
|
||||
if (!body.category || !body.content?.trim()) {
|
||||
throw createError({ statusCode: 400, message: '카테고리와 내용을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const validCategories = ['FEATURE', 'UI', 'BUG', 'ETC']
|
||||
if (!validCategories.includes(body.category)) {
|
||||
throw createError({ statusCode: 400, message: '올바른 카테고리를 선택해주세요.' })
|
||||
}
|
||||
|
||||
const result = await queryOne<any>(`
|
||||
INSERT INTO wr_feedback (author_id, category, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING feedback_id
|
||||
`, [userId, body.category, body.content.trim()])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
feedbackId: result.feedback_id,
|
||||
message: '의견이 등록되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 개선의견 목록 조회
|
||||
* GET /api/feedback/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const q = getQuery(event)
|
||||
const page = parseInt(q.page as string) || 1
|
||||
const limit = parseInt(q.limit as string) || 12
|
||||
const category = q.category as string || ''
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
// 조건 구성
|
||||
let whereClause = ''
|
||||
const countParams: any[] = []
|
||||
|
||||
if (category) {
|
||||
whereClause = 'WHERE f.category = $1'
|
||||
countParams.push(category)
|
||||
}
|
||||
|
||||
// 전체 개수
|
||||
const countResult = await query<any>(`
|
||||
SELECT COUNT(*) as total FROM wr_feedback f ${whereClause}
|
||||
`, countParams)
|
||||
const total = parseInt(countResult[0].total)
|
||||
|
||||
// 목록 조회
|
||||
let feedbacks: any[]
|
||||
|
||||
if (category) {
|
||||
feedbacks = await query<any>(`
|
||||
SELECT
|
||||
f.feedback_id,
|
||||
f.author_id,
|
||||
e.employee_name as author_name,
|
||||
f.category,
|
||||
f.content,
|
||||
f.like_count,
|
||||
f.is_resolved,
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
EXISTS(
|
||||
SELECT 1 FROM wr_feedback_like fl
|
||||
WHERE fl.feedback_id = f.feedback_id AND fl.employee_id = $1
|
||||
) as is_liked
|
||||
FROM wr_feedback f
|
||||
JOIN wr_employee_info e ON f.author_id = e.employee_id
|
||||
WHERE f.category = $2
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`, [userId, category, limit, offset])
|
||||
} else {
|
||||
feedbacks = await query<any>(`
|
||||
SELECT
|
||||
f.feedback_id,
|
||||
f.author_id,
|
||||
e.employee_name as author_name,
|
||||
f.category,
|
||||
f.content,
|
||||
f.like_count,
|
||||
f.is_resolved,
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
EXISTS(
|
||||
SELECT 1 FROM wr_feedback_like fl
|
||||
WHERE fl.feedback_id = f.feedback_id AND fl.employee_id = $1
|
||||
) as is_liked
|
||||
FROM wr_feedback f
|
||||
JOIN wr_employee_info e ON f.author_id = e.employee_id
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, [userId, limit, offset])
|
||||
}
|
||||
|
||||
return {
|
||||
feedbacks: feedbacks.map((f: any) => ({
|
||||
feedbackId: f.feedback_id,
|
||||
authorId: f.author_id,
|
||||
authorName: f.author_name,
|
||||
category: f.category,
|
||||
content: f.content,
|
||||
likeCount: f.like_count,
|
||||
isResolved: f.is_resolved,
|
||||
createdAt: f.created_at,
|
||||
updatedAt: f.updated_at,
|
||||
isLiked: f.is_liked,
|
||||
isOwner: f.author_id === userId
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 삭제
|
||||
* DELETE /api/maintenance/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT task_id, weekly_report_id FROM wr_maintenance_task WHERE task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 주간보고에 연계된 경우 경고
|
||||
if (existing.weekly_report_id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '주간보고에 연계된 업무입니다. 연계 해제 후 삭제하세요.'
|
||||
})
|
||||
}
|
||||
|
||||
await execute(`DELETE FROM wr_maintenance_task WHERE task_id = $1`, [taskId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '유지보수 업무가 삭제되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 상세 조회
|
||||
* GET /api/maintenance/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const task = await queryOne(`
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
p.project_code,
|
||||
e1.employee_name as assignee_name,
|
||||
e2.employee_name as created_by_name,
|
||||
e3.employee_name as updated_by_name
|
||||
FROM wr_maintenance_task t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e1 ON t.assignee_id = e1.employee_id
|
||||
LEFT JOIN wr_employee_info e2 ON t.created_by = e2.employee_id
|
||||
LEFT JOIN wr_employee_info e3 ON t.updated_by = e3.employee_id
|
||||
WHERE t.task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!task) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
task: {
|
||||
taskId: task.task_id,
|
||||
projectId: task.project_id,
|
||||
projectName: task.project_name,
|
||||
projectCode: task.project_code,
|
||||
batchId: task.batch_id,
|
||||
requestDate: task.request_date,
|
||||
requestTitle: task.request_title,
|
||||
requestContent: task.request_content,
|
||||
requesterName: task.requester_name,
|
||||
requesterContact: task.requester_contact,
|
||||
taskType: task.task_type,
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
assigneeId: task.assignee_id,
|
||||
assigneeName: task.assignee_name,
|
||||
devCompletedAt: task.dev_completed_at,
|
||||
opsCompletedAt: task.ops_completed_at,
|
||||
clientConfirmedAt: task.client_confirmed_at,
|
||||
resolutionContent: task.resolution_content,
|
||||
weeklyReportId: task.weekly_report_id,
|
||||
createdBy: task.created_by,
|
||||
createdByName: task.created_by_name,
|
||||
updatedBy: task.updated_by,
|
||||
updatedByName: task.updated_by_name,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
interface StatusBody {
|
||||
status: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 상태 변경
|
||||
* PUT /api/maintenance/[id]/status
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<StatusBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const validStatuses = ['PENDING', 'IN_PROGRESS', 'COMPLETED']
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
throw createError({ statusCode: 400, message: '유효하지 않은 상태입니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT task_id FROM wr_maintenance_task WHERE task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_maintenance_task SET
|
||||
status = $1,
|
||||
updated_at = NOW(),
|
||||
updated_by = $2
|
||||
WHERE task_id = $3
|
||||
`, [body.status, userId, taskId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
status: body.status,
|
||||
message: '상태가 변경되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
interface UpdateMaintenanceBody {
|
||||
projectId?: number | null
|
||||
requestDate?: string
|
||||
requestTitle?: string
|
||||
requestContent?: string
|
||||
requesterName?: string
|
||||
requesterContact?: string
|
||||
taskType?: string
|
||||
priority?: string
|
||||
status?: string
|
||||
assigneeId?: number | null
|
||||
resolutionContent?: string
|
||||
devCompletedAt?: string | null
|
||||
opsCompletedAt?: string | null
|
||||
clientConfirmedAt?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 수정
|
||||
* PUT /api/maintenance/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const taskId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateMaintenanceBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!taskId) {
|
||||
throw createError({ statusCode: 400, message: '업무 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const existing = await queryOne(`
|
||||
SELECT task_id FROM wr_maintenance_task WHERE task_id = $1
|
||||
`, [taskId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '업무를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_maintenance_task SET
|
||||
project_id = $1,
|
||||
request_date = $2,
|
||||
request_title = $3,
|
||||
request_content = $4,
|
||||
requester_name = $5,
|
||||
requester_contact = $6,
|
||||
task_type = $7,
|
||||
priority = $8,
|
||||
status = $9,
|
||||
assignee_id = $10,
|
||||
resolution_content = $11,
|
||||
dev_completed_at = $12,
|
||||
ops_completed_at = $13,
|
||||
client_confirmed_at = $14,
|
||||
updated_at = NOW(),
|
||||
updated_by = $15
|
||||
WHERE task_id = $16
|
||||
`, [
|
||||
body.projectId ?? null,
|
||||
body.requestDate,
|
||||
body.requestTitle,
|
||||
body.requestContent || null,
|
||||
body.requesterName || null,
|
||||
body.requesterContact || null,
|
||||
body.taskType || 'GENERAL',
|
||||
body.priority || 'MEDIUM',
|
||||
body.status || 'PENDING',
|
||||
body.assigneeId ?? null,
|
||||
body.resolutionContent || null,
|
||||
body.devCompletedAt || null,
|
||||
body.opsCompletedAt || null,
|
||||
body.clientConfirmedAt || null,
|
||||
userId,
|
||||
taskId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
message: '유지보수 업무가 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { insertReturning, query } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface TaskItem {
|
||||
requestDate: string | null
|
||||
requestTitle: string
|
||||
requestContent: string | null
|
||||
requesterName: string | null
|
||||
requesterContact: string | null
|
||||
taskType: string
|
||||
priority: string
|
||||
resolutionContent: string | null
|
||||
isDuplicate?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
interface BulkCreateBody {
|
||||
projectId: number
|
||||
batchId: number
|
||||
tasks: TaskItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 일괄 등록
|
||||
* POST /api/maintenance/bulk-create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<BulkCreateBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' })
|
||||
}
|
||||
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 업무가 없습니다.' })
|
||||
}
|
||||
|
||||
// 선택된 항목만 필터 (selected가 false가 아닌 것)
|
||||
const tasksToInsert = body.tasks.filter(t => t.selected !== false && !t.isDuplicate)
|
||||
|
||||
if (tasksToInsert.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 업무가 없습니다. (모두 제외되었거나 중복)' })
|
||||
}
|
||||
|
||||
const inserted: number[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const task of tasksToInsert) {
|
||||
try {
|
||||
const result = await insertReturning(`
|
||||
INSERT INTO wr_maintenance_task (
|
||||
project_id, batch_id, request_date, request_title, request_content,
|
||||
requester_name, requester_contact, task_type, priority, status,
|
||||
resolution_content, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $11)
|
||||
RETURNING task_id
|
||||
`, [
|
||||
body.projectId,
|
||||
body.batchId,
|
||||
task.requestDate || null,
|
||||
task.requestTitle,
|
||||
task.requestContent || null,
|
||||
task.requesterName || null,
|
||||
task.requesterContact || null,
|
||||
task.taskType || 'other',
|
||||
task.priority || 'medium',
|
||||
task.resolutionContent || null,
|
||||
userId
|
||||
])
|
||||
inserted.push(result.task_id)
|
||||
} catch (e: any) {
|
||||
errors.push(`${task.requestTitle}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
insertedCount: inserted.length,
|
||||
errorCount: errors.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
taskIds: inserted
|
||||
}
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface CreateMaintenanceBody {
|
||||
projectId?: number
|
||||
requestDate: string
|
||||
requestTitle: string
|
||||
requestContent?: string
|
||||
requesterName?: string
|
||||
requesterContact?: string
|
||||
taskType?: string
|
||||
priority?: string
|
||||
assigneeId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무 생성
|
||||
* POST /api/maintenance/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateMaintenanceBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.requestTitle) {
|
||||
throw createError({ statusCode: 400, message: '제목은 필수입니다.' })
|
||||
}
|
||||
|
||||
if (!body.requestDate) {
|
||||
throw createError({ statusCode: 400, message: '요청일은 필수입니다.' })
|
||||
}
|
||||
|
||||
const task = await insertReturning(`
|
||||
INSERT INTO wr_maintenance_task (
|
||||
project_id, request_date, request_title, request_content,
|
||||
requester_name, requester_contact, task_type, priority,
|
||||
assignee_id, status, created_by, updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $10)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.projectId || null,
|
||||
body.requestDate,
|
||||
body.requestTitle,
|
||||
body.requestContent || null,
|
||||
body.requesterName || null,
|
||||
body.requesterContact || null,
|
||||
body.taskType || 'GENERAL',
|
||||
body.priority || 'MEDIUM',
|
||||
body.assigneeId || null,
|
||||
userId
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId: task.task_id,
|
||||
message: '유지보수 업무가 등록되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 목록 조회
|
||||
* GET /api/maintenance/list
|
||||
*
|
||||
* Query params:
|
||||
* - projectId: 프로젝트 ID
|
||||
* - status: 상태 (PENDING, IN_PROGRESS, COMPLETED)
|
||||
* - priority: 우선순위 (HIGH, MEDIUM, LOW)
|
||||
* - keyword: 검색어 (제목, 내용, 요청자)
|
||||
* - startDate, endDate: 요청일 범위
|
||||
* - page, pageSize: 페이지네이션
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const status = params.status as string | null
|
||||
const priority = params.priority as string | null
|
||||
const keyword = params.keyword as string | null
|
||||
const startDate = params.startDate as string | null
|
||||
const endDate = params.endDate as string | null
|
||||
const page = Number(params.page) || 1
|
||||
const pageSize = Number(params.pageSize) || 20
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`t.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(`t.status = $${paramIndex++}`)
|
||||
values.push(status)
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
conditions.push(`t.priority = $${paramIndex++}`)
|
||||
values.push(priority)
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(t.request_title ILIKE $${paramIndex} OR t.request_content ILIKE $${paramIndex} OR t.requester_name ILIKE $${paramIndex})`)
|
||||
values.push(`%${keyword}%`)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`t.request_date >= $${paramIndex++}`)
|
||||
values.push(startDate)
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`t.request_date <= $${paramIndex++}`)
|
||||
values.push(endDate)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
// 전체 카운트
|
||||
const countSql = `SELECT COUNT(*) as total FROM wr_maintenance_task t ${whereClause}`
|
||||
const countResult = await query(countSql, values)
|
||||
const total = Number(countResult[0]?.total || 0)
|
||||
|
||||
// 목록 조회
|
||||
const offset = (page - 1) * pageSize
|
||||
const listSql = `
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
e.employee_name as assignee_name
|
||||
FROM wr_maintenance_task t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON t.assignee_id = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY t.request_date DESC, t.task_id DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`
|
||||
values.push(pageSize, offset)
|
||||
|
||||
const tasks = await query(listSql, values)
|
||||
|
||||
return {
|
||||
tasks: tasks.map((t: any) => ({
|
||||
taskId: t.task_id,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
requestDate: t.request_date,
|
||||
requestTitle: t.request_title,
|
||||
requestContent: t.request_content,
|
||||
requesterName: t.requester_name,
|
||||
requesterContact: t.requester_contact,
|
||||
taskType: t.task_type,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
assigneeId: t.assignee_id,
|
||||
assigneeName: t.assignee_name,
|
||||
devCompletedAt: t.dev_completed_at,
|
||||
opsCompletedAt: t.ops_completed_at,
|
||||
clientConfirmedAt: t.client_confirmed_at,
|
||||
createdAt: t.created_at
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 주간보고 연계용 유지보수 업무 조회
|
||||
* 해당 주차에 완료된 유지보수 업무 목록
|
||||
* GET /api/maintenance/report/available
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const weekStartDate = params.weekStartDate as string
|
||||
const weekEndDate = params.weekEndDate as string
|
||||
|
||||
if (!projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 해당 주차에 완료된 유지보수 업무 (아직 주간보고에 연결 안 된 것)
|
||||
const sql = `
|
||||
SELECT
|
||||
m.task_id,
|
||||
m.request_date,
|
||||
m.request_title,
|
||||
m.request_content,
|
||||
m.requester_name,
|
||||
m.task_type,
|
||||
m.priority,
|
||||
m.status,
|
||||
m.resolution_content,
|
||||
m.dev_completed_at,
|
||||
m.ops_completed_at,
|
||||
m.client_confirmed_at,
|
||||
m.weekly_report_id
|
||||
FROM wr_maintenance_task m
|
||||
WHERE m.project_id = $1
|
||||
AND m.status = 'COMPLETED'
|
||||
AND m.weekly_report_id IS NULL
|
||||
AND (
|
||||
(m.dev_completed_at >= $2 AND m.dev_completed_at <= $3)
|
||||
OR (m.ops_completed_at >= $2 AND m.ops_completed_at <= $3)
|
||||
OR (m.client_confirmed_at >= $2 AND m.client_confirmed_at <= $3)
|
||||
)
|
||||
ORDER BY m.dev_completed_at DESC NULLS LAST, m.task_id DESC
|
||||
`
|
||||
|
||||
const tasks = await query(sql, [projectId, weekStartDate, weekEndDate + ' 23:59:59'])
|
||||
|
||||
return {
|
||||
tasks: tasks.map((t: any) => ({
|
||||
taskId: t.task_id,
|
||||
requestDate: t.request_date,
|
||||
requestTitle: t.request_title,
|
||||
requestContent: t.request_content,
|
||||
requesterName: t.requester_name,
|
||||
taskType: t.task_type,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
resolutionContent: t.resolution_content,
|
||||
devCompletedAt: t.dev_completed_at,
|
||||
opsCompletedAt: t.ops_completed_at,
|
||||
clientConfirmedAt: t.client_confirmed_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { callOpenAI } from '../../../utils/openai'
|
||||
|
||||
interface TaskInput {
|
||||
taskId: number
|
||||
requestTitle: string
|
||||
requestContent?: string
|
||||
taskType: string
|
||||
resolutionContent?: string
|
||||
}
|
||||
|
||||
interface GenerateBody {
|
||||
tasks: TaskInput[]
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무를 주간보고 실적 문장으로 변환
|
||||
* POST /api/maintenance/report/generate-text
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<GenerateBody>(event)
|
||||
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '업무 목록이 필요합니다.' })
|
||||
}
|
||||
|
||||
// 유형별 그룹화
|
||||
const grouped: Record<string, TaskInput[]> = {}
|
||||
for (const task of body.tasks) {
|
||||
const type = task.taskType || 'other'
|
||||
if (!grouped[type]) grouped[type] = []
|
||||
grouped[type].push(task)
|
||||
}
|
||||
|
||||
// OpenAI 프롬프트
|
||||
const taskList = body.tasks.map((t, i) =>
|
||||
`${i+1}. [${t.taskType}] ${t.requestTitle}${t.resolutionContent ? ' → ' + t.resolutionContent : ''}`
|
||||
).join('\n')
|
||||
|
||||
const prompt = `다음 유지보수 업무 목록을 주간보고 실적으로 작성해주세요.
|
||||
|
||||
업무 목록:
|
||||
${taskList}
|
||||
|
||||
작성 가이드:
|
||||
1. 유사한 업무는 하나로 병합 (예: "XX 관련 버그 수정 3건")
|
||||
2. 주간보고에 적합한 간결한 문장으로 작성
|
||||
3. 기술적 용어는 유지하되 명확하게
|
||||
4. 각 실적은 한 줄로 작성
|
||||
|
||||
JSON 형식으로 응답:
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"description": "실적 문장",
|
||||
"sourceTaskIds": [원본 task_id 배열],
|
||||
"taskType": "bug|feature|inquiry|other"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
try {
|
||||
const response = await callOpenAI([
|
||||
{ role: 'system', content: '주간보고 작성 전문가입니다. 유지보수 업무를 간결하고 명확한 실적 문장으로 변환합니다.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], true)
|
||||
|
||||
const parsed = JSON.parse(response)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
generatedTasks: parsed.tasks || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('OpenAI error:', e)
|
||||
|
||||
// 실패 시 기본 변환
|
||||
const defaultTasks = body.tasks.map(t => ({
|
||||
description: `[${getTypeLabel(t.taskType)}] ${t.requestTitle}`,
|
||||
sourceTaskIds: [t.taskId],
|
||||
taskType: t.taskType
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
generatedTasks: defaultTasks,
|
||||
fallback: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
bug: '버그수정',
|
||||
feature: '기능개선',
|
||||
inquiry: '문의대응',
|
||||
other: '기타'
|
||||
}
|
||||
return labels[type] || '기타'
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { insertReturning, execute, query } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
|
||||
interface TaskItem {
|
||||
description: string
|
||||
sourceTaskIds: number[]
|
||||
taskType: string
|
||||
taskHours?: number
|
||||
}
|
||||
|
||||
interface LinkBody {
|
||||
reportId: number
|
||||
projectId: number
|
||||
tasks: TaskItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 유지보수 업무를 주간보고 실적으로 등록
|
||||
* POST /api/maintenance/report/link
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LinkBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
|
||||
if (!body.reportId || !body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '보고서 ID와 프로젝트 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 실적이 없습니다.' })
|
||||
}
|
||||
|
||||
const insertedTaskIds: number[] = []
|
||||
const linkedMaintenanceIds: number[] = []
|
||||
|
||||
for (const task of body.tasks) {
|
||||
// 주간보고 실적 등록
|
||||
const result = await insertReturning(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours,
|
||||
is_completed, created_ip, created_email
|
||||
) VALUES ($1, $2, $3, $4, $5, true, $6, $7)
|
||||
RETURNING task_id
|
||||
`, [
|
||||
body.reportId,
|
||||
body.projectId,
|
||||
task.taskType || 'other',
|
||||
task.description,
|
||||
task.taskHours || null,
|
||||
clientIp,
|
||||
userEmail
|
||||
])
|
||||
|
||||
const newTaskId = result.task_id
|
||||
insertedTaskIds.push(newTaskId)
|
||||
|
||||
// 유지보수 업무와 연결
|
||||
if (task.sourceTaskIds && task.sourceTaskIds.length > 0) {
|
||||
for (const maintenanceTaskId of task.sourceTaskIds) {
|
||||
await execute(`
|
||||
UPDATE wr_maintenance_task
|
||||
SET weekly_report_id = $1, updated_at = NOW()
|
||||
WHERE task_id = $2 AND weekly_report_id IS NULL
|
||||
`, [body.reportId, maintenanceTaskId])
|
||||
linkedMaintenanceIds.push(maintenanceTaskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
insertedCount: insertedTaskIds.length,
|
||||
linkedMaintenanceCount: linkedMaintenanceIds.length,
|
||||
taskIds: insertedTaskIds
|
||||
}
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 통계
|
||||
* GET /api/maintenance/stats
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const year = params.year ? Number(params.year) : new Date().getFullYear()
|
||||
const month = params.month ? Number(params.month) : null
|
||||
|
||||
const conditions: string[] = ['EXTRACT(YEAR FROM m.request_date) = $1']
|
||||
const values: any[] = [year]
|
||||
let paramIndex = 2
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`m.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (month) {
|
||||
conditions.push(`EXTRACT(MONTH FROM m.request_date) = $${paramIndex++}`)
|
||||
values.push(month)
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ')
|
||||
|
||||
// 상태별 통계
|
||||
const statusStats = await query(`
|
||||
SELECT
|
||||
m.status,
|
||||
COUNT(*) as count
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
GROUP BY m.status
|
||||
`, values)
|
||||
|
||||
// 유형별 통계
|
||||
const typeStats = await query(`
|
||||
SELECT
|
||||
m.task_type,
|
||||
COUNT(*) as count
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
GROUP BY m.task_type
|
||||
`, values)
|
||||
|
||||
// 우선순위별 통계
|
||||
const priorityStats = await query(`
|
||||
SELECT
|
||||
m.priority,
|
||||
COUNT(*) as count
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
GROUP BY m.priority
|
||||
`, values)
|
||||
|
||||
// 월별 추이 (연간)
|
||||
const monthlyTrend = await query(`
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM m.request_date) as month,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed
|
||||
FROM wr_maintenance_task m
|
||||
WHERE EXTRACT(YEAR FROM m.request_date) = $1
|
||||
${projectId ? 'AND m.project_id = $2' : ''}
|
||||
GROUP BY EXTRACT(MONTH FROM m.request_date)
|
||||
ORDER BY month
|
||||
`, projectId ? [year, projectId] : [year])
|
||||
|
||||
// 담당자별 통계
|
||||
const assigneeStats = await query(`
|
||||
SELECT
|
||||
e.employee_id,
|
||||
e.employee_name,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed
|
||||
FROM wr_maintenance_task m
|
||||
JOIN wr_employee_info e ON m.assignee_id = e.employee_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY e.employee_id, e.employee_name
|
||||
ORDER BY total DESC
|
||||
LIMIT 10
|
||||
`, values)
|
||||
|
||||
// 프로젝트별 통계
|
||||
const projectStats = await query(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed
|
||||
FROM wr_maintenance_task m
|
||||
JOIN wr_project_info p ON m.project_id = p.project_id
|
||||
WHERE EXTRACT(YEAR FROM m.request_date) = $1
|
||||
${month ? 'AND EXTRACT(MONTH FROM m.request_date) = $2' : ''}
|
||||
GROUP BY p.project_id, p.project_name
|
||||
ORDER BY total DESC
|
||||
LIMIT 10
|
||||
`, month ? [year, month] : [year])
|
||||
|
||||
// 전체 합계
|
||||
const totalResult = await query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed,
|
||||
COUNT(CASE WHEN m.status = 'PENDING' THEN 1 END) as pending,
|
||||
COUNT(CASE WHEN m.status = 'IN_PROGRESS' THEN 1 END) as in_progress
|
||||
FROM wr_maintenance_task m
|
||||
WHERE ${whereClause}
|
||||
`, values)
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
projectId,
|
||||
summary: {
|
||||
total: Number(totalResult[0]?.total || 0),
|
||||
completed: Number(totalResult[0]?.completed || 0),
|
||||
pending: Number(totalResult[0]?.pending || 0),
|
||||
inProgress: Number(totalResult[0]?.in_progress || 0)
|
||||
},
|
||||
byStatus: statusStats.map((s: any) => ({ status: s.status, count: Number(s.count) })),
|
||||
byType: typeStats.map((t: any) => ({ taskType: t.task_type, count: Number(t.count) })),
|
||||
byPriority: priorityStats.map((p: any) => ({ priority: p.priority, count: Number(p.count) })),
|
||||
monthlyTrend: monthlyTrend.map((m: any) => ({
|
||||
month: Number(m.month),
|
||||
total: Number(m.total),
|
||||
completed: Number(m.completed)
|
||||
})),
|
||||
byAssignee: assigneeStats.map((a: any) => ({
|
||||
employeeId: a.employee_id,
|
||||
employeeName: a.employee_name,
|
||||
total: Number(a.total),
|
||||
completed: Number(a.completed)
|
||||
})),
|
||||
byProject: projectStats.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectName: p.project_name,
|
||||
total: Number(p.total),
|
||||
completed: Number(p.completed)
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
import { insertReturning, query, queryOne } from '../../utils/db'
|
||||
import { callOpenAI } from '../../utils/openai'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
/**
|
||||
* 유지보수 업무 엑셀/CSV 업로드 및 AI 파싱
|
||||
* POST /api/maintenance/upload
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
// multipart/form-data 처리
|
||||
const formData = await readMultipartFormData(event)
|
||||
if (!formData || formData.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '파일을 업로드해주세요.' })
|
||||
}
|
||||
|
||||
const fileField = formData.find(f => f.name === 'file')
|
||||
const projectIdField = formData.find(f => f.name === 'projectId')
|
||||
|
||||
if (!fileField || !fileField.data) {
|
||||
throw createError({ statusCode: 400, message: '파일이 필요합니다.' })
|
||||
}
|
||||
|
||||
const projectId = projectIdField?.data ? Number(projectIdField.data.toString()) : null
|
||||
|
||||
// 파일 확장자 확인
|
||||
const filename = fileField.filename || ''
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!['xlsx', 'xls', 'csv'].includes(ext || '')) {
|
||||
throw createError({ statusCode: 400, message: '엑셀(.xlsx, .xls) 또는 CSV 파일만 지원합니다.' })
|
||||
}
|
||||
|
||||
// SheetJS로 파싱
|
||||
let rows: any[] = []
|
||||
try {
|
||||
const workbook = XLSX.read(fileField.data, { type: 'buffer' })
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const sheet = workbook.Sheets[sheetName]
|
||||
rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' })
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 400, message: '파일 파싱에 실패했습니다.' })
|
||||
}
|
||||
|
||||
if (rows.length < 2) {
|
||||
throw createError({ statusCode: 400, message: '데이터가 없습니다. (헤더 + 최소 1행)' })
|
||||
}
|
||||
|
||||
// 헤더와 데이터 분리
|
||||
const headers = rows[0] as string[]
|
||||
const dataRows = rows.slice(1).filter((r: any[]) => r.some(cell => cell !== ''))
|
||||
|
||||
if (dataRows.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '데이터 행이 없습니다.' })
|
||||
}
|
||||
|
||||
// OpenAI로 컬럼 매핑 분석
|
||||
const prompt = `다음은 유지보수 업무 목록 엑셀의 헤더입니다:
|
||||
${JSON.stringify(headers)}
|
||||
|
||||
그리고 샘플 데이터 3행입니다:
|
||||
${JSON.stringify(dataRows.slice(0, 3))}
|
||||
|
||||
각 컬럼이 다음 필드 중 어느 것에 해당하는지 매핑해주세요:
|
||||
- request_date: 요청일 (날짜)
|
||||
- request_title: 요청 제목/건명
|
||||
- request_content: 요청 내용/상세
|
||||
- requester_name: 요청자 이름
|
||||
- requester_contact: 요청자 연락처/이메일/전화
|
||||
- task_type: 유형 (bug/feature/inquiry/other)
|
||||
- priority: 우선순위 (high/medium/low)
|
||||
- resolution_content: 조치 내용/처리 결과
|
||||
|
||||
JSON 형식으로 응답 (인덱스 기반):
|
||||
{
|
||||
"mapping": {
|
||||
"request_date": 0,
|
||||
"request_title": 1,
|
||||
...
|
||||
},
|
||||
"confidence": 0.9
|
||||
}`
|
||||
|
||||
let mapping: Record<string, number> = {}
|
||||
try {
|
||||
const response = await callOpenAI([
|
||||
{ role: 'system', content: '엑셀 컬럼 매핑 전문가입니다. 정확하게 필드를 매핑합니다.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], true)
|
||||
const parsed = JSON.parse(response)
|
||||
mapping = parsed.mapping || {}
|
||||
} catch (e) {
|
||||
console.error('OpenAI mapping error:', e)
|
||||
// 기본 매핑 시도 (순서대로)
|
||||
mapping = { request_date: 0, request_title: 1, request_content: 2 }
|
||||
}
|
||||
|
||||
// 배치 ID 생성
|
||||
const batchResult = await queryOne<{ nextval: string }>(`SELECT nextval('wr_maintenance_batch_seq')`)
|
||||
const batchId = Number(batchResult?.nextval || Date.now())
|
||||
|
||||
// 데이터 변환
|
||||
const parsedTasks = dataRows.map((row: any[], idx: number) => {
|
||||
const getValue = (field: string) => {
|
||||
const colIdx = mapping[field]
|
||||
if (colIdx === undefined || colIdx === null) return null
|
||||
return row[colIdx]?.toString().trim() || null
|
||||
}
|
||||
|
||||
// 날짜 파싱
|
||||
let requestDate = getValue('request_date')
|
||||
if (requestDate) {
|
||||
// 엑셀 시리얼 넘버 처리
|
||||
if (!isNaN(Number(requestDate))) {
|
||||
const excelDate = XLSX.SSF.parse_date_code(Number(requestDate))
|
||||
if (excelDate) {
|
||||
requestDate = `${excelDate.y}-${String(excelDate.m).padStart(2,'0')}-${String(excelDate.d).padStart(2,'0')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 유형 정규화
|
||||
let taskType = getValue('task_type')?.toLowerCase() || 'other'
|
||||
if (taskType.includes('버그') || taskType.includes('오류') || taskType.includes('bug')) taskType = 'bug'
|
||||
else if (taskType.includes('기능') || taskType.includes('개선') || taskType.includes('feature')) taskType = 'feature'
|
||||
else if (taskType.includes('문의') || taskType.includes('inquiry')) taskType = 'inquiry'
|
||||
else taskType = 'other'
|
||||
|
||||
// 우선순위 정규화
|
||||
let priority = getValue('priority')?.toLowerCase() || 'medium'
|
||||
if (priority.includes('높') || priority.includes('긴급') || priority.includes('high')) priority = 'high'
|
||||
else if (priority.includes('낮') || priority.includes('low')) priority = 'low'
|
||||
else priority = 'medium'
|
||||
|
||||
return {
|
||||
rowIndex: idx + 2, // 1-based + 헤더
|
||||
requestDate,
|
||||
requestTitle: getValue('request_title') || `업무 ${idx + 1}`,
|
||||
requestContent: getValue('request_content'),
|
||||
requesterName: getValue('requester_name'),
|
||||
requesterContact: getValue('requester_contact'),
|
||||
taskType,
|
||||
priority,
|
||||
resolutionContent: getValue('resolution_content'),
|
||||
isDuplicate: false
|
||||
}
|
||||
})
|
||||
|
||||
// 중복 감지 (같은 제목 + 같은 날짜)
|
||||
if (projectId) {
|
||||
for (const task of parsedTasks) {
|
||||
if (task.requestTitle && task.requestDate) {
|
||||
const dup = await queryOne(`
|
||||
SELECT task_id FROM wr_maintenance_task
|
||||
WHERE project_id = $1 AND request_title = $2 AND request_date = $3
|
||||
`, [projectId, task.requestTitle, task.requestDate])
|
||||
if (dup) {
|
||||
task.isDuplicate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
batchId,
|
||||
filename,
|
||||
totalRows: dataRows.length,
|
||||
mapping,
|
||||
headers,
|
||||
tasks: parsedTasks
|
||||
}
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 회의록 삭제
|
||||
* DELETE /api/meeting/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const meetingId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!meetingId) {
|
||||
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 회의록 존재 확인
|
||||
const existing = await queryOne(`
|
||||
SELECT meeting_id FROM wr_meeting WHERE meeting_id = $1
|
||||
`, [meetingId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// CASCADE 설정으로 참석자, 안건도 함께 삭제됨
|
||||
await execute(`DELETE FROM wr_meeting WHERE meeting_id = $1`, [meetingId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '회의록이 삭제되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
import { query, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 회의록 상세 조회
|
||||
* GET /api/meeting/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const meetingId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!meetingId) {
|
||||
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 회의록 기본 정보
|
||||
const meeting = await queryOne(`
|
||||
SELECT
|
||||
m.*,
|
||||
p.project_name,
|
||||
e.employee_name as author_name,
|
||||
e.employee_email as author_email
|
||||
FROM wr_meeting m
|
||||
LEFT JOIN wr_project_info p ON m.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON m.author_id = e.employee_id
|
||||
WHERE m.meeting_id = $1
|
||||
`, [meetingId])
|
||||
|
||||
if (!meeting) {
|
||||
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 참석자 목록
|
||||
const attendees = await query(`
|
||||
SELECT
|
||||
a.attendee_id,
|
||||
a.employee_id,
|
||||
e.employee_name,
|
||||
e.employee_email,
|
||||
e.company,
|
||||
a.external_name,
|
||||
a.external_company
|
||||
FROM wr_meeting_attendee a
|
||||
LEFT JOIN wr_employee_info e ON a.employee_id = e.employee_id
|
||||
WHERE a.meeting_id = $1
|
||||
ORDER BY a.attendee_id
|
||||
`, [meetingId])
|
||||
|
||||
// 안건 목록 (AI 분석 결과)
|
||||
const agendas = await query(`
|
||||
SELECT *
|
||||
FROM wr_meeting_agenda
|
||||
WHERE meeting_id = $1
|
||||
ORDER BY agenda_no
|
||||
`, [meetingId])
|
||||
|
||||
return {
|
||||
meeting: {
|
||||
meetingId: meeting.meeting_id,
|
||||
meetingTitle: meeting.meeting_title,
|
||||
meetingType: meeting.meeting_type,
|
||||
projectId: meeting.project_id,
|
||||
projectName: meeting.project_name,
|
||||
meetingDate: meeting.meeting_date,
|
||||
startTime: meeting.start_time,
|
||||
endTime: meeting.end_time,
|
||||
location: meeting.location,
|
||||
rawContent: meeting.raw_content,
|
||||
aiSummary: meeting.ai_summary,
|
||||
aiStatus: meeting.ai_status,
|
||||
aiProcessedAt: meeting.ai_processed_at,
|
||||
aiConfirmedAt: meeting.ai_confirmed_at,
|
||||
authorId: meeting.author_id,
|
||||
authorName: meeting.author_name,
|
||||
authorEmail: meeting.author_email,
|
||||
createdAt: meeting.created_at,
|
||||
updatedAt: meeting.updated_at
|
||||
},
|
||||
attendees: attendees.map((a: any) => ({
|
||||
attendeeId: a.attendee_id,
|
||||
employeeId: a.employee_id,
|
||||
employeeName: a.employee_name,
|
||||
employeeEmail: a.employee_email,
|
||||
company: a.company,
|
||||
externalName: a.external_name,
|
||||
externalCompany: a.external_company,
|
||||
isExternal: !a.employee_id
|
||||
})),
|
||||
agendas: agendas.map((a: any) => ({
|
||||
agendaId: a.agenda_id,
|
||||
agendaNo: a.agenda_no,
|
||||
agendaTitle: a.agenda_title,
|
||||
agendaContent: a.agenda_content,
|
||||
decisionStatus: a.decision_status,
|
||||
decisionContent: a.decision_content
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { queryOne, execute } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
|
||||
interface Attendee {
|
||||
employeeId?: number
|
||||
externalName?: string
|
||||
externalCompany?: string
|
||||
}
|
||||
|
||||
interface UpdateMeetingBody {
|
||||
meetingTitle: string
|
||||
meetingType: 'PROJECT' | 'INTERNAL'
|
||||
projectId?: number
|
||||
meetingDate: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
location?: string
|
||||
rawContent?: string
|
||||
attendees?: Attendee[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 수정
|
||||
* PUT /api/meeting/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const meetingId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateMeetingBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
|
||||
if (!meetingId) {
|
||||
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 회의록 존재 확인
|
||||
const existing = await queryOne(`
|
||||
SELECT meeting_id FROM wr_meeting WHERE meeting_id = $1
|
||||
`, [meetingId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 필수값 검증
|
||||
if (!body.meetingTitle) {
|
||||
throw createError({ statusCode: 400, message: '회의 제목은 필수입니다.' })
|
||||
}
|
||||
if (body.meetingType === 'PROJECT' && !body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트 회의는 프로젝트 선택이 필수입니다.' })
|
||||
}
|
||||
|
||||
// 회의록 UPDATE
|
||||
await execute(`
|
||||
UPDATE wr_meeting SET
|
||||
meeting_title = $1,
|
||||
meeting_type = $2,
|
||||
project_id = $3,
|
||||
meeting_date = $4,
|
||||
start_time = $5,
|
||||
end_time = $6,
|
||||
location = $7,
|
||||
raw_content = $8,
|
||||
updated_at = NOW(),
|
||||
updated_ip = $9,
|
||||
updated_email = $10
|
||||
WHERE meeting_id = $11
|
||||
`, [
|
||||
body.meetingTitle,
|
||||
body.meetingType,
|
||||
body.meetingType === 'PROJECT' ? body.projectId : null,
|
||||
body.meetingDate,
|
||||
body.startTime || null,
|
||||
body.endTime || null,
|
||||
body.location || null,
|
||||
body.rawContent || null,
|
||||
clientIp,
|
||||
userEmail,
|
||||
meetingId
|
||||
])
|
||||
|
||||
// 참석자 갱신 (기존 삭제 후 새로 INSERT)
|
||||
await execute(`DELETE FROM wr_meeting_attendee WHERE meeting_id = $1`, [meetingId])
|
||||
|
||||
if (body.attendees && body.attendees.length > 0) {
|
||||
for (const att of body.attendees) {
|
||||
if (att.employeeId) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, employee_id)
|
||||
VALUES ($1, $2)
|
||||
`, [meetingId, att.employeeId])
|
||||
} else if (att.externalName) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, external_name, external_company)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [meetingId, att.externalName, att.externalCompany || null])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meetingId,
|
||||
message: '회의록이 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import { insertReturning, query, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getCurrentUserEmail, getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface Attendee {
|
||||
employeeId?: number
|
||||
externalName?: string
|
||||
externalCompany?: string
|
||||
}
|
||||
|
||||
interface CreateMeetingBody {
|
||||
meetingTitle: string
|
||||
meetingType: 'PROJECT' | 'INTERNAL'
|
||||
projectId?: number
|
||||
meetingDate: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
location?: string
|
||||
rawContent?: string
|
||||
attendees?: Attendee[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의록 작성
|
||||
* POST /api/meeting/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateMeetingBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
// 필수값 검증
|
||||
if (!body.meetingTitle) {
|
||||
throw createError({ statusCode: 400, message: '회의 제목은 필수입니다.' })
|
||||
}
|
||||
if (!body.meetingType) {
|
||||
throw createError({ statusCode: 400, message: '회의 유형은 필수입니다.' })
|
||||
}
|
||||
if (!body.meetingDate) {
|
||||
throw createError({ statusCode: 400, message: '회의 일자는 필수입니다.' })
|
||||
}
|
||||
if (body.meetingType === 'PROJECT' && !body.projectId) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트 회의는 프로젝트 선택이 필수입니다.' })
|
||||
}
|
||||
|
||||
// 회의록 INSERT
|
||||
const meeting = await insertReturning(`
|
||||
INSERT INTO wr_meeting (
|
||||
meeting_title, meeting_type, project_id,
|
||||
meeting_date, start_time, end_time, location,
|
||||
raw_content, ai_status, author_id,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'NONE', $9, $10, $11, $10, $11)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.meetingTitle,
|
||||
body.meetingType,
|
||||
body.meetingType === 'PROJECT' ? body.projectId : null,
|
||||
body.meetingDate,
|
||||
body.startTime || null,
|
||||
body.endTime || null,
|
||||
body.location || null,
|
||||
body.rawContent || null,
|
||||
userId,
|
||||
clientIp,
|
||||
userEmail
|
||||
])
|
||||
|
||||
// 참석자 INSERT
|
||||
if (body.attendees && body.attendees.length > 0) {
|
||||
for (const att of body.attendees) {
|
||||
if (att.employeeId) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, employee_id)
|
||||
VALUES ($1, $2)
|
||||
`, [meeting.meeting_id, att.employeeId])
|
||||
} else if (att.externalName) {
|
||||
await execute(`
|
||||
INSERT INTO wr_meeting_attendee (meeting_id, external_name, external_company)
|
||||
VALUES ($1, $2, $3)
|
||||
`, [meeting.meeting_id, att.externalName, att.externalCompany || null])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meetingId: meeting.meeting_id,
|
||||
message: '회의록이 등록되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 회의록 목록 조회
|
||||
* GET /api/meeting/list
|
||||
*
|
||||
* Query params:
|
||||
* - projectId: 프로젝트 필터 (선택)
|
||||
* - meetingType: PROJECT | INTERNAL (선택)
|
||||
* - startDate: 시작일 (선택)
|
||||
* - endDate: 종료일 (선택)
|
||||
* - keyword: 검색어 (선택)
|
||||
* - page: 페이지 번호 (기본 1)
|
||||
* - pageSize: 페이지 크기 (기본 20)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const meetingType = params.meetingType as string | null
|
||||
const startDate = params.startDate as string | null
|
||||
const endDate = params.endDate as string | null
|
||||
const keyword = params.keyword as string | null
|
||||
const page = Number(params.page) || 1
|
||||
const pageSize = Number(params.pageSize) || 20
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
// WHERE 조건 구성
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`m.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (meetingType) {
|
||||
conditions.push(`m.meeting_type = $${paramIndex++}`)
|
||||
values.push(meetingType)
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`m.meeting_date >= $${paramIndex++}`)
|
||||
values.push(startDate)
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`m.meeting_date <= $${paramIndex++}`)
|
||||
values.push(endDate)
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(m.meeting_title ILIKE $${paramIndex} OR m.raw_content ILIKE $${paramIndex})`)
|
||||
values.push(`%${keyword}%`)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
// 전체 건수 조회
|
||||
const countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM wr_meeting m
|
||||
${whereClause}
|
||||
`
|
||||
const countResult = await query(countSql, values)
|
||||
const total = Number(countResult[0]?.total || 0)
|
||||
|
||||
// 목록 조회
|
||||
const listSql = `
|
||||
SELECT
|
||||
m.meeting_id,
|
||||
m.meeting_title,
|
||||
m.meeting_type,
|
||||
m.project_id,
|
||||
p.project_name,
|
||||
m.meeting_date,
|
||||
m.start_time,
|
||||
m.end_time,
|
||||
m.location,
|
||||
m.ai_status,
|
||||
m.author_id,
|
||||
e.employee_name as author_name,
|
||||
m.created_at,
|
||||
(SELECT COUNT(*) FROM wr_meeting_attendee WHERE meeting_id = m.meeting_id) as attendee_count
|
||||
FROM wr_meeting m
|
||||
LEFT JOIN wr_project_info p ON m.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON m.author_id = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY m.meeting_date DESC, m.created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`
|
||||
values.push(pageSize, offset)
|
||||
|
||||
const meetings = await query(listSql, values)
|
||||
|
||||
return {
|
||||
meetings: meetings.map((m: any) => ({
|
||||
meetingId: m.meeting_id,
|
||||
meetingTitle: m.meeting_title,
|
||||
meetingType: m.meeting_type,
|
||||
projectId: m.project_id,
|
||||
projectName: m.project_name,
|
||||
meetingDate: m.meeting_date,
|
||||
startTime: m.start_time,
|
||||
endTime: m.end_time,
|
||||
location: m.location,
|
||||
aiStatus: m.ai_status,
|
||||
authorId: m.author_id,
|
||||
authorName: m.author_name,
|
||||
attendeeCount: Number(m.attendee_count),
|
||||
createdAt: m.created_at
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
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 p.*, b.business_name, b.business_code
|
||||
FROM wr_project_info p
|
||||
LEFT JOIN wr_business b ON p.business_id = b.business_id
|
||||
WHERE p.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 {
|
||||
project: {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
projectType: project.project_type || 'SI',
|
||||
clientName: project.client_name,
|
||||
projectDescription: project.project_description,
|
||||
startDate: project.start_date,
|
||||
endDate: project.end_date,
|
||||
contractAmount: project.contract_amount,
|
||||
projectStatus: project.project_status,
|
||||
businessId: project.business_id,
|
||||
businessName: project.business_name,
|
||||
businessCode: project.business_code,
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { execute, queryOne, insertReturning } from '../../../utils/db'
|
||||
import { formatDate } from '../../../utils/week-calc'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
|
||||
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)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(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 || ' / ', '') || '신규 담당자 지정으로 종료',
|
||||
updated_at = NOW(),
|
||||
updated_ip = $2,
|
||||
updated_email = $3
|
||||
WHERE project_id = $4 AND role_type = $5 AND end_date IS NULL
|
||||
`, [startDate, clientIp, userEmail, projectId, body.roleType])
|
||||
|
||||
// 신규 담당자 등록
|
||||
const history = await insertReturning(`
|
||||
INSERT INTO wr_project_manager_history (
|
||||
project_id, employee_id, role_type, start_date, change_reason,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $6, $7)
|
||||
RETURNING *
|
||||
`, [projectId, body.employeeId, body.roleType, startDate, body.changeReason || null, clientIp, userEmail])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
historyId: history.history_id
|
||||
}
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
}))
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
|
||||
interface UpdateProjectBody {
|
||||
projectName?: string
|
||||
projectType?: string
|
||||
clientName?: string
|
||||
projectDescription?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
contractAmount?: number
|
||||
projectStatus?: string
|
||||
businessId?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 정보 수정
|
||||
* PUT /api/project/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const projectId = getRouterParam(event, 'id')
|
||||
const body = await readBody<UpdateProjectBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT * FROM wr_project_info WHERE project_id = $1
|
||||
`, [projectId])
|
||||
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: '프로젝트를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트 유형 검증
|
||||
if (body.projectType && !['SI', 'SM'].includes(body.projectType)) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트 유형은 SI 또는 SM이어야 합니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_project_info SET
|
||||
project_name = $1,
|
||||
project_type = $2,
|
||||
client_name = $3,
|
||||
project_description = $4,
|
||||
start_date = $5,
|
||||
end_date = $6,
|
||||
contract_amount = $7,
|
||||
project_status = $8,
|
||||
business_id = $9,
|
||||
updated_at = NOW(),
|
||||
updated_ip = $10,
|
||||
updated_email = $11
|
||||
WHERE project_id = $12
|
||||
`, [
|
||||
body.projectName ?? existing.project_name,
|
||||
body.projectType ?? existing.project_type ?? 'SI',
|
||||
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,
|
||||
body.businessId !== undefined ? body.businessId : existing.business_id,
|
||||
clientIp,
|
||||
userEmail,
|
||||
projectId
|
||||
])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { query, insertReturning } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../utils/user'
|
||||
|
||||
interface CreateProjectBody {
|
||||
projectName: string
|
||||
projectType?: string // SI / SM
|
||||
clientName?: string
|
||||
projectDescription?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
contractAmount?: number
|
||||
businessId?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 코드 자동 생성 (년도-일련번호)
|
||||
*/
|
||||
async function generateProjectCode(): Promise<string> {
|
||||
const year = new Date().getFullYear()
|
||||
const prefix = `${year}-`
|
||||
|
||||
// 해당 연도의 마지막 코드 조회
|
||||
const result = await query<{ project_code: string }>(`
|
||||
SELECT project_code FROM wr_project_info
|
||||
WHERE project_code LIKE $1
|
||||
ORDER BY project_code DESC
|
||||
LIMIT 1
|
||||
`, [`${prefix}%`])
|
||||
|
||||
let nextNum = 1
|
||||
if (result.length > 0 && result[0].project_code) {
|
||||
const lastCode = result[0].project_code
|
||||
const lastNum = parseInt(lastCode.split('-')[1]) || 0
|
||||
nextNum = lastNum + 1
|
||||
}
|
||||
|
||||
return `${prefix}${String(nextNum).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 등록
|
||||
* POST /api/project/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateProjectBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
|
||||
if (!body.projectName) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트명을 입력해주세요.' })
|
||||
}
|
||||
|
||||
// 프로젝트 유형 검증
|
||||
const projectType = body.projectType || 'SI'
|
||||
if (!['SI', 'SM'].includes(projectType)) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트 유형은 SI 또는 SM이어야 합니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트 코드 자동 생성
|
||||
const projectCode = await generateProjectCode()
|
||||
|
||||
const project = await insertReturning(`
|
||||
INSERT INTO wr_project_info (
|
||||
project_code, project_name, project_type, client_name, project_description,
|
||||
start_date, end_date, contract_amount, business_id,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $10, $11)
|
||||
RETURNING *
|
||||
`, [
|
||||
projectCode,
|
||||
body.projectName,
|
||||
projectType,
|
||||
body.clientName || null,
|
||||
body.projectDescription || null,
|
||||
body.startDate || null,
|
||||
body.endDate || null,
|
||||
body.contractAmount || null,
|
||||
body.businessId || null,
|
||||
clientIp,
|
||||
userEmail
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
project: {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
projectType: project.project_type,
|
||||
businessId: project.business_id
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
const businessId = queryParams.businessId ? Number(queryParams.businessId) : null
|
||||
|
||||
let sql = `
|
||||
SELECT p.*,
|
||||
b.business_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 = '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
|
||||
LEFT JOIN wr_business b ON p.business_id = b.business_id
|
||||
`
|
||||
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (status) {
|
||||
conditions.push(`p.project_status = $${paramIndex++}`)
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
if (businessId) {
|
||||
conditions.push(`p.business_id = $${paramIndex++}`)
|
||||
params.push(businessId)
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += ' WHERE ' + conditions.join(' AND ')
|
||||
}
|
||||
|
||||
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,
|
||||
projectType: p.project_type || 'SI',
|
||||
clientName: p.client_name,
|
||||
projectDescription: p.project_description,
|
||||
startDate: p.start_date,
|
||||
endDate: p.end_date,
|
||||
contractAmount: p.contract_amount,
|
||||
projectStatus: p.project_status,
|
||||
businessId: p.business_id,
|
||||
businessName: p.business_name,
|
||||
pmName: p.pm_name,
|
||||
plName: p.pl_name,
|
||||
createdAt: p.created_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { requireAuth } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 내가 보고서 작성한 프로젝트 목록
|
||||
* GET /api/project/my-projects
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
// 내가 주간보고를 작성한 프로젝트 + 전체 활성 프로젝트
|
||||
const projects = await query(`
|
||||
SELECT DISTINCT p.*,
|
||||
CASE WHEN t.project_id IS NOT NULL THEN true ELSE false END as has_my_report
|
||||
FROM wr_project_info p
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT t.project_id
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
WHERE r.author_id = $1
|
||||
) t ON p.project_id = t.project_id
|
||||
WHERE p.project_status = 'ACTIVE'
|
||||
ORDER BY has_my_report DESC, p.project_name
|
||||
`, [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
|
||||
}))
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { query } from '../../utils/db'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
// 문자열 해시 함수 (seed용)
|
||||
function hashCode(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // 32bit 정수로 변환
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
interface QualityScore {
|
||||
summary: string // 총평 (맨 위)
|
||||
specificity: { score: number; improvement: string } // 구체성
|
||||
completeness: { score: number; improvement: string } // 완결성
|
||||
timeEstimation: { score: number; improvement: string } // 시간산정
|
||||
planning: { score: number; improvement: string } // 계획성
|
||||
overall: number // 종합점수
|
||||
bestPractice: { // 모범 답안
|
||||
workTasks: string[] // 금주 실적 모범 답안
|
||||
planTasks: string[] // 차주 계획 모범 답안
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 PMO AI 리뷰 - 작성 품질 점수 + 모범 답안
|
||||
* POST /api/report/review
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { reportId } = body
|
||||
|
||||
if (!reportId) {
|
||||
throw createError({ statusCode: 400, message: 'reportId가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 주간보고 조회
|
||||
const reports = await query(`
|
||||
SELECT
|
||||
r.report_id,
|
||||
r.report_year,
|
||||
r.report_week,
|
||||
e.employee_name as author_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (reports.length === 0) {
|
||||
throw createError({ statusCode: 404, message: '주간보고를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const report = reports[0]
|
||||
|
||||
// Task 조회
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed,
|
||||
p.project_name
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE t.report_id = $1
|
||||
ORDER BY t.task_type, p.project_name
|
||||
`, [reportId])
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록된 Task가 없습니다.' })
|
||||
}
|
||||
|
||||
// Task를 실적/계획으로 분리
|
||||
const workTasks = tasks.filter((t: any) => t.task_type === 'WORK')
|
||||
const planTasks = tasks.filter((t: any) => t.task_type === 'PLAN')
|
||||
|
||||
// 프롬프트용 텍스트 생성
|
||||
let taskText = `[작성자] ${report.author_name}\n[기간] ${report.report_year}년 ${report.report_week}주차\n\n`
|
||||
|
||||
if (workTasks.length > 0) {
|
||||
taskText += `[금주 실적]\n`
|
||||
workTasks.forEach((t: any) => {
|
||||
const status = t.is_completed ? '완료' : '진행중'
|
||||
taskText += `- ${t.project_name} / ${t.task_description} / ${t.task_hours}h / ${status}\n`
|
||||
})
|
||||
taskText += '\n'
|
||||
}
|
||||
|
||||
if (planTasks.length > 0) {
|
||||
taskText += `[차주 계획]\n`
|
||||
planTasks.forEach((t: any) => {
|
||||
taskText += `- ${t.project_name} / ${t.task_description} / ${t.task_hours}h\n`
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI 품질 점수 + 모범 답안 요청
|
||||
const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)입니다.
|
||||
주간보고의 작성 품질을 평가하고, 모범 답안을 제시해주세요.
|
||||
|
||||
[평가 항목] (각 1~10점)
|
||||
1. 구체성 (specificity): 작업 내용이 어떤 기능/모듈인지 구체적으로 작성되었는지
|
||||
2. 완결성 (completeness): 필수 정보 포함 여부
|
||||
3. 시간산정 (timeEstimation): 작업 시간이 내용 대비 적절하게 배분되었는지
|
||||
4. 계획성 (planning): 차주 계획이 실현 가능하고 명확한 목표가 있는지
|
||||
|
||||
[완결성 상세 기준] - 엄격하게 적용
|
||||
- 진행중 작업에 진척률(%)이 없으면 -2점
|
||||
- 진행중 작업에 완료 예정일이 없으면 -2점
|
||||
- 완료 작업인데 산출물/결과 언급이 없으면 -1점
|
||||
- 상태(완료/진행중)가 명확하지 않으면 -1점
|
||||
|
||||
[계획성 상세 기준] - 엄격하게 적용
|
||||
- 차주 계획에 예상 소요시간 근거가 없으면 -1점
|
||||
- 차주 계획에 목표 완료일/산출물이 없으면 -2점
|
||||
- 단순 "~할 예정", "~진행" 만 있고 구체적 목표가 없으면 -2점
|
||||
- 실현 가능성이 낮은 과도한 계획이면 -1점
|
||||
|
||||
[점수 기준]
|
||||
- 1~3점: 매우 부족 (내용이 거의 없거나 한 단어 수준)
|
||||
- 4~5점: 부족 (진척률/예정일 누락, 모호한 표현)
|
||||
- 6~7점: 보통 (기본 내용은 있으나 구체성 부족)
|
||||
- 8~9점: 양호 (진척률, 예정일, 산출물 모두 명시)
|
||||
- 10점: 우수 (완벽한 모범 사례)
|
||||
|
||||
※ 진행중 작업에 진척률/예정일이 없으면 완결성은 6점 이하로 평가하세요.
|
||||
※ 차주 계획에 구체적 목표가 없으면 계획성은 6점 이하로 평가하세요.
|
||||
|
||||
[모범 답안 작성 규칙]
|
||||
- 사용자가 작성한 내용을 기반으로 더 구체적으로 보완
|
||||
- 같은 프로젝트명, 비슷한 작업 내용을 유지하되 구체성 추가
|
||||
- 진행중인 작업은 반드시 진척률(%)과 완료 예정일 추가
|
||||
- 시간이 긴 작업은 세부 내역 포함
|
||||
- 차주 계획은 목표 산출물과 예상 완료일 명시
|
||||
- 형식: "프로젝트명 / 작업내용 (세부사항, 진척률, 예정일) / 시간h / 상태"
|
||||
|
||||
[응답 규칙]
|
||||
- 반드시 아래 JSON 형식으로만 응답
|
||||
- summary: 전체적인 총평 (30~60자, 격려 포함)
|
||||
- improvement: 각 항목별 개선 포인트 (15~30자, 구체적으로)
|
||||
- bestPractice: 모범 답안 (workTasks, planTasks 배열)
|
||||
- JSON 외의 텍스트는 절대 포함하지 마세요`
|
||||
|
||||
const userPrompt = `다음 주간보고의 작성 품질을 평가하고, 모범 답안을 만들어주세요.
|
||||
|
||||
${taskText}
|
||||
|
||||
아래 JSON 형식으로만 응답하세요:
|
||||
{
|
||||
"summary": "총평 (격려 포함)",
|
||||
"specificity": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"completeness": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"timeEstimation": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"planning": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"overall": 종합점수(소수점1자리),
|
||||
"bestPractice": {
|
||||
"workTasks": ["모범답안1", "모범답안2", ...],
|
||||
"planTasks": ["모범답안1", "모범답안2", ...]
|
||||
}
|
||||
}`
|
||||
|
||||
try {
|
||||
// Task 내용 기반 seed 생성 (같은 내용 = 같은 점수)
|
||||
const seed = hashCode(taskText)
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
max_tokens: 1500,
|
||||
temperature: 0.2, // 낮춰서 일관성 강화
|
||||
seed: seed // 같은 내용 = 같은 seed = 같은 결과
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content || ''
|
||||
|
||||
// JSON 파싱
|
||||
let qualityScore: QualityScore
|
||||
try {
|
||||
// JSON 블록 추출 (```json ... ``` 형태 처리)
|
||||
let jsonStr = content
|
||||
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/)
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1]
|
||||
} else {
|
||||
// { } 사이 추출
|
||||
const braceMatch = content.match(/\{[\s\S]*\}/)
|
||||
if (braceMatch) {
|
||||
jsonStr = braceMatch[0]
|
||||
}
|
||||
}
|
||||
qualityScore = JSON.parse(jsonStr)
|
||||
} catch (parseError) {
|
||||
console.error('JSON 파싱 실패:', content)
|
||||
throw new Error('AI 응답을 파싱할 수 없습니다.')
|
||||
}
|
||||
|
||||
const reviewedAt = new Date().toISOString()
|
||||
|
||||
// DB에 저장 (ai_review에 JSON 문자열로 저장)
|
||||
await query(`
|
||||
UPDATE wr_weekly_report
|
||||
SET ai_review = $1, ai_review_at = $2
|
||||
WHERE report_id = $3
|
||||
`, [JSON.stringify(qualityScore), reviewedAt, reportId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
qualityScore,
|
||||
reviewedAt
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('OpenAI API error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'AI 품질 평가 중 오류가 발생했습니다: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
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: '취합 보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 개별 보고서 목록 (Task 기반)
|
||||
const reports = await query(`
|
||||
SELECT DISTINCT
|
||||
r.report_id,
|
||||
r.author_id,
|
||||
e.employee_name as author_name,
|
||||
e.employee_position,
|
||||
r.issue_description,
|
||||
r.vacation_description,
|
||||
r.remark_description,
|
||||
r.report_status,
|
||||
r.submitted_at
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE t.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])
|
||||
|
||||
// 각 보고서의 Task 조회
|
||||
const reportIds = reports.map((r: any) => r.report_id)
|
||||
const tasks = reportIds.length > 0 ? await query(`
|
||||
SELECT
|
||||
t.report_id,
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed
|
||||
FROM wr_weekly_report_task t
|
||||
WHERE t.report_id = ANY($1) AND t.project_id = $2
|
||||
ORDER BY t.report_id, t.task_type
|
||||
`, [reportIds, summary.project_id]) : []
|
||||
|
||||
// Task를 보고서별로 그룹핑
|
||||
const tasksByReport = new Map<number, { work: any[], plan: any[] }>()
|
||||
for (const task of tasks) {
|
||||
if (!tasksByReport.has(task.report_id)) {
|
||||
tasksByReport.set(task.report_id, { work: [], plan: [] })
|
||||
}
|
||||
const group = tasksByReport.get(task.report_id)!
|
||||
if (task.task_type === 'WORK') {
|
||||
group.work.push({
|
||||
description: task.task_description,
|
||||
hours: parseFloat(task.task_hours) || 0,
|
||||
isCompleted: task.is_completed
|
||||
})
|
||||
} else {
|
||||
group.plan.push({
|
||||
description: task.task_description,
|
||||
hours: parseFloat(task.task_hours) || 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
aggregatedAt: summary.aggregated_at,
|
||||
aiSummary: summary.ai_summary,
|
||||
aiSummaryAt: summary.ai_summary_at
|
||||
},
|
||||
reports: reports.map((r: any) => {
|
||||
const taskGroup = tasksByReport.get(r.report_id) || { work: [], plan: [] }
|
||||
return {
|
||||
reportId: r.report_id,
|
||||
authorId: r.author_id,
|
||||
authorName: r.author_name,
|
||||
authorPosition: r.employee_position,
|
||||
workTasks: taskGroup.work,
|
||||
planTasks: taskGroup.plan,
|
||||
issueDescription: r.issue_description,
|
||||
vacationDescription: r.vacation_description,
|
||||
remarkDescription: r.remark_description,
|
||||
reportStatus: r.report_status,
|
||||
submittedAt: r.submitted_at
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { execute, queryOne } from '../../../../utils/db'
|
||||
import { requireAuth } from '../../../../utils/session'
|
||||
|
||||
interface ReviewBody {
|
||||
reviewerComment?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PM 검토/코멘트 작성
|
||||
* PUT /api/report/summary/[id]/review
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
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
|
||||
`, [userId, body.reviewerComment || null, summaryId])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { query, queryOne, execute, insertReturning } from '../../../utils/db'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
interface AggregateBody {
|
||||
projectIds: number[]
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
/**
|
||||
* 다중 프로젝트 취합 실행 (OpenAI 요약 포함)
|
||||
* POST /api/report/summary/aggregate
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const body = await readBody<AggregateBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
|
||||
if (!body.projectIds || body.projectIds.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' })
|
||||
}
|
||||
if (!body.reportYear || !body.reportWeek) {
|
||||
throw createError({ statusCode: 400, message: '연도와 주차를 선택해주세요.' })
|
||||
}
|
||||
|
||||
let summaryCount = 0
|
||||
let totalMembers = 0
|
||||
const allReportIds: number[] = []
|
||||
|
||||
// 각 프로젝트별로 취합 생성
|
||||
for (const projectId of body.projectIds) {
|
||||
// 해당 프로젝트/주차의 Task 조회 (작성자 포함)
|
||||
const tasks = await query<any>(`
|
||||
SELECT
|
||||
t.task_id,
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed,
|
||||
r.report_id,
|
||||
r.author_id,
|
||||
e.employee_name as author_name,
|
||||
r.week_start_date,
|
||||
r.week_end_date
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE t.project_id = $1
|
||||
AND r.report_year = $2
|
||||
AND r.report_week = $3
|
||||
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
|
||||
ORDER BY t.task_type, e.employee_name
|
||||
`, [projectId, body.reportYear, body.reportWeek])
|
||||
|
||||
if (tasks.length === 0) continue
|
||||
|
||||
const reportIds = [...new Set(tasks.map(t => t.report_id))]
|
||||
const weekStartDate = tasks[0].week_start_date
|
||||
const weekEndDate = tasks[0].week_end_date
|
||||
|
||||
// 총 시간 계산
|
||||
const totalWorkHours = tasks
|
||||
.filter(t => t.task_type === 'WORK')
|
||||
.reduce((sum, t) => sum + (parseFloat(t.task_hours) || 0), 0)
|
||||
|
||||
// OpenAI로 요약 생성 (금주 실적 / 차주 계획 분리)
|
||||
const { workSummary, planSummary } = await generateAISummary(tasks, projectId, body.reportYear, body.reportWeek)
|
||||
|
||||
// 기존 취합 보고서 확인
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT summary_id FROM wr_aggregated_report_summary
|
||||
WHERE project_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [projectId, body.reportYear, body.reportWeek])
|
||||
|
||||
if (existing) {
|
||||
// 기존 취합 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_aggregated_report_summary
|
||||
SET report_ids = $1,
|
||||
member_count = $2,
|
||||
total_work_hours = $3,
|
||||
ai_work_summary = $4,
|
||||
ai_plan_summary = $5,
|
||||
ai_summary_at = NOW(),
|
||||
aggregated_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_ip = $6,
|
||||
updated_email = $7
|
||||
WHERE summary_id = $8
|
||||
`, [reportIds, reportIds.length, totalWorkHours, workSummary, planSummary, clientIp, userEmail, existing.summary_id])
|
||||
} else {
|
||||
// 새 취합 생성
|
||||
await insertReturning<any>(`
|
||||
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, ai_work_summary, ai_plan_summary, ai_summary_at,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11, $12, $11, $12)
|
||||
RETURNING summary_id
|
||||
`, [
|
||||
projectId, body.reportYear, body.reportWeek,
|
||||
weekStartDate, weekEndDate,
|
||||
reportIds, reportIds.length, totalWorkHours, workSummary, planSummary,
|
||||
clientIp, userEmail
|
||||
])
|
||||
}
|
||||
|
||||
summaryCount++
|
||||
totalMembers += reportIds.length
|
||||
allReportIds.push(...reportIds)
|
||||
}
|
||||
|
||||
if (summaryCount === 0) {
|
||||
throw createError({ statusCode: 400, message: '취합할 보고서가 없습니다.' })
|
||||
}
|
||||
|
||||
// 개별 보고서 상태 업데이트
|
||||
const uniqueReportIds = [...new Set(allReportIds)]
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report
|
||||
SET report_status = 'AGGREGATED',
|
||||
updated_at = NOW(),
|
||||
updated_ip = $1,
|
||||
updated_email = $2
|
||||
WHERE report_id = ANY($3)
|
||||
`, [clientIp, userEmail, uniqueReportIds])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
summaryCount,
|
||||
totalMembers: uniqueReportIds.length
|
||||
}
|
||||
})
|
||||
|
||||
// OpenAI로 금주 실적/차주 계획 분리 요약 생성
|
||||
async function generateAISummary(tasks: any[], projectId: number, year: number, week: number): Promise<{ workSummary: string, planSummary: string }> {
|
||||
// 프로젝트명 조회
|
||||
const project = await queryOne<any>(`SELECT project_name FROM wr_project_info WHERE project_id = $1`, [projectId])
|
||||
const projectName = project?.project_name || '프로젝트'
|
||||
|
||||
// Task를 실적/계획으로 분류
|
||||
const workTasks = tasks.filter(t => t.task_type === 'WORK')
|
||||
const planTasks = tasks.filter(t => t.task_type === 'PLAN')
|
||||
|
||||
// 금주 실적 요약
|
||||
const workPrompt = `당신은 주간보고 취합 전문가입니다. 아래 금주 실적을 간결하게 요약해주세요.
|
||||
|
||||
## 프로젝트: ${projectName}
|
||||
## 기간: ${year}년 ${week}주차
|
||||
|
||||
## 금주 실적 (${workTasks.length}건)
|
||||
${workTasks.map(t => `- [${t.author_name}] ${t.is_completed ? '완료' : '진행중'} | ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
|
||||
|
||||
## 요약 규칙
|
||||
1. 완료된 작업과 진행 중인 작업을 구분하여 핵심만 요약
|
||||
2. 동일/유사한 작업은 하나로 통합
|
||||
3. 담당자 이름은 생략하고 내용 위주로 작성
|
||||
4. 3~5줄 이내로 간결하게
|
||||
5. 마크다운 리스트 형식으로 작성`
|
||||
|
||||
// 차주 계획 요약
|
||||
const planPrompt = `당신은 주간보고 취합 전문가입니다. 아래 차주 계획을 간결하게 요약해주세요.
|
||||
|
||||
## 프로젝트: ${projectName}
|
||||
## 기간: ${year}년 ${week+1}주차 계획
|
||||
|
||||
## 차주 계획 (${planTasks.length}건)
|
||||
${planTasks.map(t => `- [${t.author_name}] ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
|
||||
|
||||
## 요약 규칙
|
||||
1. 주요 계획을 우선순위에 따라 요약
|
||||
2. 동일/유사한 작업은 하나로 통합
|
||||
3. 담당자 이름은 생략하고 내용 위주로 작성
|
||||
4. 2~4줄 이내로 간결하게
|
||||
5. 마크다운 리스트 형식으로 작성`
|
||||
|
||||
try {
|
||||
const [workRes, planRes] = await Promise.all([
|
||||
openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
|
||||
{ role: 'user', content: workPrompt }
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 500
|
||||
}),
|
||||
openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
|
||||
{ role: 'user', content: planPrompt }
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 500
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
workSummary: workRes.choices[0]?.message?.content || '요약 없음',
|
||||
planSummary: planRes.choices[0]?.message?.content || '요약 없음'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OpenAI 요약 생성 실패:', error)
|
||||
return {
|
||||
workSummary: '요약 생성 실패',
|
||||
planSummary: '요약 생성 실패'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { defineEventHandler, getQuery, createError } from 'h3'
|
||||
import { query } from '../../../utils/db'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const { year, week } = getQuery(event)
|
||||
|
||||
if (!year || !week) {
|
||||
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
|
||||
}
|
||||
|
||||
// 해당 주차에 제출된 보고서가 있는 프로젝트 목록
|
||||
const projects = await query(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_code,
|
||||
p.project_name,
|
||||
COUNT(DISTINCT r.report_id) as report_count
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE r.report_year = $1
|
||||
AND r.report_week = $2
|
||||
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
|
||||
GROUP BY p.project_id, p.project_code, p.project_name
|
||||
ORDER BY p.project_name
|
||||
`, [Number(year), Number(week)])
|
||||
|
||||
return {
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name,
|
||||
reportCount: parseInt(p.report_count)
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
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: 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,
|
||||
aiSummary: s.ai_summary,
|
||||
aiSummaryAt: s.ai_summary_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,150 +0,0 @@
|
||||
import { defineEventHandler, createError } from 'h3'
|
||||
import { query, queryOne, execute } from '../../../utils/db'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
/**
|
||||
* 기존 취합 보고서에 AI 요약 일괄 생성
|
||||
* POST /api/report/summary/regenerate-ai
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
// AI 요약이 없는 취합 보고서 조회
|
||||
const summaries = await query<any>(`
|
||||
SELECT s.summary_id, s.project_id, s.report_year, s.report_week,
|
||||
p.project_name
|
||||
FROM wr_aggregated_report_summary s
|
||||
JOIN wr_project_info p ON s.project_id = p.project_id
|
||||
WHERE s.ai_work_summary IS NULL OR s.ai_plan_summary IS NULL
|
||||
ORDER BY s.summary_id
|
||||
`, [])
|
||||
|
||||
console.log(`AI 요약 생성 대상: ${summaries.length}건`)
|
||||
|
||||
let successCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
for (const summary of summaries) {
|
||||
try {
|
||||
// 해당 프로젝트/주차의 Task 조회
|
||||
const tasks = await query<any>(`
|
||||
SELECT
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed,
|
||||
e.employee_name as author_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE t.project_id = $1
|
||||
AND r.report_year = $2
|
||||
AND r.report_week = $3
|
||||
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
|
||||
ORDER BY t.task_type, e.employee_name
|
||||
`, [summary.project_id, summary.report_year, summary.report_week])
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log(`Skip ${summary.summary_id}: no tasks`)
|
||||
continue
|
||||
}
|
||||
|
||||
// AI 요약 생성
|
||||
const { workSummary, planSummary } = await generateAISummary(
|
||||
tasks,
|
||||
summary.project_name,
|
||||
summary.report_year,
|
||||
summary.report_week
|
||||
)
|
||||
|
||||
// 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_aggregated_report_summary
|
||||
SET ai_work_summary = $1,
|
||||
ai_plan_summary = $2,
|
||||
ai_summary_at = NOW()
|
||||
WHERE summary_id = $3
|
||||
`, [workSummary, planSummary, summary.summary_id])
|
||||
|
||||
successCount++
|
||||
console.log(`Generated AI summary for ${summary.project_name} (${summary.report_year}-W${summary.report_week})`)
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(`Error for summary ${summary.summary_id}:`, e.message)
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
total: summaries.length,
|
||||
successCount,
|
||||
errorCount
|
||||
}
|
||||
})
|
||||
|
||||
async function generateAISummary(tasks: any[], projectName: string, year: number, week: number) {
|
||||
const workTasks = tasks.filter(t => t.task_type === 'WORK')
|
||||
const planTasks = tasks.filter(t => t.task_type === 'PLAN')
|
||||
|
||||
const workPrompt = `주간보고 취합 전문가입니다. 아래 금주 실적을 간결하게 요약해주세요.
|
||||
|
||||
## 프로젝트: ${projectName}
|
||||
## 기간: ${year}년 ${week}주차
|
||||
|
||||
## 금주 실적 (${workTasks.length}건)
|
||||
${workTasks.map(t => `- [${t.author_name}] ${t.is_completed ? '완료' : '진행중'} | ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
|
||||
|
||||
## 요약 규칙
|
||||
1. 완료된 작업과 진행 중인 작업을 구분하여 핵심만 요약
|
||||
2. 동일/유사한 작업은 하나로 통합
|
||||
3. 담당자 이름은 생략하고 내용 위주로 작성
|
||||
4. 3~5줄 이내로 간결하게
|
||||
5. 마크다운 리스트 형식으로 작성`
|
||||
|
||||
const planPrompt = `주간보고 취합 전문가입니다. 아래 차주 계획을 간결하게 요약해주세요.
|
||||
|
||||
## 프로젝트: ${projectName}
|
||||
## 기간: ${year}년 ${week+1}주차 계획
|
||||
|
||||
## 차주 계획 (${planTasks.length}건)
|
||||
${planTasks.map(t => `- [${t.author_name}] ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
|
||||
|
||||
## 요약 규칙
|
||||
1. 주요 계획을 우선순위에 따라 요약
|
||||
2. 동일/유사한 작업은 하나로 통합
|
||||
3. 담당자 이름은 생략하고 내용 위주로 작성
|
||||
4. 2~4줄 이내로 간결하게
|
||||
5. 마크다운 리스트 형식으로 작성`
|
||||
|
||||
const [workRes, planRes] = await Promise.all([
|
||||
openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
|
||||
{ role: 'user', content: workPrompt }
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 500
|
||||
}),
|
||||
openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
|
||||
{ role: 'user', content: planPrompt }
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 500
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
workSummary: workRes.choices[0]?.message?.content || '요약 없음',
|
||||
planSummary: planRes.choices[0]?.message?.content || '요약 없음'
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { defineEventHandler, getQuery, createError } from 'h3'
|
||||
import { query } from '../../../../utils/db'
|
||||
|
||||
/**
|
||||
* 주차별 취합 상세 (프로젝트별 실적/계획 테이블용)
|
||||
* GET /api/report/summary/week/detail?year=2026&week=1
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const year = queryParams.year ? parseInt(queryParams.year as string) : null
|
||||
const week = queryParams.week ? parseInt(queryParams.week as string) : null
|
||||
|
||||
if (!year || !week) {
|
||||
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
|
||||
}
|
||||
|
||||
// 해당 주차 취합 보고서 목록
|
||||
const summaries = await query(`
|
||||
SELECT
|
||||
s.summary_id,
|
||||
s.project_id,
|
||||
p.project_name,
|
||||
p.project_code,
|
||||
s.week_start_date,
|
||||
s.week_end_date,
|
||||
s.member_count,
|
||||
s.total_work_hours,
|
||||
s.ai_work_summary,
|
||||
s.ai_plan_summary,
|
||||
s.ai_summary_at,
|
||||
s.summary_status,
|
||||
s.aggregated_at
|
||||
FROM wr_aggregated_report_summary s
|
||||
JOIN wr_project_info p ON s.project_id = p.project_id
|
||||
WHERE s.report_year = $1 AND s.report_week = $2
|
||||
ORDER BY p.project_name
|
||||
`, [year, week])
|
||||
|
||||
if (summaries.length === 0) {
|
||||
throw createError({ statusCode: 404, message: '해당 주차의 취합 보고서가 없습니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트 ID 목록
|
||||
const projectIds = summaries.map((s: any) => s.project_id)
|
||||
|
||||
// 해당 주차/프로젝트의 모든 Task 조회
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.project_id,
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed,
|
||||
r.author_id,
|
||||
e.employee_name as author_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_year = $1
|
||||
AND r.report_week = $2
|
||||
AND t.project_id = ANY($3)
|
||||
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
|
||||
ORDER BY t.project_id, t.task_type, e.employee_name
|
||||
`, [year, week, projectIds])
|
||||
|
||||
// Task를 프로젝트별로 그룹핑 + 프로젝트별 인원별 시간 집계 (실적/계획 통합)
|
||||
const tasksByProject = new Map<number, { work: any[], plan: any[] }>()
|
||||
const membersByProject = new Map<number, Map<number, { name: string, workHours: number, planHours: number }>>()
|
||||
|
||||
for (const task of tasks) {
|
||||
// Task 그룹핑
|
||||
if (!tasksByProject.has(task.project_id)) {
|
||||
tasksByProject.set(task.project_id, { work: [], plan: [] })
|
||||
}
|
||||
const group = tasksByProject.get(task.project_id)!
|
||||
const taskItem = {
|
||||
description: task.task_description,
|
||||
hours: parseFloat(task.task_hours) || 0,
|
||||
isCompleted: task.is_completed,
|
||||
authorId: task.author_id,
|
||||
authorName: task.author_name
|
||||
}
|
||||
|
||||
// 프로젝트별 인원별 시간 집계
|
||||
if (!membersByProject.has(task.project_id)) {
|
||||
membersByProject.set(task.project_id, new Map())
|
||||
}
|
||||
const members = membersByProject.get(task.project_id)!
|
||||
if (!members.has(task.author_id)) {
|
||||
members.set(task.author_id, { name: task.author_name, workHours: 0, planHours: 0 })
|
||||
}
|
||||
|
||||
const hours = parseFloat(task.task_hours) || 0
|
||||
const member = members.get(task.author_id)!
|
||||
|
||||
if (task.task_type === 'WORK') {
|
||||
group.work.push(taskItem)
|
||||
member.workHours += hours
|
||||
} else {
|
||||
group.plan.push(taskItem)
|
||||
member.planHours += hours
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 인원별 시간 집계
|
||||
const memberHours = new Map<number, { name: string, workHours: number, planHours: number }>()
|
||||
for (const task of tasks) {
|
||||
const authorId = task.author_id
|
||||
if (!memberHours.has(authorId)) {
|
||||
memberHours.set(authorId, { name: task.author_name, workHours: 0, planHours: 0 })
|
||||
}
|
||||
const member = memberHours.get(authorId)!
|
||||
const hours = parseFloat(task.task_hours) || 0
|
||||
if (task.task_type === 'WORK') {
|
||||
member.workHours += hours
|
||||
} else {
|
||||
member.planHours += hours
|
||||
}
|
||||
}
|
||||
|
||||
// 첫번째 summary에서 날짜 정보 추출
|
||||
const weekInfo = {
|
||||
reportYear: year,
|
||||
reportWeek: week,
|
||||
weekStartDate: summaries[0].week_start_date,
|
||||
weekEndDate: summaries[0].week_end_date,
|
||||
totalProjects: summaries.length,
|
||||
totalWorkHours: summaries.reduce((sum: number, s: any) => sum + (parseFloat(s.total_work_hours) || 0), 0)
|
||||
}
|
||||
|
||||
// 프로젝트별 데이터 구성
|
||||
const projects = summaries.map((s: any) => {
|
||||
const taskGroup = tasksByProject.get(s.project_id) || { work: [], plan: [] }
|
||||
const projectMembers = membersByProject.get(s.project_id)
|
||||
|
||||
// 프로젝트별 인원 시간 배열 (실적+계획 통합)
|
||||
const memberHoursList = projectMembers
|
||||
? Array.from(projectMembers.values()).sort((a, b) => (b.workHours + b.planHours) - (a.workHours + a.planHours))
|
||||
: []
|
||||
|
||||
return {
|
||||
summaryId: s.summary_id,
|
||||
projectId: s.project_id,
|
||||
projectName: s.project_name,
|
||||
projectCode: s.project_code,
|
||||
memberCount: s.member_count,
|
||||
totalWorkHours: parseFloat(s.total_work_hours) || 0,
|
||||
aiWorkSummary: s.ai_work_summary,
|
||||
aiPlanSummary: s.ai_plan_summary,
|
||||
aiSummaryAt: s.ai_summary_at,
|
||||
workTasks: taskGroup.work,
|
||||
planTasks: taskGroup.plan,
|
||||
memberHours: memberHoursList // { name, workHours, planHours }
|
||||
}
|
||||
})
|
||||
|
||||
// 전체 인원별 시간 배열로 변환
|
||||
const members = Array.from(memberHours.entries()).map(([id, m]) => ({
|
||||
employeeId: id,
|
||||
employeeName: m.name,
|
||||
workHours: m.workHours,
|
||||
planHours: m.planHours,
|
||||
availableHours: Math.max(0, 40 - m.planHours)
|
||||
})).sort((a, b) => b.availableHours - a.availableHours)
|
||||
|
||||
return {
|
||||
weekInfo,
|
||||
projects,
|
||||
members
|
||||
}
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 주차별 취합 목록
|
||||
* GET /api/report/summary/weekly-list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const year = queryParams.year ? parseInt(queryParams.year as string) : new Date().getFullYear()
|
||||
|
||||
// 주차별로 그룹핑
|
||||
const rows = await query(`
|
||||
SELECT
|
||||
s.report_year,
|
||||
s.report_week,
|
||||
COUNT(DISTINCT s.project_id) as project_count,
|
||||
SUM(s.member_count) as total_members,
|
||||
SUM(COALESCE(s.total_work_hours, 0)) as total_work_hours,
|
||||
MAX(s.aggregated_at) as latest_aggregated_at,
|
||||
ARRAY_AGG(DISTINCT p.project_name ORDER BY p.project_name) as project_names
|
||||
FROM wr_aggregated_report_summary s
|
||||
JOIN wr_project_info p ON s.project_id = p.project_id
|
||||
WHERE s.report_year = $1
|
||||
GROUP BY s.report_year, s.report_week
|
||||
ORDER BY s.report_week DESC
|
||||
`, [year])
|
||||
|
||||
return {
|
||||
weeks: rows.map((r: any) => {
|
||||
const { monday, sunday } = getWeekDates(r.report_year, r.report_week)
|
||||
return {
|
||||
reportYear: r.report_year,
|
||||
reportWeek: r.report_week,
|
||||
weekStartDate: monday,
|
||||
weekEndDate: sunday,
|
||||
projectCount: parseInt(r.project_count),
|
||||
totalMembers: parseInt(r.total_members),
|
||||
totalWorkHours: parseFloat(r.total_work_hours) || 0,
|
||||
latestAggregatedAt: r.latest_aggregated_at,
|
||||
projects: r.project_names || []
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ISO 8601 주차 기준 월요일~일요일 계산
|
||||
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
|
||||
// 해당 연도의 1월 4일이 속한 주가 1주차 (ISO 8601)
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
const jan4DayOfWeek = jan4.getDay() || 7 // 일요일=7
|
||||
|
||||
// 1주차의 월요일
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek + 1)
|
||||
|
||||
// 요청된 주차의 월요일
|
||||
const monday = new Date(week1Monday)
|
||||
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
|
||||
|
||||
// 일요일
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
|
||||
return {
|
||||
monday: formatDate(monday),
|
||||
sunday: formatDate(sunday)
|
||||
}
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { query, execute } from '../../../../utils/db'
|
||||
import { requireAuth } from '../../../../utils/session'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
/**
|
||||
* 주간보고 삭제
|
||||
* DELETE /api/report/weekly/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
if (!reportId) {
|
||||
throw createError({ statusCode: 400, message: '보고서 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
const isAdmin = currentUser[0]?.employee_email === ADMIN_EMAIL
|
||||
|
||||
// 보고서 정보 조회
|
||||
const report = await query<any>(`
|
||||
SELECT report_id, author_id FROM wr_weekly_report WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report[0]) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 권한 체크: 본인 또는 관리자만 삭제 가능
|
||||
if (report[0].author_id !== userId && !isAdmin) {
|
||||
throw createError({ statusCode: 403, message: '삭제 권한이 없습니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트 실적 먼저 삭제
|
||||
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
|
||||
|
||||
// 주간보고 삭제
|
||||
await execute(`DELETE FROM wr_weekly_report WHERE report_id = $1`, [reportId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '주간보고가 삭제되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,136 +0,0 @@
|
||||
import { query, queryOne } from '../../../../utils/db'
|
||||
import { requireAuth } from '../../../../utils/session'
|
||||
|
||||
/**
|
||||
* 주간보고 상세 조회
|
||||
* GET /api/report/weekly/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
|
||||
// 마스터 조회
|
||||
const report = await queryOne<any>(`
|
||||
SELECT
|
||||
r.*,
|
||||
e.employee_name as author_name,
|
||||
e.employee_email as author_email
|
||||
FROM wr_weekly_report r
|
||||
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: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 같은 주차의 이전/다음 보고서 조회
|
||||
const prevReport = await queryOne<any>(`
|
||||
SELECT r.report_id, e.employee_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_year = $1 AND r.report_week = $2 AND r.report_id < $3
|
||||
ORDER BY r.report_id DESC
|
||||
LIMIT 1
|
||||
`, [report.report_year, report.report_week, reportId])
|
||||
|
||||
const nextReport = await queryOne<any>(`
|
||||
SELECT r.report_id, e.employee_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_year = $1 AND r.report_week = $2 AND r.report_id > $3
|
||||
ORDER BY r.report_id ASC
|
||||
LIMIT 1
|
||||
`, [report.report_year, report.report_week, reportId])
|
||||
|
||||
// Task 조회
|
||||
const tasks = await query<any>(`
|
||||
SELECT
|
||||
t.task_id,
|
||||
t.project_id,
|
||||
p.project_code,
|
||||
p.project_name,
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE t.report_id = $1
|
||||
ORDER BY t.project_id, t.task_type, t.task_id
|
||||
`, [reportId])
|
||||
|
||||
// 프로젝트별로 그룹핑
|
||||
const projectMap = new Map<number, any>()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!projectMap.has(task.project_id)) {
|
||||
projectMap.set(task.project_id, {
|
||||
projectId: task.project_id,
|
||||
projectCode: task.project_code,
|
||||
projectName: task.project_name,
|
||||
workTasks: [],
|
||||
planTasks: []
|
||||
})
|
||||
}
|
||||
|
||||
const proj = projectMap.get(task.project_id)
|
||||
const taskItem = {
|
||||
taskId: task.task_id,
|
||||
description: task.task_description,
|
||||
hours: parseFloat(task.task_hours) || 0,
|
||||
isCompleted: task.is_completed
|
||||
}
|
||||
|
||||
if (task.task_type === 'WORK') {
|
||||
proj.workTasks.push(taskItem)
|
||||
} else {
|
||||
proj.planTasks.push(taskItem)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
report: {
|
||||
reportId: report.report_id,
|
||||
authorId: report.author_id,
|
||||
authorName: report.author_name,
|
||||
authorEmail: report.author_email,
|
||||
reportYear: report.report_year,
|
||||
reportWeek: report.report_week,
|
||||
weekStartDate: formatDateOnly(report.week_start_date),
|
||||
weekEndDate: formatDateOnly(report.week_end_date),
|
||||
issueDescription: report.issue_description,
|
||||
vacationDescription: report.vacation_description,
|
||||
remarkDescription: report.remark_description,
|
||||
reportStatus: report.report_status,
|
||||
submittedAt: report.submitted_at,
|
||||
createdAt: report.created_at,
|
||||
updatedAt: report.updated_at,
|
||||
aiReview: report.ai_review,
|
||||
aiReviewAt: report.ai_review_at
|
||||
},
|
||||
prevReport: prevReport ? { reportId: prevReport.report_id, authorName: prevReport.employee_name } : null,
|
||||
nextReport: nextReport ? { reportId: nextReport.report_id, authorName: nextReport.employee_name } : null,
|
||||
projects: Array.from(projectMap.values()),
|
||||
tasks: tasks.map((t: any) => ({
|
||||
taskId: t.task_id,
|
||||
projectId: t.project_id,
|
||||
projectCode: t.project_code,
|
||||
projectName: t.project_name,
|
||||
taskType: t.task_type,
|
||||
taskDescription: t.task_description,
|
||||
taskHours: parseFloat(t.task_hours) || 0,
|
||||
isCompleted: t.is_completed
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 날짜를 YYYY-MM-DD 형식으로 변환 (타임존 보정)
|
||||
function formatDateOnly(date: Date | string | null): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const kstOffset = 9 * 60 * 60 * 1000
|
||||
const kstDate = new Date(d.getTime() + kstOffset)
|
||||
return kstDate.toISOString().split('T')[0]
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { execute, queryOne } from '../../../../utils/db'
|
||||
import { getClientIp } from '../../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../../utils/user'
|
||||
import { requireAuth } from '../../../../utils/session'
|
||||
|
||||
/**
|
||||
* 주간보고 제출
|
||||
* POST /api/report/weekly/[id]/submit
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
|
||||
// 보고서 조회 및 권한 확인
|
||||
const report = await queryOne<any>(`
|
||||
SELECT * FROM wr_weekly_report WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (report.author_id !== userId) {
|
||||
throw createError({ statusCode: 403, message: '본인의 보고서만 제출할 수 있습니다.' })
|
||||
}
|
||||
|
||||
if (report.report_status !== 'DRAFT') {
|
||||
throw createError({ statusCode: 400, message: '이미 제출된 보고서입니다.' })
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report SET
|
||||
report_status = 'SUBMITTED',
|
||||
submitted_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_ip = $1,
|
||||
updated_email = $2
|
||||
WHERE report_id = $3
|
||||
`, [clientIp, userEmail, reportId])
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import { query, execute, queryOne } from '../../../../utils/db'
|
||||
import { requireAuth } from '../../../../utils/session'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
/**
|
||||
* 주간보고 수정
|
||||
* PUT /api/report/weekly/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
|
||||
const user = await queryOne<any>(`SELECT employee_email FROM wr_employee_info WHERE employee_id = $1`, [userId])
|
||||
const userEmail = user?.employee_email || ''
|
||||
const isAdmin = userEmail === ADMIN_EMAIL
|
||||
|
||||
// 보고서 조회 및 권한 체크
|
||||
const report = await queryOne<any>(`
|
||||
SELECT report_id, author_id, report_status FROM wr_weekly_report WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 관리자가 아니면 본인 보고서만 수정 가능
|
||||
if (!isAdmin && report.author_id !== userId) {
|
||||
throw createError({ statusCode: 403, message: '본인의 보고서만 수정할 수 있습니다.' })
|
||||
}
|
||||
|
||||
// 취합완료된 보고서는 수정 불가 (관리자도)
|
||||
if (report.report_status === 'AGGREGATED') {
|
||||
throw createError({ statusCode: 400, message: '취합완료된 보고서는 수정할 수 없습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{
|
||||
reportYear?: number
|
||||
reportWeek?: number
|
||||
weekStartDate?: string
|
||||
weekEndDate?: string
|
||||
tasks: {
|
||||
projectId: number
|
||||
taskType: 'WORK' | 'PLAN'
|
||||
taskDescription: string
|
||||
taskHours: number
|
||||
isCompleted?: boolean
|
||||
}[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}>(event)
|
||||
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '최소 1개 이상의 Task가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 마스터 수정
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report SET
|
||||
report_year = COALESCE($1, report_year),
|
||||
report_week = COALESCE($2, report_week),
|
||||
week_start_date = COALESCE($3, week_start_date),
|
||||
week_end_date = COALESCE($4, week_end_date),
|
||||
issue_description = $5,
|
||||
vacation_description = $6,
|
||||
remark_description = $7,
|
||||
updated_at = NOW(),
|
||||
updated_ip = $8,
|
||||
updated_email = $9
|
||||
WHERE report_id = $10
|
||||
`, [
|
||||
body.reportYear || null,
|
||||
body.reportWeek || null,
|
||||
body.weekStartDate || null,
|
||||
body.weekEndDate || null,
|
||||
body.issueDescription || null,
|
||||
body.vacationDescription || null,
|
||||
body.remarkDescription || null,
|
||||
clientIp, userEmail, reportId
|
||||
])
|
||||
|
||||
// 기존 Task 삭제 후 재등록
|
||||
await execute(`DELETE FROM wr_weekly_report_task WHERE report_id = $1`, [reportId])
|
||||
|
||||
for (const task of body.tasks) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours, is_completed,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)
|
||||
`, [
|
||||
reportId, task.projectId, task.taskType, task.taskDescription, task.taskHours || 0,
|
||||
task.taskType === 'WORK' ? (task.isCompleted !== false) : null,
|
||||
clientIp, userEmail
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '주간보고가 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { defineEventHandler, getQuery, createError } from 'h3'
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
const ADMIN_EMAIL = 'admin@turbosoft.co.kr'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userEmail = event.node.req.headers['x-user-email'] as string
|
||||
|
||||
if (!userEmail) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
// 관리자만 취합 가능
|
||||
if (userEmail !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 취합할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const { year, week, projectIds } = getQuery(event)
|
||||
|
||||
if (!year || !week) {
|
||||
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
|
||||
}
|
||||
|
||||
// 프로젝트 ID 파싱
|
||||
let projectIdList: number[] = []
|
||||
if (projectIds) {
|
||||
projectIdList = String(projectIds).split(',').map(Number).filter(n => !isNaN(n))
|
||||
}
|
||||
|
||||
// 해당 주차의 모든 프로젝트별 Task 조회
|
||||
let projectFilter = ''
|
||||
const params: any[] = [Number(year), Number(week)]
|
||||
|
||||
if (projectIdList.length > 0) {
|
||||
projectFilter = `AND t.project_id = ANY($3)`
|
||||
params.push(projectIdList)
|
||||
}
|
||||
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_id,
|
||||
t.project_id,
|
||||
p.project_code,
|
||||
p.project_name,
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed,
|
||||
r.report_id,
|
||||
r.author_id,
|
||||
e.employee_name as author_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_year = $1 AND r.report_week = $2
|
||||
${projectFilter}
|
||||
ORDER BY p.project_name, t.task_type, e.employee_name
|
||||
`, params)
|
||||
|
||||
// 프로젝트별로 그룹핑
|
||||
const projectMap = new Map<number, {
|
||||
projectId: number
|
||||
projectCode: string
|
||||
projectName: string
|
||||
workTasks: any[]
|
||||
planTasks: any[]
|
||||
totalWorkHours: number
|
||||
totalPlanHours: number
|
||||
}>()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!projectMap.has(task.project_id)) {
|
||||
projectMap.set(task.project_id, {
|
||||
projectId: task.project_id,
|
||||
projectCode: task.project_code,
|
||||
projectName: task.project_name,
|
||||
workTasks: [],
|
||||
planTasks: [],
|
||||
totalWorkHours: 0,
|
||||
totalPlanHours: 0
|
||||
})
|
||||
}
|
||||
|
||||
const proj = projectMap.get(task.project_id)!
|
||||
const taskItem = {
|
||||
taskId: task.task_id,
|
||||
description: task.task_description,
|
||||
hours: parseFloat(task.task_hours) || 0,
|
||||
isCompleted: task.is_completed,
|
||||
authorName: task.author_name
|
||||
}
|
||||
|
||||
if (task.task_type === 'WORK') {
|
||||
proj.workTasks.push(taskItem)
|
||||
proj.totalWorkHours += taskItem.hours
|
||||
} else {
|
||||
proj.planTasks.push(taskItem)
|
||||
proj.totalPlanHours += taskItem.hours
|
||||
}
|
||||
}
|
||||
|
||||
// 해당 주차에 보고서가 있는 모든 프로젝트 목록
|
||||
const allProjects = await query(`
|
||||
SELECT DISTINCT p.project_id, p.project_code, p.project_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE r.report_year = $1 AND r.report_week = $2
|
||||
ORDER BY p.project_name
|
||||
`, [Number(year), Number(week)])
|
||||
|
||||
// 해당 주차 보고서 수
|
||||
const reportCount = await query(`
|
||||
SELECT COUNT(DISTINCT report_id) as cnt
|
||||
FROM wr_weekly_report
|
||||
WHERE report_year = $1 AND report_week = $2
|
||||
`, [Number(year), Number(week)])
|
||||
|
||||
return {
|
||||
year: Number(year),
|
||||
week: Number(week),
|
||||
reportCount: parseInt(reportCount[0]?.cnt || '0'),
|
||||
availableProjects: allProjects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name
|
||||
})),
|
||||
projects: Array.from(projectMap.values())
|
||||
}
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { query, execute, queryOne } from '../../../utils/db'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 주간보고 작성
|
||||
* POST /api/report/weekly/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 세션 기반 인증 사용 (레거시 쿠키 대신)
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
|
||||
const user = await queryOne<any>(`SELECT employee_email FROM wr_employee_info WHERE employee_id = $1`, [userId])
|
||||
const userEmail = user?.employee_email || ''
|
||||
|
||||
const body = await readBody<{
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
tasks: {
|
||||
projectId: number
|
||||
taskType: 'WORK' | 'PLAN'
|
||||
taskDescription: string
|
||||
taskHours: number
|
||||
isCompleted?: boolean
|
||||
}[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}>(event)
|
||||
|
||||
// 필수값 체크
|
||||
if (!body.reportYear || !body.reportWeek || !body.weekStartDate || !body.weekEndDate) {
|
||||
throw createError({ statusCode: 400, message: '주차 정보가 필요합니다.' })
|
||||
}
|
||||
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '최소 1개 이상의 Task가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [userId, body.reportYear, body.reportWeek])
|
||||
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: '해당 주차에 이미 작성된 보고서가 있습니다.' })
|
||||
}
|
||||
|
||||
// 마스터 등록
|
||||
const result = await queryOne<any>(`
|
||||
INSERT INTO wr_weekly_report (
|
||||
author_id, report_year, report_week, week_start_date, week_end_date,
|
||||
issue_description, vacation_description, remark_description,
|
||||
report_status, created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'DRAFT', $9, $10, $9, $10)
|
||||
RETURNING report_id
|
||||
`, [
|
||||
userId, body.reportYear, body.reportWeek, body.weekStartDate, body.weekEndDate,
|
||||
body.issueDescription || null, body.vacationDescription || null, body.remarkDescription || null,
|
||||
clientIp, userEmail
|
||||
])
|
||||
|
||||
const reportId = result.report_id
|
||||
|
||||
// Task 등록
|
||||
for (const task of body.tasks) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours, is_completed,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)
|
||||
`, [
|
||||
reportId, task.projectId, task.taskType, task.taskDescription, task.taskHours || 0,
|
||||
task.taskType === 'WORK' ? (task.isCompleted !== false) : null,
|
||||
clientIp, userEmail
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reportId,
|
||||
message: '주간보고가 작성되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { getWeekInfo, formatWeekString } from '../../../utils/week-calc'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
|
||||
/**
|
||||
* 이번 주 보고서 현황 조회
|
||||
* GET /api/report/weekly/current-week
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
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
|
||||
`, [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
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { requireAuth } from '../../../utils/session'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
/**
|
||||
* 주간보고 목록 조회 (필터링 지원)
|
||||
* GET /api/report/weekly/list
|
||||
*
|
||||
* Query params:
|
||||
* - authorId: 작성자 ID
|
||||
* - projectId: 프로젝트 ID
|
||||
* - year: 연도
|
||||
* - weekFrom: 시작 주차
|
||||
* - weekTo: 종료 주차
|
||||
* - startDate: 시작일 (YYYY-MM-DD)
|
||||
* - endDate: 종료일 (YYYY-MM-DD)
|
||||
* - status: 상태 (DRAFT, SUBMITTED, AGGREGATED)
|
||||
* - viewAll: 전체 조회 (관리자만)
|
||||
* - limit: 조회 개수 (기본 100)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 세션 기반 인증 사용
|
||||
const userId = await requireAuth(event)
|
||||
|
||||
// 현재 사용자 정보 조회 (관리자 여부 확인)
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
const isAdmin = currentUser[0]?.employee_email === ADMIN_EMAIL
|
||||
|
||||
const q = getQuery(event)
|
||||
const limit = parseInt(q.limit as string) || 100
|
||||
|
||||
// 필터 조건 구성
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
// 작성자 필터 (선택된 경우에만 적용)
|
||||
if (q.authorId) {
|
||||
conditions.push(`r.author_id = $${paramIndex++}`)
|
||||
params.push(q.authorId)
|
||||
}
|
||||
|
||||
// 프로젝트 필터
|
||||
if (q.projectId) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM wr_weekly_report_project wrp
|
||||
WHERE wrp.report_id = r.report_id AND wrp.project_id = $${paramIndex++}
|
||||
)`)
|
||||
params.push(q.projectId)
|
||||
}
|
||||
|
||||
// 연도 필터
|
||||
if (q.year) {
|
||||
conditions.push(`r.report_year = $${paramIndex++}`)
|
||||
params.push(q.year)
|
||||
}
|
||||
|
||||
// 주차 필터 (단일)
|
||||
if (q.week) {
|
||||
conditions.push(`r.report_week = $${paramIndex++}`)
|
||||
params.push(q.week)
|
||||
}
|
||||
|
||||
// 특정 주차 이전 필터 (beforeYear, beforeWeek)
|
||||
if (q.beforeYear && q.beforeWeek) {
|
||||
conditions.push(`(r.report_year < $${paramIndex} OR (r.report_year = $${paramIndex} AND r.report_week < $${paramIndex + 1}))`)
|
||||
params.push(q.beforeYear)
|
||||
paramIndex++
|
||||
params.push(q.beforeWeek)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
// 주차 범위 필터
|
||||
if (q.weekFrom) {
|
||||
conditions.push(`r.report_week >= $${paramIndex++}`)
|
||||
params.push(q.weekFrom)
|
||||
}
|
||||
if (q.weekTo) {
|
||||
conditions.push(`r.report_week <= $${paramIndex++}`)
|
||||
params.push(q.weekTo)
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (q.startDate) {
|
||||
conditions.push(`r.week_start_date >= $${paramIndex++}`)
|
||||
params.push(q.startDate)
|
||||
}
|
||||
if (q.endDate) {
|
||||
conditions.push(`r.week_end_date <= $${paramIndex++}`)
|
||||
params.push(q.endDate)
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (q.status) {
|
||||
conditions.push(`r.report_status = $${paramIndex++}`)
|
||||
params.push(q.status)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
params.push(limit)
|
||||
|
||||
const reports = await query<any>(`
|
||||
SELECT
|
||||
r.report_id,
|
||||
r.author_id,
|
||||
e.employee_name as author_name,
|
||||
e.employee_email as author_email,
|
||||
r.report_year,
|
||||
r.report_week,
|
||||
r.week_start_date,
|
||||
r.week_end_date,
|
||||
r.issue_description,
|
||||
r.vacation_description,
|
||||
r.report_status,
|
||||
r.submitted_at,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
r.ai_review,
|
||||
(SELECT COUNT(DISTINCT project_id) FROM wr_weekly_report_task WHERE report_id = r.report_id) as project_count,
|
||||
(SELECT string_agg(DISTINCT p.project_name, ', ')
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE t.report_id = r.report_id) as project_names,
|
||||
(SELECT COALESCE(SUM(task_hours), 0) FROM wr_weekly_report_task WHERE report_id = r.report_id AND task_type = 'WORK') as total_work_hours,
|
||||
(SELECT COALESCE(SUM(task_hours), 0) FROM wr_weekly_report_task WHERE report_id = r.report_id AND task_type = 'PLAN') as total_plan_hours
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY r.report_year DESC, r.report_week DESC, e.employee_name
|
||||
LIMIT $${paramIndex}
|
||||
`, params)
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
reports: reports.map((r: any) => ({
|
||||
reportId: r.report_id,
|
||||
authorId: r.author_id,
|
||||
authorName: r.author_name,
|
||||
authorEmail: r.author_email,
|
||||
reportYear: r.report_year,
|
||||
reportWeek: r.report_week,
|
||||
weekStartDate: formatDateOnly(r.week_start_date),
|
||||
weekEndDate: formatDateOnly(r.week_end_date),
|
||||
issueDescription: r.issue_description,
|
||||
vacationDescription: r.vacation_description,
|
||||
reportStatus: r.report_status,
|
||||
submittedAt: r.submitted_at,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
aiReview: r.ai_review,
|
||||
projectCount: parseInt(r.project_count),
|
||||
projectNames: r.project_names,
|
||||
totalWorkHours: parseFloat(r.total_work_hours) || 0,
|
||||
totalPlanHours: parseFloat(r.total_plan_hours) || 0
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 날짜를 YYYY-MM-DD 형식으로 변환 (타임존 보정)
|
||||
function formatDateOnly(date: Date | string | null): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// 한국 시간 기준으로 날짜만 추출
|
||||
const kstOffset = 9 * 60 * 60 * 1000
|
||||
const kstDate = new Date(d.getTime() + kstOffset)
|
||||
return kstDate.toISOString().split('T')[0]
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
|
||||
/**
|
||||
* 저장소 목록 조회
|
||||
* GET /api/repository/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const serverId = params.serverId ? Number(params.serverId) : null
|
||||
const includeInactive = params.includeInactive === 'true'
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`r.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
conditions.push(`r.server_id = $${paramIndex++}`)
|
||||
values.push(serverId)
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
conditions.push('r.is_active = true')
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const repos = await query(`
|
||||
SELECT
|
||||
r.*,
|
||||
s.server_name,
|
||||
s.server_type,
|
||||
p.project_name,
|
||||
e.employee_name as created_by_name
|
||||
FROM wr_repository r
|
||||
JOIN wr_vcs_server s ON r.server_id = s.server_id
|
||||
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
|
||||
LEFT JOIN wr_employee_info e ON r.created_by = e.employee_id
|
||||
${whereClause}
|
||||
ORDER BY r.repo_name
|
||||
`, values)
|
||||
|
||||
return {
|
||||
repositories: repos.map((r: any) => ({
|
||||
repoId: r.repo_id,
|
||||
serverId: r.server_id,
|
||||
serverName: r.server_name,
|
||||
serverType: r.server_type,
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
repoName: r.repo_name,
|
||||
repoPath: r.repo_path,
|
||||
repoUrl: r.repo_url,
|
||||
defaultBranch: r.default_branch,
|
||||
description: r.description,
|
||||
isActive: r.is_active,
|
||||
lastSyncAt: r.last_sync_at,
|
||||
createdAt: r.created_at,
|
||||
createdByName: r.created_by_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* TODO 삭제
|
||||
* DELETE /api/todo/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const todoId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const existing = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [todoId])
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute('DELETE FROM wr_todo WHERE todo_id = $1', [todoId])
|
||||
|
||||
return { success: true, message: 'TODO가 삭제되었습니다.' }
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* TODO 상세 조회
|
||||
* GET /api/todo/[id]/detail
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const todoId = Number(getRouterParam(event, 'id'))
|
||||
|
||||
const todo = await queryOne(`
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
m.meeting_title,
|
||||
a.employee_name as assignee_name,
|
||||
r.employee_name as reporter_name
|
||||
FROM wr_todo t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_meeting m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN wr_employee_info a ON t.assignee_id = a.employee_id
|
||||
LEFT JOIN wr_employee_info r ON t.reporter_id = r.employee_id
|
||||
WHERE t.todo_id = $1
|
||||
`, [todoId])
|
||||
|
||||
if (!todo) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
return {
|
||||
todo: {
|
||||
todoId: todo.todo_id,
|
||||
todoTitle: todo.todo_title,
|
||||
todoContent: todo.todo_content,
|
||||
sourceType: todo.source_type,
|
||||
sourceId: todo.source_id,
|
||||
meetingId: todo.meeting_id,
|
||||
meetingTitle: todo.meeting_title,
|
||||
projectId: todo.project_id,
|
||||
projectName: todo.project_name,
|
||||
assigneeId: todo.assignee_id,
|
||||
assigneeName: todo.assignee_name,
|
||||
reporterId: todo.reporter_id,
|
||||
reporterName: todo.reporter_name,
|
||||
dueDate: todo.due_date,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
completedAt: todo.completed_at,
|
||||
weeklyReportId: todo.weekly_report_id,
|
||||
createdAt: todo.created_at,
|
||||
updatedAt: todo.updated_at
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
interface UpdateTodoBody {
|
||||
todoTitle?: string
|
||||
todoContent?: string
|
||||
projectId?: number | null
|
||||
assigneeId?: number | null
|
||||
dueDate?: string | null
|
||||
status?: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO 수정
|
||||
* PUT /api/todo/[id]/update
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const todoId = Number(getRouterParam(event, 'id'))
|
||||
const body = await readBody<UpdateTodoBody>(event)
|
||||
|
||||
const existing = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [todoId])
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (body.todoTitle !== undefined) {
|
||||
updates.push(`todo_title = $${paramIndex++}`)
|
||||
values.push(body.todoTitle)
|
||||
}
|
||||
|
||||
if (body.todoContent !== undefined) {
|
||||
updates.push(`todo_content = $${paramIndex++}`)
|
||||
values.push(body.todoContent)
|
||||
}
|
||||
|
||||
if (body.projectId !== undefined) {
|
||||
updates.push(`project_id = $${paramIndex++}`)
|
||||
values.push(body.projectId)
|
||||
}
|
||||
|
||||
if (body.assigneeId !== undefined) {
|
||||
updates.push(`assignee_id = $${paramIndex++}`)
|
||||
values.push(body.assigneeId)
|
||||
}
|
||||
|
||||
if (body.dueDate !== undefined) {
|
||||
updates.push(`due_date = $${paramIndex++}`)
|
||||
values.push(body.dueDate)
|
||||
}
|
||||
|
||||
if (body.status !== undefined) {
|
||||
updates.push(`status = $${paramIndex++}`)
|
||||
values.push(body.status)
|
||||
|
||||
// 완료 상태로 변경 시 완료일시 기록
|
||||
if (body.status === 'COMPLETED') {
|
||||
updates.push(`completed_at = NOW()`)
|
||||
} else if (existing.status === 'COMPLETED') {
|
||||
updates.push(`completed_at = NULL`)
|
||||
}
|
||||
}
|
||||
|
||||
if (body.priority !== undefined) {
|
||||
updates.push(`priority = $${paramIndex++}`)
|
||||
values.push(body.priority)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { success: true, message: '변경된 내용이 없습니다.' }
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()')
|
||||
values.push(todoId)
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_todo SET ${updates.join(', ')} WHERE todo_id = $${paramIndex}
|
||||
`, values)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { insertReturning } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
interface CreateTodoBody {
|
||||
todoTitle: string
|
||||
todoContent?: string
|
||||
sourceType?: string
|
||||
sourceId?: number
|
||||
meetingId?: number
|
||||
projectId?: number
|
||||
assigneeId?: number
|
||||
dueDate?: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO 생성
|
||||
* POST /api/todo/create
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CreateTodoBody>(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
if (!body.todoTitle?.trim()) {
|
||||
throw createError({ statusCode: 400, message: '제목을 입력해주세요.' })
|
||||
}
|
||||
|
||||
const result = await insertReturning(`
|
||||
INSERT INTO wr_todo (
|
||||
todo_title, todo_content, source_type, source_id, meeting_id,
|
||||
project_id, assignee_id, reporter_id, due_date, status, priority, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $8)
|
||||
RETURNING *
|
||||
`, [
|
||||
body.todoTitle.trim(),
|
||||
body.todoContent || null,
|
||||
body.sourceType || null,
|
||||
body.sourceId || null,
|
||||
body.meetingId || null,
|
||||
body.projectId || null,
|
||||
body.assigneeId || null,
|
||||
userId,
|
||||
body.dueDate || null,
|
||||
body.priority || 1
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
todo: {
|
||||
todoId: result.todo_id,
|
||||
todoTitle: result.todo_title,
|
||||
status: result.status
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { getCurrentUserId } from '../../utils/user'
|
||||
|
||||
/**
|
||||
* TODO 목록 조회
|
||||
* GET /api/todo/list
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = getQuery(event)
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
const status = params.status as string | null
|
||||
const assigneeId = params.assigneeId ? Number(params.assigneeId) : null
|
||||
const projectId = params.projectId ? Number(params.projectId) : null
|
||||
const meetingId = params.meetingId ? Number(params.meetingId) : null
|
||||
const myOnly = params.myOnly === 'true'
|
||||
|
||||
const conditions: string[] = []
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (status) {
|
||||
conditions.push(`t.status = $${paramIndex++}`)
|
||||
values.push(status)
|
||||
}
|
||||
|
||||
if (assigneeId) {
|
||||
conditions.push(`t.assignee_id = $${paramIndex++}`)
|
||||
values.push(assigneeId)
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`t.project_id = $${paramIndex++}`)
|
||||
values.push(projectId)
|
||||
}
|
||||
|
||||
if (meetingId) {
|
||||
conditions.push(`t.meeting_id = $${paramIndex++}`)
|
||||
values.push(meetingId)
|
||||
}
|
||||
|
||||
if (myOnly && userId) {
|
||||
conditions.push(`(t.assignee_id = $${paramIndex} OR t.reporter_id = $${paramIndex})`)
|
||||
values.push(userId)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
t.*,
|
||||
p.project_name,
|
||||
m.meeting_title,
|
||||
a.employee_name as assignee_name,
|
||||
r.employee_name as reporter_name
|
||||
FROM wr_todo t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
LEFT JOIN wr_meeting m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN wr_employee_info a ON t.assignee_id = a.employee_id
|
||||
LEFT JOIN wr_employee_info r ON t.reporter_id = r.employee_id
|
||||
${whereClause}
|
||||
ORDER BY
|
||||
CASE t.status WHEN 'PENDING' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'COMPLETED' THEN 3 ELSE 4 END,
|
||||
t.priority DESC,
|
||||
t.due_date ASC NULLS LAST,
|
||||
t.created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
const todos = await query(sql, values)
|
||||
|
||||
return {
|
||||
todos: todos.map((t: any) => ({
|
||||
todoId: t.todo_id,
|
||||
todoTitle: t.todo_title,
|
||||
todoContent: t.todo_content,
|
||||
sourceType: t.source_type,
|
||||
sourceId: t.source_id,
|
||||
meetingId: t.meeting_id,
|
||||
meetingTitle: t.meeting_title,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
assigneeId: t.assignee_id,
|
||||
assigneeName: t.assignee_name,
|
||||
reporterId: t.reporter_id,
|
||||
reporterName: t.reporter_name,
|
||||
dueDate: t.due_date,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
completedAt: t.completed_at,
|
||||
weeklyReportId: t.weekly_report_id,
|
||||
createdAt: t.created_at,
|
||||
updatedAt: t.updated_at
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
|
||||
interface LinkBody {
|
||||
todoId: number
|
||||
weeklyReportId: number
|
||||
markCompleted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO와 주간보고 연계
|
||||
* POST /api/todo/report/link
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LinkBody>(event)
|
||||
|
||||
if (!body.todoId || !body.weeklyReportId) {
|
||||
throw createError({ statusCode: 400, message: 'TODO ID와 주간보고 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
const todo = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [body.todoId])
|
||||
if (!todo) {
|
||||
throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const updates = ['weekly_report_id = $1', 'updated_at = NOW()']
|
||||
const values = [body.weeklyReportId]
|
||||
|
||||
if (body.markCompleted) {
|
||||
updates.push('status = $2', 'completed_at = NOW()')
|
||||
values.push('COMPLETED')
|
||||
}
|
||||
|
||||
values.push(body.todoId)
|
||||
|
||||
await execute(`
|
||||
UPDATE wr_todo SET ${updates.join(', ')} WHERE todo_id = $${values.length}
|
||||
`, values)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: body.markCompleted ? 'TODO가 완료 처리되고 주간보고와 연계되었습니다.' : 'TODO가 주간보고와 연계되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
import { query } from '../../../utils/db'
|
||||
import { callOpenAI } from '../../../utils/openai'
|
||||
|
||||
interface SearchBody {
|
||||
taskDescription: string
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 실적과 유사한 TODO 검색
|
||||
* POST /api/todo/report/similar
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<SearchBody>(event)
|
||||
|
||||
if (!body.taskDescription?.trim()) {
|
||||
return { todos: [] }
|
||||
}
|
||||
|
||||
// 해당 프로젝트의 미완료 TODO 조회
|
||||
const conditions = ["t.status IN ('PENDING', 'IN_PROGRESS')"]
|
||||
const values: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (body.projectId) {
|
||||
conditions.push(`t.project_id = $${paramIndex++}`)
|
||||
values.push(body.projectId)
|
||||
}
|
||||
|
||||
const todos = await query(`
|
||||
SELECT
|
||||
t.todo_id,
|
||||
t.todo_title,
|
||||
t.todo_content,
|
||||
t.project_id,
|
||||
p.project_name,
|
||||
t.status,
|
||||
t.due_date
|
||||
FROM wr_todo t
|
||||
LEFT JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 20
|
||||
`, values)
|
||||
|
||||
if (todos.length === 0) {
|
||||
return { todos: [] }
|
||||
}
|
||||
|
||||
// OpenAI로 유사도 분석
|
||||
const todoList = todos.map((t: any, i: number) =>
|
||||
`${i + 1}. [ID:${t.todo_id}] ${t.todo_title}${t.todo_content ? ' - ' + t.todo_content.substring(0, 50) : ''}`
|
||||
).join('\n')
|
||||
|
||||
const prompt = `다음 주간보고 실적과 가장 유사한 TODO를 찾아주세요.
|
||||
|
||||
실적 내용: "${body.taskDescription}"
|
||||
|
||||
TODO 목록:
|
||||
${todoList}
|
||||
|
||||
유사한 TODO의 ID를 배열로 반환해주세요 (유사도 70% 이상만, 최대 3개).
|
||||
JSON 형식: { "similarIds": [1, 2] }`
|
||||
|
||||
try {
|
||||
const response = await callOpenAI([
|
||||
{ role: 'system', content: '주간보고 실적과 TODO의 유사도를 분석하는 전문가입니다.' },
|
||||
{ role: 'user', content: prompt }
|
||||
], true)
|
||||
|
||||
const parsed = JSON.parse(response)
|
||||
const similarIds = parsed.similarIds || []
|
||||
|
||||
const similarTodos = todos.filter((t: any) => similarIds.includes(t.todo_id))
|
||||
|
||||
return {
|
||||
todos: similarTodos.map((t: any) => ({
|
||||
todoId: t.todo_id,
|
||||
todoTitle: t.todo_title,
|
||||
todoContent: t.todo_content,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
status: t.status,
|
||||
dueDate: t.due_date
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('OpenAI error:', e)
|
||||
// 실패 시 키워드 기반 간단 매칭
|
||||
const keywords = body.taskDescription.split(/\s+/).filter(k => k.length >= 2)
|
||||
const matched = todos.filter((t: any) => {
|
||||
const title = t.todo_title?.toLowerCase() || ''
|
||||
return keywords.some(k => title.includes(k.toLowerCase()))
|
||||
}).slice(0, 3)
|
||||
|
||||
return {
|
||||
todos: matched.map((t: any) => ({
|
||||
todoId: t.todo_id,
|
||||
todoTitle: t.todo_title,
|
||||
projectId: t.project_id,
|
||||
projectName: t.project_name,
|
||||
status: t.status,
|
||||
dueDate: t.due_date
|
||||
})),
|
||||
fallback: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { execute, queryOne } from '../../../utils/db'
|
||||
import { getCurrentUserId } from '../../../utils/user'
|
||||
|
||||
/**
|
||||
* VCS 계정 삭제
|
||||
* DELETE /api/vcs-account/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const accountId = Number(getRouterParam(event, 'id'))
|
||||
const userId = await getCurrentUserId(event)
|
||||
|
||||
const existing = await queryOne(
|
||||
'SELECT * FROM wr_employee_vcs_account WHERE account_id = $1 AND employee_id = $2',
|
||||
[accountId, userId]
|
||||
)
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, message: 'VCS 계정을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
await execute('DELETE FROM wr_employee_vcs_account WHERE account_id = $1', [accountId])
|
||||
|
||||
return { success: true, message: 'VCS 계정이 삭제되었습니다.' }
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user