기능구현중

This commit is contained in:
2026-01-11 17:01:01 +09:00
parent 375d5bf91a
commit 954ba21211
148 changed files with 2276 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
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
}
})

View File

@@ -0,0 +1,44 @@
import { queryOne, execute } from '../../../../utils/db'
import { requireAdmin } from '../../../../utils/session'
/**
* 메뉴 권한 토글
* POST /api/admin/menu/[id]/toggle-role
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const menuId = getRouterParam(event, 'id')
const body = await readBody(event)
const { roleId, enabled } = body
if (!roleId) {
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
}
// 메뉴 존재 확인
const menu = await queryOne<any>(`
SELECT menu_id FROM wr_menu WHERE menu_id = $1
`, [menuId])
if (!menu) {
throw createError({ statusCode: 404, message: '메뉴를 찾을 수 없습니다.' })
}
if (enabled) {
// 권한 추가
await execute(`
INSERT INTO wr_menu_role (menu_id, role_id)
VALUES ($1, $2)
ON CONFLICT (menu_id, role_id) DO NOTHING
`, [menuId, roleId])
} else {
// 권한 제거
await execute(`
DELETE FROM wr_menu_role
WHERE menu_id = $1 AND role_id = $2
`, [menuId, roleId])
}
return { success: true }
})

View File

@@ -0,0 +1,73 @@
import { query } from '../../../utils/db'
import { requireAdmin } from '../../../utils/session'
/**
* 메뉴 목록 조회 (권한 포함)
* GET /api/admin/menu/list
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
// 메뉴 목록 조회
const menus = await query<any>(`
SELECT
m.menu_id,
m.menu_code,
m.menu_name,
m.menu_path,
m.menu_icon,
m.parent_menu_id,
m.sort_order,
m.is_active,
m.created_at,
m.updated_at,
pm.menu_name AS parent_menu_name
FROM wr_menu m
LEFT JOIN wr_menu pm ON m.parent_menu_id = pm.menu_id
ORDER BY m.parent_menu_id NULLS FIRST, m.sort_order
`)
// 권한 목록 조회
const roles = await query<any>(`
SELECT role_id, role_code, role_name
FROM wr_role
ORDER BY role_id
`)
// 메뉴-권한 매핑 조회
const menuRoles = await query<any>(`
SELECT menu_id, role_id
FROM wr_menu_role
`)
// 메뉴별 권한 매핑 정리
const menuRoleMap: Record<number, number[]> = {}
for (const mr of menuRoles) {
if (!menuRoleMap[mr.menu_id]) {
menuRoleMap[mr.menu_id] = []
}
menuRoleMap[mr.menu_id].push(mr.role_id)
}
return {
menus: menus.map(m => ({
menuId: m.menu_id,
menuCode: m.menu_code,
menuName: m.menu_name,
menuPath: m.menu_path,
menuIcon: m.menu_icon,
parentMenuId: m.parent_menu_id,
parentMenuName: m.parent_menu_name,
sortOrder: m.sort_order,
isActive: m.is_active,
createdAt: m.created_at,
updatedAt: m.updated_at,
roleIds: menuRoleMap[m.menu_id] || []
})),
roles: roles.map((r: any) => ({
roleId: r.role_id,
roleCode: r.role_code,
roleName: r.role_name
}))
}
})

View File

@@ -0,0 +1,157 @@
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
}))
}
})

View File

@@ -0,0 +1,157 @@
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
}))
}
})

View File

@@ -0,0 +1,46 @@
import { queryOne, execute } from '../../../../utils/db'
import { requireAdmin } from '../../../../utils/session'
/**
* 권한 삭제
* DELETE /api/admin/role/[id]/delete
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const roleId = getRouterParam(event, 'id')
if (!roleId) {
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
}
// 존재 여부 확인
const existing = await queryOne<any>(`
SELECT role_id, role_code FROM wr_role WHERE role_id = $1
`, [roleId])
if (!existing) {
throw createError({ statusCode: 404, message: '권한을 찾을 수 없습니다.' })
}
// 기본 권한은 삭제 불가
const protectedCodes = ['ROLE_ADMIN', 'ROLE_MANAGER', 'ROLE_USER']
if (protectedCodes.includes(existing.role_code)) {
throw createError({ statusCode: 400, message: '기본 권한은 삭제할 수 없습니다.' })
}
// 사용 중인 권한인지 확인
const usageCount = await queryOne<any>(`
SELECT COUNT(*) as cnt FROM wr_employee_role WHERE role_id = $1
`, [roleId])
if (parseInt(usageCount.cnt) > 0) {
throw createError({
statusCode: 400,
message: `${usageCount.cnt}명의 사용자가 이 권한을 사용 중입니다. 먼저 권한을 해제해주세요.`
})
}
await execute(`DELETE FROM wr_role WHERE role_id = $1`, [roleId])
return { success: true }
})

View File

@@ -0,0 +1,54 @@
import { queryOne, execute } from '../../../../utils/db'
import { requireAdmin } from '../../../../utils/session'
/**
* 권한 수정
* PUT /api/admin/role/[id]/update
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const roleId = getRouterParam(event, 'id')
if (!roleId) {
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
}
const body = await readBody<{
roleName?: string
roleDescription?: string
isInternalIpOnly?: boolean
sortOrder?: number
isActive?: boolean
}>(event)
// 존재 여부 확인
const existing = await queryOne<any>(`
SELECT role_id, role_code FROM wr_role WHERE role_id = $1
`, [roleId])
if (!existing) {
throw createError({ statusCode: 404, message: '권한을 찾을 수 없습니다.' })
}
await execute(`
UPDATE wr_role SET
role_name = COALESCE($2, role_name),
role_description = COALESCE($3, role_description),
is_internal_ip_only = COALESCE($4, is_internal_ip_only),
sort_order = COALESCE($5, sort_order),
is_active = COALESCE($6, is_active),
updated_at = NOW()
WHERE role_id = $1
`, [
roleId,
body.roleName,
body.roleDescription,
body.isInternalIpOnly,
body.sortOrder,
body.isActive
])
const updated = await queryOne<any>(`SELECT * FROM wr_role WHERE role_id = $1`, [roleId])
return { success: true, role: updated }
})

View File

@@ -0,0 +1,45 @@
import { queryOne } from '../../../utils/db'
import { requireAdmin } from '../../../utils/session'
/**
* 권한 생성
* POST /api/admin/role/create
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await readBody<{
roleCode: string
roleName: string
roleDescription?: string
isInternalIpOnly?: boolean
sortOrder?: number
}>(event)
if (!body.roleCode || !body.roleName) {
throw createError({ statusCode: 400, message: '권한코드와 권한명은 필수입니다.' })
}
// 코드 중복 체크
const existing = await queryOne<any>(`
SELECT role_id FROM wr_role WHERE role_code = $1
`, [body.roleCode])
if (existing) {
throw createError({ statusCode: 400, message: '이미 존재하는 권한코드입니다.' })
}
const role = await queryOne<any>(`
INSERT INTO wr_role (role_code, role_name, role_description, is_internal_ip_only, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [
body.roleCode,
body.roleName,
body.roleDescription || null,
body.isInternalIpOnly || false,
body.sortOrder || 0
])
return { success: true, role }
})

View File

@@ -0,0 +1,34 @@
import { query } from '../../../utils/db'
import { requireAdmin } from '../../../utils/session'
/**
* 권한 목록 조회
* GET /api/admin/role/list
*/
export default defineEventHandler(async (event) => {
// 관리자 권한 체크
await requireAdmin(event)
const roles = await query<any>(`
SELECT
r.role_id,
r.role_code,
r.role_name,
r.role_description,
r.is_internal_ip_only,
r.sort_order,
r.is_active,
r.created_at,
r.updated_at,
COUNT(DISTINCT er.employee_id) as user_count
FROM wr_role r
LEFT JOIN wr_employee_role er ON r.role_id = er.role_id
GROUP BY r.role_id
ORDER BY r.sort_order, r.role_id
`)
return {
roles,
total: roles.length
}
})

View File

@@ -0,0 +1,55 @@
import { query, queryOne, execute } from '../../../../utils/db'
import { requireAdmin } from '../../../../utils/session'
/**
* 사용자 권한 변경
* PUT /api/admin/user/[id]/roles
*
* Body: { roleIds: number[] }
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const employeeId = getRouterParam(event, 'id')
if (!employeeId) {
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
}
const body = await readBody<{ roleIds: number[] }>(event)
const roleIds = body.roleIds || []
// 사용자 존재 확인
const user = await queryOne<any>(`
SELECT employee_id, employee_email FROM wr_employee_info WHERE employee_id = $1
`, [employeeId])
if (!user) {
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
}
// 기존 권한 모두 삭제
await execute(`DELETE FROM wr_employee_role WHERE employee_id = $1`, [employeeId])
// 새 권한 추가
for (const roleId of roleIds) {
await execute(`
INSERT INTO wr_employee_role (employee_id, role_id)
VALUES ($1, $2)
ON CONFLICT (employee_id, role_id) DO NOTHING
`, [employeeId, roleId])
}
// 변경된 권한 조회
const updatedRoles = await query<any>(`
SELECT r.role_id, r.role_code, r.role_name
FROM wr_employee_role er
JOIN wr_role r ON er.role_id = r.role_id
WHERE er.employee_id = $1
`, [employeeId])
return {
success: true,
employeeId: parseInt(employeeId as string),
roles: updatedRoles
}
})

View File

@@ -0,0 +1,70 @@
import { queryOne, execute } from '../../../../utils/db'
import { requireAdmin } from '../../../../utils/session'
/**
* 사용자 개별 권한 토글 (추가/제거)
* POST /api/admin/user/[id]/toggle-role
*
* Body: { roleId: number }
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const employeeId = getRouterParam(event, 'id')
if (!employeeId) {
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
}
const body = await readBody<{ roleId: number }>(event)
if (!body.roleId) {
throw createError({ statusCode: 400, message: '권한 ID가 필요합니다.' })
}
// 사용자 존재 확인
const user = await queryOne<any>(`
SELECT employee_id FROM wr_employee_info WHERE employee_id = $1
`, [employeeId])
if (!user) {
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
}
// 권한 존재 확인
const role = await queryOne<any>(`
SELECT role_id, role_code FROM wr_role WHERE role_id = $1
`, [body.roleId])
if (!role) {
throw createError({ statusCode: 404, message: '권한을 찾을 수 없습니다.' })
}
// 현재 권한 보유 여부 확인
const existing = await queryOne<any>(`
SELECT employee_role_id FROM wr_employee_role
WHERE employee_id = $1 AND role_id = $2
`, [employeeId, body.roleId])
let added: boolean
if (existing) {
// 권한 제거
await execute(`
DELETE FROM wr_employee_role WHERE employee_id = $1 AND role_id = $2
`, [employeeId, body.roleId])
added = false
} else {
// 권한 추가
await execute(`
INSERT INTO wr_employee_role (employee_id, role_id) VALUES ($1, $2)
`, [employeeId, body.roleId])
added = true
}
return {
success: true,
employeeId: parseInt(employeeId as string),
roleId: body.roleId,
roleCode: role.role_code,
added
}
})

View File

@@ -0,0 +1,110 @@
import { query } from '../../../utils/db'
import { requireAdmin } from '../../../utils/session'
/**
* 사용자 목록 조회 (권한 정보 + 최근 로그인 포함)
* GET /api/admin/user/list
*/
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const queryParams = getQuery(event)
const company = queryParams.company as string || ''
const name = queryParams.name as string || ''
const email = queryParams.email as string || ''
const phone = queryParams.phone as string || ''
const status = queryParams.status as string || 'active' // 기본값: 활성
// 1. 사용자 목록 조회 (최근 로그인 포함)
let userQuery = `
SELECT
e.employee_id,
e.employee_name,
e.employee_email,
e.employee_phone,
e.employee_position,
e.company,
e.join_date,
e.is_active,
e.created_at,
(
SELECT MAX(login_at)
FROM wr_login_history
WHERE employee_id = e.employee_id
) as last_login_at
FROM wr_employee_info e
WHERE 1=1
`
const params: any[] = []
// 소속사 검색
if (company) {
params.push(`%${company}%`)
userQuery += ` AND e.company ILIKE $${params.length}`
}
// 이름 검색
if (name) {
params.push(`%${name}%`)
userQuery += ` AND e.employee_name ILIKE $${params.length}`
}
// 이메일 검색
if (email) {
params.push(`%${email}%`)
userQuery += ` AND e.employee_email ILIKE $${params.length}`
}
// 전화번호 검색
if (phone) {
params.push(`%${phone}%`)
userQuery += ` AND e.employee_phone ILIKE $${params.length}`
}
// 상태 검색
if (status === 'active') {
userQuery += ` AND e.is_active = true`
} else if (status === 'inactive') {
userQuery += ` AND e.is_active = false`
}
// status === 'all' 이면 조건 없음
userQuery += ` ORDER BY e.company, e.employee_position, e.employee_name`
const users = await query<any>(userQuery, params)
// 2. 모든 권한 목록 조회
const roles = await query<any>(`
SELECT role_id, role_code, role_name, sort_order
FROM wr_role
WHERE is_active = true
ORDER BY sort_order
`)
// 3. 사용자별 권한 매핑 조회
const userRoles = await query<any>(`
SELECT employee_id, role_id
FROM wr_employee_role
`)
// 4. 사용자별 권한 배열 생성
const userRoleMap = new Map<number, number[]>()
for (const ur of userRoles) {
if (!userRoleMap.has(ur.employee_id)) {
userRoleMap.set(ur.employee_id, [])
}
userRoleMap.get(ur.employee_id)!.push(ur.role_id)
}
// 5. 사용자 데이터에 권한 정보 추가
const usersWithRoles = users.map(u => ({
...u,
roleIds: userRoleMap.get(u.employee_id) || []
}))
return {
users: usersWithRoles,
roles,
total: users.length
}
})

View File

@@ -0,0 +1,58 @@
import { query, execute } from '../../../utils/db'
import { hashPassword, generateTempPassword } from '../../../utils/password'
import { getClientIp } from '../../../utils/ip'
import { requireAuth } from '../../../utils/session'
interface AdminResetPasswordBody {
employeeId: number
}
/**
* 관리자 비밀번호 초기화
* POST /api/admin/user/reset-password
*/
export default defineEventHandler(async (event) => {
const currentUserId = await requireAuth(event)
// TODO: 관리자 권한 체크 (현재는 모든 로그인 사용자 허용)
const body = await readBody<AdminResetPasswordBody>(event)
const clientIp = getClientIp(event)
if (!body.employeeId) {
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
}
// 대상 사용자 조회
const employees = await query<any>(`
SELECT employee_id, employee_name, employee_email
FROM wr_employee_info
WHERE employee_id = $1
`, [body.employeeId])
if (employees.length === 0) {
throw createError({ statusCode: 404, 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, body.employeeId])
return {
success: true,
message: '비밀번호가 초기화되었습니다.',
tempPassword,
employeeName: employee.employee_name
}
})

View File

@@ -0,0 +1,59 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 전체 VCS 동기화 상태 조회 (관리자용)
* GET /api/admin/vcs/status
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
// 저장소별 동기화 상태
const repos = await query(`
SELECT
r.repo_id, r.repo_name, r.repo_path,
r.last_sync_at, r.last_sync_status, r.last_sync_message,
s.server_type, s.server_name,
p.project_name,
(SELECT COUNT(*) FROM wr_commit_log c WHERE c.repo_id = r.repo_id) as commit_count
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
WHERE r.is_active = true
ORDER BY r.last_sync_at DESC NULLS LAST
`)
// 전체 통계
const stats = await query(`
SELECT
COUNT(DISTINCT r.repo_id) as total_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'SUCCESS' THEN r.repo_id END) as success_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'FAILED' THEN r.repo_id END) as failed_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_at IS NULL THEN r.repo_id END) as never_synced,
(SELECT COUNT(*) FROM wr_commit_log) as total_commits
FROM wr_repository r
WHERE r.is_active = true
`)
return {
repositories: repos.map(r => ({
repoId: r.repo_id,
repoName: r.repo_name,
repoPath: r.repo_path,
serverType: r.server_type,
serverName: r.server_name,
projectName: r.project_name,
lastSyncAt: r.last_sync_at,
lastSyncStatus: r.last_sync_status,
lastSyncMessage: r.last_sync_message,
commitCount: parseInt(r.commit_count || '0')
})),
stats: {
totalRepos: parseInt(stats[0]?.total_repos || '0'),
successRepos: parseInt(stats[0]?.success_repos || '0'),
failedRepos: parseInt(stats[0]?.failed_repos || '0'),
neverSynced: parseInt(stats[0]?.never_synced || '0'),
totalCommits: parseInt(stats[0]?.total_commits || '0')
}
}
})

View File

@@ -0,0 +1,63 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { syncGitRepository } from '../../../utils/git-sync'
import { syncSvnRepository } from '../../../utils/svn-sync'
/**
* 전체 VCS 저장소 동기화 (관리자용)
* POST /api/admin/vcs/sync-all
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
// 모든 활성 저장소 조회
const repos = await query(`
SELECT r.repo_id, r.repo_name, s.server_type
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.is_active = true AND s.is_active = true
ORDER BY r.repo_name
`)
const results: any[] = []
for (const repo of repos) {
try {
let result
if (repo.server_type === 'GIT') {
result = await syncGitRepository(repo.repo_id)
} else if (repo.server_type === 'SVN') {
result = await syncSvnRepository(repo.repo_id)
} else {
result = { success: false, message: '지원하지 않는 서버 타입' }
}
results.push({
repoId: repo.repo_id,
repoName: repo.repo_name,
serverType: repo.server_type,
...result
})
} catch (e: any) {
results.push({
repoId: repo.repo_id,
repoName: repo.repo_name,
serverType: repo.server_type,
success: false,
message: e.message
})
}
}
const successCount = results.filter(r => r.success).length
const failCount = results.filter(r => !r.success).length
return {
success: failCount === 0,
message: `동기화 완료: 성공 ${successCount}개, 실패 ${failCount}`,
totalRepos: repos.length,
successCount,
failCount,
results
}
})

View File

@@ -0,0 +1,131 @@
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 외의 텍스트는 절대 출력하지 마세요`
}

View File

@@ -0,0 +1,150 @@
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
}
]
}

View File

@@ -0,0 +1,65 @@
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: '비밀번호가 변경되었습니다.' }
})

View File

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

View File

@@ -0,0 +1,127 @@
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')
}
})

View File

@@ -0,0 +1,50 @@
/**
* 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)
})

View File

@@ -0,0 +1,75 @@
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
}
})
}
})

View File

@@ -0,0 +1,82 @@
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
}
}
})

View File

@@ -0,0 +1,84 @@
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
}
}
})

View File

@@ -0,0 +1,33 @@
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 }
})

70
server/api/auth/me.get.ts Normal file
View File

@@ -0,0 +1,70 @@
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
}
}
})

View File

@@ -0,0 +1,80 @@
import { query } from '../../utils/db'
import { getDbSession, getSessionIdFromCookie } from '../../utils/session'
/**
* 현재 사용자 접근 가능 메뉴 조회
* GET /api/auth/menu
*/
export default defineEventHandler(async (event) => {
const sessionId = getSessionIdFromCookie(event)
if (!sessionId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
const session = await getDbSession(sessionId)
if (!session) {
throw createError({ statusCode: 401, message: '세션이 만료되었습니다.' })
}
// 사용자의 권한 목록 조회
const userRoles = await query<any>(`
SELECT r.role_id, r.role_code
FROM wr_employee_role er
JOIN wr_role r ON er.role_id = r.role_id
WHERE er.employee_id = $1
`, [session.employeeId])
const roleIds = userRoles.map(r => r.role_id)
if (roleIds.length === 0) {
return { menus: [] }
}
// 접근 가능한 메뉴 조회
const menus = await query<any>(`
SELECT DISTINCT
m.menu_id,
m.menu_code,
m.menu_name,
m.menu_path,
m.menu_icon,
m.parent_menu_id,
m.sort_order
FROM wr_menu m
JOIN wr_menu_role mr ON m.menu_id = mr.menu_id
WHERE mr.role_id = ANY($1)
AND m.is_active = true
ORDER BY m.parent_menu_id NULLS FIRST, m.sort_order
`, [roleIds])
// 계층 구조로 변환
const menuMap = new Map<number, any>()
const rootMenus: any[] = []
for (const m of menus) {
const menuItem = {
menuId: m.menu_id,
menuCode: m.menu_code,
menuName: m.menu_name,
menuPath: m.menu_path,
menuIcon: m.menu_icon,
parentMenuId: m.parent_menu_id,
sortOrder: m.sort_order,
children: []
}
menuMap.set(m.menu_id, menuItem)
}
for (const m of menus) {
const menuItem = menuMap.get(m.menu_id)
if (m.parent_menu_id && menuMap.has(m.parent_menu_id)) {
menuMap.get(m.parent_menu_id).children.push(menuItem)
} else if (!m.parent_menu_id) {
rootMenus.push(menuItem)
}
}
return { menus: rootMenus }
})

View File

@@ -0,0 +1,23 @@
import { query } from '../../utils/db'
/**
* 최근 로그인 사용자 목록
* GET /api/auth/recent-users
*/
export default defineEventHandler(async () => {
const users = await query(`
SELECT * FROM wr_recent_login_users
ORDER BY last_active_at DESC
LIMIT 10
`)
return {
users: users.map((u: any) => ({
employeeId: u.employee_id,
employeeName: u.employee_name,
employeeEmail: u.employee_email,
employeePosition: u.employee_position,
lastActiveAt: u.last_active_at
}))
}
})

View File

@@ -0,0 +1,78 @@
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
}
})

View File

@@ -0,0 +1,58 @@
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
}
}
})

View File

@@ -0,0 +1,77 @@
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 ? '임시 비밀번호가 생성되었습니다.' : '비밀번호가 설정되었습니다.'
}
})

View File

@@ -0,0 +1,107 @@
import { queryOne, execute } from '../../../utils/db'
import { createSession } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
/**
* Synology SSO 콜백
* GET /api/auth/synology/callback
*
* Synology SSO Server에서 인증 후 리다이렉트되는 엔드포인트
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const query = getQuery(event)
const ip = getClientIp(event)
const code = query.code as string
const error = query.error as string
if (error) {
return sendRedirect(event, `/login?error=${encodeURIComponent('Synology 인증이 취소되었습니다.')}`)
}
if (!code) {
return sendRedirect(event, '/login?error=' + encodeURIComponent('인증 코드가 없습니다.'))
}
try {
// 1. 코드로 액세스 토큰 교환
const tokenResponse = await $fetch<any>(`${config.synologyServerUrl}/webman/sso/SSOAccessToken.cgi`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: config.synologyClientId,
client_secret: config.synologyClientSecret,
redirect_uri: config.synologyRedirectUri
}).toString()
})
if (!tokenResponse.access_token) {
console.error('Synology token error:', tokenResponse)
return sendRedirect(event, '/login?error=' + encodeURIComponent('토큰 획득 실패'))
}
// 2. 액세스 토큰으로 사용자 정보 조회
const userResponse = await $fetch<any>(`${config.synologyServerUrl}/webman/sso/SSOUserInfo.cgi`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${tokenResponse.access_token}`
}
})
if (!userResponse.data || !userResponse.data.email) {
console.error('Synology user info error:', userResponse)
return sendRedirect(event, '/login?error=' + encodeURIComponent('사용자 정보를 가져올 수 없습니다.'))
}
const synologyEmail = userResponse.data.email
const synologyId = userResponse.data.user_id || userResponse.data.uid
const synologyName = userResponse.data.name || userResponse.data.username
// 3. 이메일로 사용자 매칭
const employee = await queryOne<any>(`
SELECT employee_id, employee_name, is_active, password_hash,
synology_id, synology_email
FROM wr_employee_info
WHERE email = $1
`, [synologyEmail])
if (!employee) {
return sendRedirect(event, '/login?error=' + encodeURIComponent('등록되지 않은 사용자입니다. 관리자에게 문의하세요.'))
}
if (!employee.is_active) {
return sendRedirect(event, '/login?error=' + encodeURIComponent('비활성화된 계정입니다.'))
}
// 4. Synology 계정 연결 정보 업데이트
await execute(`
UPDATE wr_employee_info
SET synology_id = $1, synology_email = $2, synology_linked_at = NOW()
WHERE employee_id = $3
`, [synologyId, synologyEmail, employee.employee_id])
// 5. 로그인 이력 기록
await execute(`
INSERT INTO wr_login_history (employee_id, login_type, login_ip, login_at, login_success, login_email)
VALUES ($1, 'SYNOLOGY', $2, NOW(), true, $3)
`, [employee.employee_id, ip, synologyEmail])
// 6. 세션 생성
await createSession(event, employee.employee_id)
// 7. 비밀번호 미설정 시 설정 페이지로
if (!employee.password_hash) {
return sendRedirect(event, '/set-password?from=synology')
}
// 8. 메인 페이지로 리다이렉트
return sendRedirect(event, '/')
} catch (e: any) {
console.error('Synology OAuth error:', e)
return sendRedirect(event, '/login?error=' + encodeURIComponent('Synology 인증 중 오류가 발생했습니다.'))
}
})

View File

@@ -0,0 +1,26 @@
/**
* Synology SSO 로그인 시작
* GET /api/auth/synology
*
* Synology SSO Server OAuth 2.0 인증 페이지로 리다이렉트
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!config.synologyServerUrl || !config.synologyClientId) {
throw createError({
statusCode: 500,
message: 'Synology SSO가 설정되지 않았습니다.'
})
}
// Synology SSO Server OAuth 인증 URL
const authUrl = new URL(`${config.synologyServerUrl}/webman/sso/SSOOauth.cgi`)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', config.synologyClientId)
authUrl.searchParams.set('redirect_uri', config.synologyRedirectUri)
authUrl.searchParams.set('scope', 'user_id')
authUrl.searchParams.set('state', crypto.randomUUID())
return sendRedirect(event, authUrl.toString())
})

View File

@@ -0,0 +1,30 @@
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: '보고서가 확정되었습니다.' }
})

View File

@@ -0,0 +1,80 @@
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
}))
}
})

View File

@@ -0,0 +1,31 @@
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 }
})

View File

@@ -0,0 +1,148 @@
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'
}
}
})

View File

@@ -0,0 +1,51 @@
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
}))
}
})

View File

@@ -0,0 +1,44 @@
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: '사업이 삭제(중단) 처리되었습니다.'
}
})

View File

@@ -0,0 +1,71 @@
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
}))
}
})

View File

@@ -0,0 +1,68 @@
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: '사업이 수정되었습니다.'
}
})

View File

@@ -0,0 +1,47 @@
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: '사업이 등록되었습니다.'
}
})

View File

@@ -0,0 +1,77 @@
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
}))
}
})

View File

@@ -0,0 +1,90 @@
import { query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 내 주간 커밋 조회 (주간보고 작성용)
* GET /api/commits/my-weekly
*
* Query params:
* - projectId: 프로젝트 ID (필수)
* - startDate: 주 시작일 (YYYY-MM-DD)
* - endDate: 주 종료일 (YYYY-MM-DD)
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const queryParams = getQuery(event)
const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null
const startDate = queryParams.startDate as string
const endDate = queryParams.endDate as string
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
// 조건 빌드
const conditions = [
'r.project_id = $1',
'c.employee_id = $2'
]
const values: any[] = [projectId, employeeId]
let paramIndex = 3
if (startDate) {
conditions.push(`c.commit_date >= $${paramIndex++}`)
values.push(startDate)
}
if (endDate) {
conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`)
values.push(endDate)
}
const whereClause = conditions.join(' AND ')
// 내 커밋 목록
const commits = await query(`
SELECT
c.commit_id, c.commit_hash, c.commit_message, c.commit_date,
c.files_changed, c.insertions, c.deletions,
r.repo_id, r.repo_name,
s.server_type
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE ${whereClause}
ORDER BY c.commit_date DESC
LIMIT 100
`, values)
// 통계
const statsResult = await query(`
SELECT
COUNT(*) as commit_count,
COALESCE(SUM(c.insertions), 0) as total_insertions,
COALESCE(SUM(c.deletions), 0) as total_deletions
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
return {
commits: commits.map(c => ({
commitId: c.commit_id,
commitHash: c.commit_hash?.substring(0, 8),
commitMessage: c.commit_message,
commitDate: c.commit_date,
filesChanged: c.files_changed,
insertions: c.insertions,
deletions: c.deletions,
repoId: c.repo_id,
repoName: c.repo_name,
serverType: c.server_type
})),
stats: {
commitCount: parseInt(statsResult[0]?.commit_count || '0'),
totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'),
totalDeletions: parseInt(statsResult[0]?.total_deletions || '0')
}
}
})

View File

@@ -0,0 +1,153 @@
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
}

View File

@@ -0,0 +1,64 @@
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}님이 삭제되었습니다.`
}
}
})

View File

@@ -0,0 +1,72 @@
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)
}))
}
})

View File

@@ -0,0 +1,34 @@
import { execute } from '../../../utils/db'
import { getClientIp } from '../../../utils/ip'
import { requireAuth } from '../../../utils/session'
/**
* Google 계정 연결 해제
* POST /api/employee/[id]/unlink-google
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const employeeId = parseInt(event.context.params?.id || '0')
if (!employeeId) {
throw createError({ statusCode: 400, message: '사용자 ID가 필요합니다.' })
}
const ip = getClientIp(event)
const result = await execute(`
UPDATE wr_employee_info
SET google_id = NULL,
google_email = NULL,
google_linked_at = NULL,
updated_at = NOW(),
updated_ip = $1
WHERE employee_id = $2
`, [ip, employeeId])
if (result === 0) {
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
}
return { success: true, message: 'Google 계정 연결이 해제되었습니다.' }
})

View File

@@ -0,0 +1,60 @@
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 }
})

View File

@@ -0,0 +1,62 @@
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
}
}
})

View File

@@ -0,0 +1,32 @@
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
}))
}
})

View File

@@ -0,0 +1,29 @@
import { query } from '../../utils/db'
/**
* 사원 검색 (이름/이메일)
* GET /api/employee/search?q=keyword
*/
export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const keyword = queryParams.q as string
if (!keyword || keyword.length < 1) {
return []
}
const employees = await query(`
SELECT * FROM wr_employee_info
WHERE is_active = true
AND (employee_name ILIKE $1 OR employee_email ILIKE $1)
ORDER BY employee_name
LIMIT 20
`, [`%${keyword}%`])
return employees.map((e: any) => ({
employeeId: e.employee_id,
employeeName: e.employee_name,
employeeEmail: e.employee_email,
employeePosition: e.employee_position
}))
})

View File

@@ -0,0 +1,37 @@
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: '삭제되었습니다.'
}
})

View File

@@ -0,0 +1,64 @@
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
}
})

View File

@@ -0,0 +1,55 @@
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: '수정되었습니다.'
}
})

View File

@@ -0,0 +1,36 @@
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: '의견이 등록되었습니다.'
}
})

View File

@@ -0,0 +1,101 @@
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)
}
}
})

View File

@@ -0,0 +1,22 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 구글 그룹 삭제 (비활성화)
* DELETE /api/google-group/[id]/delete
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const groupId = parseInt(getRouterParam(event, 'id') || '0')
if (!groupId) {
throw createError({ statusCode: 400, message: '그룹 ID가 필요합니다.' })
}
await execute(`
UPDATE wr_google_group SET is_active = false, updated_at = NOW()
WHERE group_id = $1
`, [groupId])
return { success: true, message: '그룹이 삭제되었습니다.' }
})

View File

@@ -0,0 +1,87 @@
import { queryOne } from '../../../utils/db'
import { requireAuth, getCurrentUser } from '../../../utils/session'
import { getValidGoogleToken } from '../../../utils/google-token'
/**
* 구글 그룹 메시지 목록 조회
* GET /api/google-group/[id]/messages
*/
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
const groupId = parseInt(getRouterParam(event, 'id') || '0')
const queryParams = getQuery(event)
if (!groupId) {
throw createError({ statusCode: 400, message: '그룹 ID가 필요합니다.' })
}
// 그룹 정보 조회
const group = await queryOne<any>(`
SELECT group_email, group_name FROM wr_google_group WHERE group_id = $1
`, [groupId])
if (!group) {
throw createError({ statusCode: 404, message: '그룹을 찾을 수 없습니다.' })
}
// Google 토큰 확인
const accessToken = await getValidGoogleToken(user.employeeId)
if (!accessToken) {
throw createError({
statusCode: 401,
message: 'Google 계정 연결이 필요합니다.'
})
}
const maxResults = parseInt(queryParams.limit as string) || 20
const pageToken = queryParams.pageToken as string || ''
try {
// Gmail API로 그룹 메일 검색
const searchQuery = encodeURIComponent(`from:${group.group_email} OR to:${group.group_email}`)
let url = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${searchQuery}&maxResults=${maxResults}`
if (pageToken) url += `&pageToken=${pageToken}`
const listRes = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` }
})
if (!listRes.ok) {
throw createError({ statusCode: 500, message: 'Gmail API 오류' })
}
const listData = await listRes.json()
const messages: any[] = []
// 각 메시지 상세 정보 조회 (최대 10개)
for (const msg of (listData.messages || []).slice(0, 10)) {
const detailRes = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (detailRes.ok) {
const detail = await detailRes.json()
const headers = detail.payload?.headers || []
messages.push({
messageId: msg.id,
threadId: msg.threadId,
subject: headers.find((h: any) => h.name === 'Subject')?.value || '(제목 없음)',
from: headers.find((h: any) => h.name === 'From')?.value || '',
date: headers.find((h: any) => h.name === 'Date')?.value || '',
snippet: detail.snippet || ''
})
}
}
return {
group: { groupId, groupEmail: group.group_email, groupName: group.group_name },
messages,
nextPageToken: listData.nextPageToken || null
}
} catch (e: any) {
throw createError({ statusCode: 500, message: e.message || '메시지 조회 실패' })
}
})

View File

@@ -0,0 +1,23 @@
import { execute, insertReturning } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 등록
* POST /api/google-group/create
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const body = await readBody(event)
if (!body.groupEmail || !body.groupName) {
throw createError({ statusCode: 400, message: '그룹 이메일과 이름은 필수입니다.' })
}
const result = await insertReturning(`
INSERT INTO wr_google_group (group_email, group_name, description)
VALUES ($1, $2, $3)
RETURNING group_id
`, [body.groupEmail.toLowerCase().trim(), body.groupName.trim(), body.description || null])
return { success: true, groupId: result.group_id, message: '그룹이 등록되었습니다.' }
})

View File

@@ -0,0 +1,28 @@
import { query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 목록 조회
* GET /api/google-group/list
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const groups = await query(`
SELECT group_id, group_email, group_name, description, is_active, created_at
FROM wr_google_group
WHERE is_active = true
ORDER BY group_name
`)
return {
groups: groups.map(g => ({
groupId: g.group_id,
groupEmail: g.group_email,
groupName: g.group_name,
description: g.description,
isActive: g.is_active,
createdAt: g.created_at
}))
}
})

View File

@@ -0,0 +1,139 @@
import { queryOne, execute } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 메시지 조회
* GET /api/google-group/messages
*
* Gmail API로 그룹 이메일 조회
*
* Query params:
* - groupEmail: 그룹 이메일 주소 (예: dev-team@company.com)
* - maxResults: 최대 결과 수 (기본 20)
* - after: 이 날짜 이후 메시지 (YYYY-MM-DD)
*/
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const query = getQuery(event)
const groupEmail = query.groupEmail as string
const maxResults = parseInt(query.maxResults as string) || 20
const after = query.after as string
if (!groupEmail) {
throw createError({ statusCode: 400, message: '그룹 이메일이 필요합니다.' })
}
// 사용자의 Google 토큰 조회
const employee = await queryOne<any>(`
SELECT google_access_token, google_refresh_token, google_token_expires_at
FROM wr_employee_info WHERE employee_id = $1
`, [session.employeeId])
if (!employee?.google_access_token) {
throw createError({
statusCode: 401,
message: 'Google 계정이 연결되지 않았습니다.'
})
}
let accessToken = employee.google_access_token
// 토큰 만료 확인 및 갱신
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
}
try {
// Gmail API로 그룹 메일 검색
let searchQuery = `list:${groupEmail}`
if (after) {
searchQuery += ` after:${after}`
}
const listResponse = await $fetch<any>('https://gmail.googleapis.com/gmail/v1/users/me/messages', {
headers: { 'Authorization': `Bearer ${accessToken}` },
query: {
q: searchQuery,
maxResults: maxResults
}
})
if (!listResponse.messages || listResponse.messages.length === 0) {
return { messages: [], total: 0 }
}
// 각 메시지의 상세 정보 조회
const messages = await Promise.all(
listResponse.messages.slice(0, maxResults).map(async (msg: any) => {
const detail = await $fetch<any>(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
query: { format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] }
})
const headers = detail.payload?.headers || []
const getHeader = (name: string) => headers.find((h: any) => h.name === name)?.value || ''
return {
id: msg.id,
threadId: msg.threadId,
subject: getHeader('Subject'),
from: getHeader('From'),
to: getHeader('To'),
date: getHeader('Date'),
snippet: detail.snippet
}
})
)
return {
messages,
total: listResponse.resultSizeEstimate || messages.length
}
} catch (e: any) {
console.error('Gmail API error:', e)
if (e.status === 403) {
throw createError({
statusCode: 403,
message: 'Gmail 접근 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.'
})
}
throw createError({
statusCode: 500,
message: '그룹 메시지를 가져오는데 실패했습니다.'
})
}
})
/**
* Google 토큰 갱신
*/
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
const config = useRuntimeConfig()
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
}).toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
if (response.access_token) {
await execute(`
UPDATE wr_employee_info
SET google_access_token = $1,
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
WHERE employee_id = $2
`, [response.access_token, employeeId])
return response.access_token
}
throw new Error('토큰 갱신 실패')
}

View File

@@ -0,0 +1,207 @@
import { queryOne, execute, query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 주간보고를 구글 그룹에 공유 (이메일 전송)
* POST /api/google-group/share-report
*
* Gmail API로 그룹에 이메일 전송
*
* Body:
* - reportId: 주간보고 ID
* - groupEmail: 그룹 이메일 주소
* - subject?: 이메일 제목 (기본값 자동 생성)
*/
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const body = await readBody(event)
const { reportId, groupEmail, subject } = body
if (!reportId || !groupEmail) {
throw createError({ statusCode: 400, message: '보고서 ID와 그룹 이메일이 필요합니다.' })
}
// 주간보고 조회
const report = await queryOne<any>(`
SELECT r.*, e.employee_name, e.employee_email, p.project_name
FROM wr_weekly_report r
JOIN wr_employee_info e ON r.employee_id = e.employee_id
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
WHERE r.report_id = $1
`, [reportId])
if (!report) {
throw createError({ statusCode: 404, message: '주간보고를 찾을 수 없습니다.' })
}
// 권한 확인 (본인 보고서만)
if (report.employee_id !== session.employeeId) {
throw createError({ statusCode: 403, message: '본인의 주간보고만 공유할 수 있습니다.' })
}
// 사용자의 Google 토큰 조회
const employee = await queryOne<any>(`
SELECT google_access_token, google_refresh_token, google_token_expires_at, employee_email
FROM wr_employee_info WHERE employee_id = $1
`, [session.employeeId])
if (!employee?.google_access_token) {
throw createError({
statusCode: 401,
message: 'Google 계정이 연결되지 않았습니다.'
})
}
let accessToken = employee.google_access_token
// 토큰 만료 확인 및 갱신
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
}
// 이메일 내용 생성
const emailSubject = subject || `[주간보고] ${report.project_name || '개인'} - ${report.report_week}주차 (${report.employee_name})`
const emailBody = generateReportEmailBody(report)
// RFC 2822 형식의 이메일 메시지 생성
const emailLines = [
`From: ${employee.employee_email}`,
`To: ${groupEmail}`,
`Subject: =?UTF-8?B?${Buffer.from(emailSubject).toString('base64')}?=`,
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
'',
emailBody
]
const rawEmail = Buffer.from(emailLines.join('\r\n'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
try {
// Gmail API로 이메일 전송
const response = await $fetch<any>('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: { raw: rawEmail }
})
// 공유 이력 저장
await execute(`
INSERT INTO wr_report_share_log (report_id, shared_to, shared_type, shared_by, message_id)
VALUES ($1, $2, 'GOOGLE_GROUP', $3, $4)
`, [reportId, groupEmail, session.employeeId, response.id])
return {
success: true,
message: `${groupEmail}로 주간보고가 공유되었습니다.`,
messageId: response.id
}
} catch (e: any) {
console.error('Gmail send error:', e)
if (e.status === 403) {
throw createError({
statusCode: 403,
message: 'Gmail 발송 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.'
})
}
throw createError({
statusCode: 500,
message: '이메일 발송에 실패했습니다.'
})
}
})
/**
* 주간보고 이메일 본문 생성
*/
function generateReportEmailBody(report: any): string {
const weekRange = `${report.week_start_date?.split('T')[0] || ''} ~ ${report.week_end_date?.split('T')[0] || ''}`
return `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: 'Malgun Gothic', sans-serif; padding: 20px; max-width: 800px;">
<h2 style="color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px;">
📋 주간업무보고
</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>작성자</strong></td>
<td style="padding: 8px;">${report.employee_name}</td>
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>프로젝트</strong></td>
<td style="padding: 8px;">${report.project_name || '-'}</td>
</tr>
<tr>
<td style="padding: 8px; background: #f8f9fa;"><strong>보고 주차</strong></td>
<td style="padding: 8px;">${report.report_year}${report.report_week}주차</td>
<td style="padding: 8px; background: #f8f9fa;"><strong>기간</strong></td>
<td style="padding: 8px;">${weekRange}</td>
</tr>
</table>
<h3 style="color: #28a745;">✅ 금주 실적</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.this_week_work || '(내용 없음)'}</div>
<h3 style="color: #007bff;">📅 차주 계획</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.next_week_plan || '(내용 없음)'}</div>
${report.issues ? `
<h3 style="color: #dc3545;">⚠️ 이슈사항</h3>
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.issues}</div>
` : ''}
${report.remarks ? `
<h3 style="color: #6c757d;">📝 비고</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;">${report.remarks}</div>
` : ''}
<hr style="margin-top: 30px; border: none; border-top: 1px solid #ddd;">
<p style="color: #6c757d; font-size: 12px;">
이 메일은 주간업무보고 시스템에서 자동 발송되었습니다.
</p>
</body>
</html>
`.trim()
}
/**
* Google 토큰 갱신
*/
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
const config = useRuntimeConfig()
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
}).toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
if (response.access_token) {
await execute(`
UPDATE wr_employee_info
SET google_access_token = $1,
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
WHERE employee_id = $2
`, [response.access_token, employeeId])
return response.access_token
}
throw new Error('토큰 갱신 실패')
}

View File

@@ -0,0 +1,36 @@
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: '유지보수 업무가 삭제되었습니다.'
}
})

View File

@@ -0,0 +1,64 @@
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
}
}
})

View File

@@ -0,0 +1,48 @@
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: '상태가 변경되었습니다.'
}
})

View File

@@ -0,0 +1,85 @@
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: '유지보수 업무가 수정되었습니다.'
}
})

View File

@@ -0,0 +1,84 @@
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
}
})

View File

@@ -0,0 +1,57 @@
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: '유지보수 업무가 등록되었습니다.'
}
})

View File

@@ -0,0 +1,114 @@
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)
}
}
})

View File

@@ -0,0 +1,64 @@
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
}))
}
})

View File

@@ -0,0 +1,100 @@
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] || '기타'
}

View File

@@ -0,0 +1,78 @@
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
}
})

View File

@@ -0,0 +1,145 @@
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)
}))
}
})

View File

@@ -0,0 +1,175 @@
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
}
})

View File

@@ -0,0 +1,105 @@
import { queryOne, execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { callOpenAI } from '../../../utils/openai'
/**
* 회의록 AI 분석
* POST /api/meeting/[id]/analyze
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const meetingId = parseInt(event.context.params?.id || '0')
if (!meetingId) {
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
}
// 회의록 조회
const meeting = await queryOne<any>(`
SELECT m.*, p.project_name
FROM wr_meeting m
LEFT JOIN wr_project_info p ON m.project_id = p.project_id
WHERE m.meeting_id = $1
`, [meetingId])
if (!meeting) {
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
}
if (!meeting.raw_content) {
throw createError({ statusCode: 400, message: '분석할 회의 내용이 없습니다.' })
}
// AI 프롬프트
const systemPrompt = `당신은 회의록 정리 전문가입니다.
아래 회의 내용을 분석하여 JSON 형식으로 정리해주세요.
## 출력 형식 (JSON만 출력, 다른 텍스트 없이)
{
"agendas": [
{
"no": 1,
"title": "안건 제목",
"content": "상세 내용 요약",
"status": "DECIDED | PENDING | IN_PROGRESS",
"decision": "결정 내용 (결정된 경우만)",
"todos": [
{
"title": "TODO 제목",
"assignee": "담당자명 또는 null",
"reason": "TODO로 추출한 이유"
}
]
}
],
"summary": "전체 회의 요약 (2-3문장)"
}
## 규칙
1. 안건은 주제별로 분리하여 넘버링
2. 결정된 사항은 DECIDED, 추후 논의는 PENDING, 진행중은 IN_PROGRESS
3. 미결정/진행중 사항 중 액션이 필요한 것은 todos로 추출
4. 담당자가 언급되면 assignee에 기록 (없으면 null)
5. JSON 외 다른 텍스트 출력 금지`
const userPrompt = `## 회의 정보
- 제목: ${meeting.meeting_title}
- 프로젝트: ${meeting.project_name || '없음 (내부업무)'}
- 일자: ${meeting.meeting_date}
## 회의 내용
${meeting.raw_content}`
try {
const result = await callOpenAI([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
], true, 'gpt-4o-mini')
// JSON 파싱
let aiResult: any
try {
// JSON 블록 추출 (```json ... ``` 형태 처리)
let jsonStr = result.trim()
if (jsonStr.startsWith('```')) {
jsonStr = jsonStr.replace(/^```json?\n?/, '').replace(/\n?```$/, '')
}
aiResult = JSON.parse(jsonStr)
} catch (e) {
console.error('AI result parse error:', result)
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
}
// DB 저장
await execute(`
UPDATE wr_meeting
SET ai_summary = $1, ai_status = 'PENDING', ai_processed_at = NOW()
WHERE meeting_id = $2
`, [JSON.stringify(aiResult), meetingId])
return { success: true, result: aiResult }
} catch (e: any) {
console.error('AI analyze error:', e)
throw createError({ statusCode: 500, message: e.message || 'AI 분석 실패' })
}
})

View File

@@ -0,0 +1,82 @@
import { queryOne, execute, insertReturning } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
interface ConfirmBody {
selectedTodos?: Array<{
agendaNo: number
todoIndex: number
title: string
assignee?: string
}>
}
/**
* AI 분석 결과 확정 + TODO 생성
* POST /api/meeting/[id]/confirm
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const meetingId = parseInt(event.context.params?.id || '0')
const body = await readBody<ConfirmBody>(event)
const ip = getClientIp(event)
if (!meetingId) {
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
}
// 회의록 조회
const meeting = await queryOne<any>(`
SELECT meeting_id, ai_summary, ai_status, project_id
FROM wr_meeting WHERE meeting_id = $1
`, [meetingId])
if (!meeting) {
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
}
if (!meeting.ai_summary) {
throw createError({ statusCode: 400, message: 'AI 분석 결과가 없습니다.' })
}
const aiResult = typeof meeting.ai_summary === 'string'
? JSON.parse(meeting.ai_summary)
: meeting.ai_summary
// 선택된 TODO 생성
const createdTodos: any[] = []
if (body.selectedTodos && body.selectedTodos.length > 0) {
for (const todo of body.selectedTodos) {
const inserted = await insertReturning(`
INSERT INTO wr_todo (
source_type, meeting_id, project_id,
todo_title, todo_description, todo_status,
author_id, created_at, created_ip
) VALUES ('MEETING', $1, $2, $3, $4, 'PENDING', $5, NOW(), $6)
RETURNING todo_id
`, [
meetingId,
meeting.project_id,
todo.title,
`안건 ${todo.agendaNo}에서 추출`,
employeeId,
ip
])
createdTodos.push({ todoId: inserted.todo_id, title: todo.title })
}
}
// 상태 업데이트
await execute(`
UPDATE wr_meeting
SET ai_status = 'CONFIRMED', ai_confirmed_at = NOW()
WHERE meeting_id = $1
`, [meetingId])
return {
success: true,
message: `확정 완료. ${createdTodos.length}개의 TODO가 생성되었습니다.`,
createdTodos
}
})

View File

@@ -0,0 +1,30 @@
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: '회의록이 삭제되었습니다.'
}
})

View File

@@ -0,0 +1,96 @@
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
}))
}
})

View File

@@ -0,0 +1,107 @@
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: '회의록이 수정되었습니다.'
}
})

View File

@@ -0,0 +1,92 @@
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: '회의록이 등록되었습니다.'
}
})

View File

@@ -0,0 +1,122 @@
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)
}
}
})

View File

@@ -0,0 +1,131 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 프로젝트 커밋 목록 조회
* GET /api/project/[id]/commits
*
* Query params:
* - startDate: 시작일 (YYYY-MM-DD)
* - endDate: 종료일 (YYYY-MM-DD)
* - repoId: 저장소 ID (옵션)
* - authorId: 작성자 ID (옵션)
* - page: 페이지 번호 (기본 1)
* - limit: 페이지당 개수 (기본 50)
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const projectId = parseInt(getRouterParam(event, 'id') || '0')
const queryParams = getQuery(event)
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
const startDate = queryParams.startDate as string
const endDate = queryParams.endDate as string
const repoId = queryParams.repoId ? parseInt(queryParams.repoId as string) : null
const authorId = queryParams.authorId ? parseInt(queryParams.authorId as string) : null
const page = parseInt(queryParams.page as string) || 1
const limit = Math.min(parseInt(queryParams.limit as string) || 50, 100)
const offset = (page - 1) * limit
// 조건 빌드
const conditions = ['r.project_id = $1']
const values: any[] = [projectId]
let paramIndex = 2
if (startDate) {
conditions.push(`c.commit_date >= $${paramIndex++}`)
values.push(startDate)
}
if (endDate) {
conditions.push(`c.commit_date <= $${paramIndex++}::date + INTERVAL '1 day'`)
values.push(endDate)
}
if (repoId) {
conditions.push(`c.repo_id = $${paramIndex++}`)
values.push(repoId)
}
if (authorId) {
conditions.push(`c.employee_id = $${paramIndex++}`)
values.push(authorId)
}
const whereClause = conditions.join(' AND ')
// 커밋 목록 조회
const commits = await query(`
SELECT
c.commit_id, c.commit_hash, c.commit_message, c.commit_author, c.commit_email,
c.commit_date, c.employee_id, c.files_changed, c.insertions, c.deletions,
r.repo_id, r.repo_name, r.repo_path,
s.server_type, s.server_name,
e.employee_name, e.display_name
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
JOIN wr_vcs_server s ON r.server_id = s.server_id
LEFT JOIN wr_employee_info e ON c.employee_id = e.employee_id
WHERE ${whereClause}
ORDER BY c.commit_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`, [...values, limit, offset])
// 전체 개수 조회
const countResult = await query(`
SELECT COUNT(*) as total
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
const total = parseInt(countResult[0]?.total || '0')
// 통계 (해당 기간)
const statsResult = await query(`
SELECT
COUNT(*) as commit_count,
COALESCE(SUM(c.insertions), 0) as total_insertions,
COALESCE(SUM(c.deletions), 0) as total_deletions,
COUNT(DISTINCT c.employee_id) as author_count
FROM wr_commit_log c
JOIN wr_repository r ON c.repo_id = r.repo_id
WHERE ${whereClause}
`, values)
return {
commits: commits.map(c => ({
commitId: c.commit_id,
commitHash: c.commit_hash,
commitMessage: c.commit_message,
commitAuthor: c.commit_author,
commitEmail: c.commit_email,
commitDate: c.commit_date,
employeeId: c.employee_id,
employeeName: c.display_name || c.employee_name,
filesChanged: c.files_changed,
insertions: c.insertions,
deletions: c.deletions,
repoId: c.repo_id,
repoName: c.repo_name,
repoPath: c.repo_path,
serverType: c.server_type,
serverName: c.server_name
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
},
stats: {
commitCount: parseInt(statsResult[0]?.commit_count || '0'),
totalInsertions: parseInt(statsResult[0]?.total_insertions || '0'),
totalDeletions: parseInt(statsResult[0]?.total_deletions || '0'),
authorCount: parseInt(statsResult[0]?.author_count || '0')
}
}
})

View File

@@ -0,0 +1,31 @@
import { requireAuth } from '../../../../utils/session'
import { syncProjectGitRepositories } from '../../../../utils/git-sync'
import { syncProjectSvnRepositories } from '../../../../utils/svn-sync'
/**
* 프로젝트 커밋 새로고침 (모든 저장소 동기화)
* POST /api/project/[id]/commits/refresh
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const projectId = parseInt(getRouterParam(event, 'id') || '0')
if (!projectId) {
throw createError({ statusCode: 400, message: '프로젝트 ID가 필요합니다.' })
}
// Git과 SVN 모두 동기화
const gitResult = await syncProjectGitRepositories(projectId)
const svnResult = await syncProjectSvnRepositories(projectId)
const allResults = [...gitResult.results, ...svnResult.results]
const allSuccess = allResults.every(r => r.success)
return {
success: allSuccess,
message: allSuccess
? `${allResults.length}개 저장소 동기화 완료`
: '일부 저장소 동기화 실패',
results: allResults
}
})

View File

@@ -0,0 +1,50 @@
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
}
}
})

View File

@@ -0,0 +1,58 @@
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
}
})

View File

@@ -0,0 +1,30 @@
import { query } from '../../../utils/db'
/**
* 프로젝트 PM/PL 이력 조회
* GET /api/project/[id]/manager-history
*/
export default defineEventHandler(async (event) => {
const projectId = getRouterParam(event, 'id')
const history = await query(`
SELECT h.*, e.employee_name, e.employee_email
FROM wr_project_manager_history h
JOIN wr_employee_info e ON h.employee_id = e.employee_id
WHERE h.project_id = $1
ORDER BY h.role_type, h.start_date DESC
`, [projectId])
return history.map((h: any) => ({
historyId: h.history_id,
projectId: h.project_id,
employeeId: h.employee_id,
employeeName: h.employee_name,
employeeEmail: h.employee_email,
roleType: h.role_type,
startDate: h.start_date,
endDate: h.end_date,
changeReason: h.change_reason,
createdAt: h.created_at
}))
})

View File

@@ -0,0 +1,71 @@
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 }
})

View File

@@ -0,0 +1,94 @@
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
}
}
})

View File

@@ -0,0 +1,70 @@
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
}))
}
})

View File

@@ -0,0 +1,33 @@
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
}))
})

View File

@@ -0,0 +1,228 @@
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
})
}
})

View File

@@ -0,0 +1,116 @@
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
}
})
}
})

View File

@@ -0,0 +1,37 @@
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 }
})

View File

@@ -0,0 +1,220 @@
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: '요약 생성 실패'
}
}
}

View File

@@ -0,0 +1,39 @@
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)
}))
}
})

View File

@@ -0,0 +1,58 @@
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
}))
}
})

View File

@@ -0,0 +1,150 @@
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 || '요약 없음'
}
}

View File

@@ -0,0 +1,171 @@
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
}
})

View File

@@ -0,0 +1,76 @@
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}`
}

Some files were not shown because too many files have changed in this diff Show More