기능구현중

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

@@ -1,228 +0,0 @@
import { defineEventHandler, readBody, createError } from 'h3'
import { query } from '../../utils/db'
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
// 문자열 해시 함수 (seed용)
function hashCode(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // 32bit 정수로 변환
}
return Math.abs(hash)
}
interface QualityScore {
summary: string // 총평 (맨 위)
specificity: { score: number; improvement: string } // 구체성
completeness: { score: number; improvement: string } // 완결성
timeEstimation: { score: number; improvement: string } // 시간산정
planning: { score: number; improvement: string } // 계획성
overall: number // 종합점수
bestPractice: { // 모범 답안
workTasks: string[] // 금주 실적 모범 답안
planTasks: string[] // 차주 계획 모범 답안
}
}
/**
* 주간보고 PMO AI 리뷰 - 작성 품질 점수 + 모범 답안
* POST /api/report/review
*/
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { reportId } = body
if (!reportId) {
throw createError({ statusCode: 400, message: 'reportId가 필요합니다.' })
}
// 주간보고 조회
const reports = await query(`
SELECT
r.report_id,
r.report_year,
r.report_week,
e.employee_name as author_name
FROM wr_weekly_report r
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE r.report_id = $1
`, [reportId])
if (reports.length === 0) {
throw createError({ statusCode: 404, message: '주간보고를 찾을 수 없습니다.' })
}
const report = reports[0]
// Task 조회
const tasks = await query(`
SELECT
t.task_type,
t.task_description,
t.task_hours,
t.is_completed,
p.project_name
FROM wr_weekly_report_task t
JOIN wr_project_info p ON t.project_id = p.project_id
WHERE t.report_id = $1
ORDER BY t.task_type, p.project_name
`, [reportId])
if (tasks.length === 0) {
throw createError({ statusCode: 400, message: '등록된 Task가 없습니다.' })
}
// Task를 실적/계획으로 분리
const workTasks = tasks.filter((t: any) => t.task_type === 'WORK')
const planTasks = tasks.filter((t: any) => t.task_type === 'PLAN')
// 프롬프트용 텍스트 생성
let taskText = `[작성자] ${report.author_name}\n[기간] ${report.report_year}${report.report_week}주차\n\n`
if (workTasks.length > 0) {
taskText += `[금주 실적]\n`
workTasks.forEach((t: any) => {
const status = t.is_completed ? '완료' : '진행중'
taskText += `- ${t.project_name} / ${t.task_description} / ${t.task_hours}h / ${status}\n`
})
taskText += '\n'
}
if (planTasks.length > 0) {
taskText += `[차주 계획]\n`
planTasks.forEach((t: any) => {
taskText += `- ${t.project_name} / ${t.task_description} / ${t.task_hours}h\n`
})
}
// OpenAI 품질 점수 + 모범 답안 요청
const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)입니다.
주간보고의 작성 품질을 평가하고, 모범 답안을 제시해주세요.
[평가 항목] (각 1~10점)
1. 구체성 (specificity): 작업 내용이 어떤 기능/모듈인지 구체적으로 작성되었는지
2. 완결성 (completeness): 필수 정보 포함 여부
3. 시간산정 (timeEstimation): 작업 시간이 내용 대비 적절하게 배분되었는지
4. 계획성 (planning): 차주 계획이 실현 가능하고 명확한 목표가 있는지
[완결성 상세 기준] - 엄격하게 적용
- 진행중 작업에 진척률(%)이 없으면 -2점
- 진행중 작업에 완료 예정일이 없으면 -2점
- 완료 작업인데 산출물/결과 언급이 없으면 -1점
- 상태(완료/진행중)가 명확하지 않으면 -1점
[계획성 상세 기준] - 엄격하게 적용
- 차주 계획에 예상 소요시간 근거가 없으면 -1점
- 차주 계획에 목표 완료일/산출물이 없으면 -2점
- 단순 "~할 예정", "~진행" 만 있고 구체적 목표가 없으면 -2점
- 실현 가능성이 낮은 과도한 계획이면 -1점
[점수 기준]
- 1~3점: 매우 부족 (내용이 거의 없거나 한 단어 수준)
- 4~5점: 부족 (진척률/예정일 누락, 모호한 표현)
- 6~7점: 보통 (기본 내용은 있으나 구체성 부족)
- 8~9점: 양호 (진척률, 예정일, 산출물 모두 명시)
- 10점: 우수 (완벽한 모범 사례)
※ 진행중 작업에 진척률/예정일이 없으면 완결성은 6점 이하로 평가하세요.
※ 차주 계획에 구체적 목표가 없으면 계획성은 6점 이하로 평가하세요.
[모범 답안 작성 규칙]
- 사용자가 작성한 내용을 기반으로 더 구체적으로 보완
- 같은 프로젝트명, 비슷한 작업 내용을 유지하되 구체성 추가
- 진행중인 작업은 반드시 진척률(%)과 완료 예정일 추가
- 시간이 긴 작업은 세부 내역 포함
- 차주 계획은 목표 산출물과 예상 완료일 명시
- 형식: "프로젝트명 / 작업내용 (세부사항, 진척률, 예정일) / 시간h / 상태"
[응답 규칙]
- 반드시 아래 JSON 형식으로만 응답
- summary: 전체적인 총평 (30~60자, 격려 포함)
- improvement: 각 항목별 개선 포인트 (15~30자, 구체적으로)
- bestPractice: 모범 답안 (workTasks, planTasks 배열)
- JSON 외의 텍스트는 절대 포함하지 마세요`
const userPrompt = `다음 주간보고의 작성 품질을 평가하고, 모범 답안을 만들어주세요.
${taskText}
아래 JSON 형식으로만 응답하세요:
{
"summary": "총평 (격려 포함)",
"specificity": { "score": 숫자, "improvement": "개선포인트" },
"completeness": { "score": 숫자, "improvement": "개선포인트" },
"timeEstimation": { "score": 숫자, "improvement": "개선포인트" },
"planning": { "score": 숫자, "improvement": "개선포인트" },
"overall": 종합점수(소수점1자리),
"bestPractice": {
"workTasks": ["모범답안1", "모범답안2", ...],
"planTasks": ["모범답안1", "모범답안2", ...]
}
}`
try {
// Task 내용 기반 seed 생성 (같은 내용 = 같은 점수)
const seed = hashCode(taskText)
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
max_tokens: 1500,
temperature: 0.2, // 낮춰서 일관성 강화
seed: seed // 같은 내용 = 같은 seed = 같은 결과
})
const content = response.choices[0]?.message?.content || ''
// JSON 파싱
let qualityScore: QualityScore
try {
// JSON 블록 추출 (```json ... ``` 형태 처리)
let jsonStr = content
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/)
if (jsonMatch) {
jsonStr = jsonMatch[1]
} else {
// { } 사이 추출
const braceMatch = content.match(/\{[\s\S]*\}/)
if (braceMatch) {
jsonStr = braceMatch[0]
}
}
qualityScore = JSON.parse(jsonStr)
} catch (parseError) {
console.error('JSON 파싱 실패:', content)
throw new Error('AI 응답을 파싱할 수 없습니다.')
}
const reviewedAt = new Date().toISOString()
// DB에 저장 (ai_review에 JSON 문자열로 저장)
await query(`
UPDATE wr_weekly_report
SET ai_review = $1, ai_review_at = $2
WHERE report_id = $3
`, [JSON.stringify(qualityScore), reviewedAt, reportId])
return {
success: true,
qualityScore,
reviewedAt
}
} catch (error: any) {
console.error('OpenAI API error:', error)
throw createError({
statusCode: 500,
message: 'AI 품질 평가 중 오류가 발생했습니다: ' + error.message
})
}
})

View File

@@ -1,116 +0,0 @@
import { queryOne, query } from '../../../../utils/db'
/**
* 취합 보고서 상세 (개별 보고서 포함)
* GET /api/report/summary/[id]/detail
*/
export default defineEventHandler(async (event) => {
const summaryId = getRouterParam(event, 'id')
// 취합 보고서 조회
const summary = await queryOne<any>(`
SELECT s.*, p.project_name, p.project_code,
e.employee_name as reviewer_name
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
LEFT JOIN wr_employee_info e ON s.reviewer_id = e.employee_id
WHERE s.summary_id = $1
`, [summaryId])
if (!summary) {
throw createError({ statusCode: 404, message: '취합 보고서를 찾을 수 없습니다.' })
}
// 개별 보고서 목록 (Task 기반)
const reports = await query(`
SELECT DISTINCT
r.report_id,
r.author_id,
e.employee_name as author_name,
e.employee_position,
r.issue_description,
r.vacation_description,
r.remark_description,
r.report_status,
r.submitted_at
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE t.project_id = $1 AND r.report_year = $2 AND r.report_week = $3
ORDER BY e.employee_name
`, [summary.project_id, summary.report_year, summary.report_week])
// 각 보고서의 Task 조회
const reportIds = reports.map((r: any) => r.report_id)
const tasks = reportIds.length > 0 ? await query(`
SELECT
t.report_id,
t.task_type,
t.task_description,
t.task_hours,
t.is_completed
FROM wr_weekly_report_task t
WHERE t.report_id = ANY($1) AND t.project_id = $2
ORDER BY t.report_id, t.task_type
`, [reportIds, summary.project_id]) : []
// Task를 보고서별로 그룹핑
const tasksByReport = new Map<number, { work: any[], plan: any[] }>()
for (const task of tasks) {
if (!tasksByReport.has(task.report_id)) {
tasksByReport.set(task.report_id, { work: [], plan: [] })
}
const group = tasksByReport.get(task.report_id)!
if (task.task_type === 'WORK') {
group.work.push({
description: task.task_description,
hours: parseFloat(task.task_hours) || 0,
isCompleted: task.is_completed
})
} else {
group.plan.push({
description: task.task_description,
hours: parseFloat(task.task_hours) || 0
})
}
}
return {
summary: {
summaryId: summary.summary_id,
projectId: summary.project_id,
projectName: summary.project_name,
projectCode: summary.project_code,
reportYear: summary.report_year,
reportWeek: summary.report_week,
weekStartDate: summary.week_start_date,
weekEndDate: summary.week_end_date,
memberCount: summary.member_count,
totalWorkHours: summary.total_work_hours,
reviewerId: summary.reviewer_id,
reviewerName: summary.reviewer_name,
reviewerComment: summary.reviewer_comment,
reviewedAt: summary.reviewed_at,
summaryStatus: summary.summary_status,
aggregatedAt: summary.aggregated_at,
aiSummary: summary.ai_summary,
aiSummaryAt: summary.ai_summary_at
},
reports: reports.map((r: any) => {
const taskGroup = tasksByReport.get(r.report_id) || { work: [], plan: [] }
return {
reportId: r.report_id,
authorId: r.author_id,
authorName: r.author_name,
authorPosition: r.employee_position,
workTasks: taskGroup.work,
planTasks: taskGroup.plan,
issueDescription: r.issue_description,
vacationDescription: r.vacation_description,
remarkDescription: r.remark_description,
reportStatus: r.report_status,
submittedAt: r.submitted_at
}
})
}
})

View File

@@ -1,37 +0,0 @@
import { execute, queryOne } from '../../../../utils/db'
import { requireAuth } from '../../../../utils/session'
interface ReviewBody {
reviewerComment?: string
}
/**
* PM 검토/코멘트 작성
* PUT /api/report/summary/[id]/review
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const summaryId = getRouterParam(event, 'id')
const body = await readBody<ReviewBody>(event)
const summary = await queryOne<any>(`
SELECT * FROM wr_aggregated_report_summary WHERE summary_id = $1
`, [summaryId])
if (!summary) {
throw createError({ statusCode: 404, message: '취합 보고서를 찾을 수 없습니다.' })
}
await execute(`
UPDATE wr_aggregated_report_summary SET
reviewer_id = $1,
reviewer_comment = $2,
reviewed_at = NOW(),
summary_status = 'REVIEWED',
updated_at = NOW()
WHERE summary_id = $3
`, [userId, body.reviewerComment || null, summaryId])
return { success: true }
})

View File

@@ -1,220 +0,0 @@
import { defineEventHandler, readBody, createError } from 'h3'
import { query, queryOne, execute, insertReturning } from '../../../utils/db'
import { getClientIp } from '../../../utils/ip'
import { getCurrentUserEmail } from '../../../utils/user'
import { requireAuth } from '../../../utils/session'
import OpenAI from 'openai'
interface AggregateBody {
projectIds: number[]
reportYear: number
reportWeek: number
}
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
/**
* 다중 프로젝트 취합 실행 (OpenAI 요약 포함)
* POST /api/report/summary/aggregate
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const body = await readBody<AggregateBody>(event)
const clientIp = getClientIp(event)
const userEmail = await getCurrentUserEmail(event)
if (!body.projectIds || body.projectIds.length === 0) {
throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' })
}
if (!body.reportYear || !body.reportWeek) {
throw createError({ statusCode: 400, message: '연도와 주차를 선택해주세요.' })
}
let summaryCount = 0
let totalMembers = 0
const allReportIds: number[] = []
// 각 프로젝트별로 취합 생성
for (const projectId of body.projectIds) {
// 해당 프로젝트/주차의 Task 조회 (작성자 포함)
const tasks = await query<any>(`
SELECT
t.task_id,
t.task_type,
t.task_description,
t.task_hours,
t.is_completed,
r.report_id,
r.author_id,
e.employee_name as author_name,
r.week_start_date,
r.week_end_date
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE t.project_id = $1
AND r.report_year = $2
AND r.report_week = $3
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
ORDER BY t.task_type, e.employee_name
`, [projectId, body.reportYear, body.reportWeek])
if (tasks.length === 0) continue
const reportIds = [...new Set(tasks.map(t => t.report_id))]
const weekStartDate = tasks[0].week_start_date
const weekEndDate = tasks[0].week_end_date
// 총 시간 계산
const totalWorkHours = tasks
.filter(t => t.task_type === 'WORK')
.reduce((sum, t) => sum + (parseFloat(t.task_hours) || 0), 0)
// OpenAI로 요약 생성 (금주 실적 / 차주 계획 분리)
const { workSummary, planSummary } = await generateAISummary(tasks, projectId, body.reportYear, body.reportWeek)
// 기존 취합 보고서 확인
const existing = await queryOne<any>(`
SELECT summary_id FROM wr_aggregated_report_summary
WHERE project_id = $1 AND report_year = $2 AND report_week = $3
`, [projectId, body.reportYear, body.reportWeek])
if (existing) {
// 기존 취합 업데이트
await execute(`
UPDATE wr_aggregated_report_summary
SET report_ids = $1,
member_count = $2,
total_work_hours = $3,
ai_work_summary = $4,
ai_plan_summary = $5,
ai_summary_at = NOW(),
aggregated_at = NOW(),
updated_at = NOW(),
updated_ip = $6,
updated_email = $7
WHERE summary_id = $8
`, [reportIds, reportIds.length, totalWorkHours, workSummary, planSummary, clientIp, userEmail, existing.summary_id])
} else {
// 새 취합 생성
await insertReturning<any>(`
INSERT INTO wr_aggregated_report_summary (
project_id, report_year, report_week, week_start_date, week_end_date,
report_ids, member_count, total_work_hours, ai_work_summary, ai_plan_summary, ai_summary_at,
created_ip, created_email, updated_ip, updated_email
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11, $12, $11, $12)
RETURNING summary_id
`, [
projectId, body.reportYear, body.reportWeek,
weekStartDate, weekEndDate,
reportIds, reportIds.length, totalWorkHours, workSummary, planSummary,
clientIp, userEmail
])
}
summaryCount++
totalMembers += reportIds.length
allReportIds.push(...reportIds)
}
if (summaryCount === 0) {
throw createError({ statusCode: 400, message: '취합할 보고서가 없습니다.' })
}
// 개별 보고서 상태 업데이트
const uniqueReportIds = [...new Set(allReportIds)]
await execute(`
UPDATE wr_weekly_report
SET report_status = 'AGGREGATED',
updated_at = NOW(),
updated_ip = $1,
updated_email = $2
WHERE report_id = ANY($3)
`, [clientIp, userEmail, uniqueReportIds])
return {
success: true,
summaryCount,
totalMembers: uniqueReportIds.length
}
})
// OpenAI로 금주 실적/차주 계획 분리 요약 생성
async function generateAISummary(tasks: any[], projectId: number, year: number, week: number): Promise<{ workSummary: string, planSummary: string }> {
// 프로젝트명 조회
const project = await queryOne<any>(`SELECT project_name FROM wr_project_info WHERE project_id = $1`, [projectId])
const projectName = project?.project_name || '프로젝트'
// Task를 실적/계획으로 분류
const workTasks = tasks.filter(t => t.task_type === 'WORK')
const planTasks = tasks.filter(t => t.task_type === 'PLAN')
// 금주 실적 요약
const workPrompt = `당신은 주간보고 취합 전문가입니다. 아래 금주 실적을 간결하게 요약해주세요.
## 프로젝트: ${projectName}
## 기간: ${year}${week}주차
## 금주 실적 (${workTasks.length}건)
${workTasks.map(t => `- [${t.author_name}] ${t.is_completed ? '완료' : '진행중'} | ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
## 요약 규칙
1. 완료된 작업과 진행 중인 작업을 구분하여 핵심만 요약
2. 동일/유사한 작업은 하나로 통합
3. 담당자 이름은 생략하고 내용 위주로 작성
4. 3~5줄 이내로 간결하게
5. 마크다운 리스트 형식으로 작성`
// 차주 계획 요약
const planPrompt = `당신은 주간보고 취합 전문가입니다. 아래 차주 계획을 간결하게 요약해주세요.
## 프로젝트: ${projectName}
## 기간: ${year}${week+1}주차 계획
## 차주 계획 (${planTasks.length}건)
${planTasks.map(t => `- [${t.author_name}] ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
## 요약 규칙
1. 주요 계획을 우선순위에 따라 요약
2. 동일/유사한 작업은 하나로 통합
3. 담당자 이름은 생략하고 내용 위주로 작성
4. 2~4줄 이내로 간결하게
5. 마크다운 리스트 형식으로 작성`
try {
const [workRes, planRes] = await Promise.all([
openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
{ role: 'user', content: workPrompt }
],
temperature: 0.3,
max_tokens: 500
}),
openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
{ role: 'user', content: planPrompt }
],
temperature: 0.3,
max_tokens: 500
})
])
return {
workSummary: workRes.choices[0]?.message?.content || '요약 없음',
planSummary: planRes.choices[0]?.message?.content || '요약 없음'
}
} catch (error) {
console.error('OpenAI 요약 생성 실패:', error)
return {
workSummary: '요약 생성 실패',
planSummary: '요약 생성 실패'
}
}
}

View File

@@ -1,39 +0,0 @@
import { defineEventHandler, getQuery, createError } from 'h3'
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const { year, week } = getQuery(event)
if (!year || !week) {
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
}
// 해당 주차에 제출된 보고서가 있는 프로젝트 목록
const projects = await query(`
SELECT
p.project_id,
p.project_code,
p.project_name,
COUNT(DISTINCT r.report_id) as report_count
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_project_info p ON t.project_id = p.project_id
WHERE r.report_year = $1
AND r.report_week = $2
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
GROUP BY p.project_id, p.project_code, p.project_name
ORDER BY p.project_name
`, [Number(year), Number(week)])
return {
projects: projects.map((p: any) => ({
projectId: p.project_id,
projectCode: p.project_code,
projectName: p.project_name,
reportCount: parseInt(p.report_count)
}))
}
})

View File

@@ -1,58 +0,0 @@
import { query } from '../../../utils/db'
/**
* 취합 보고서 목록
* GET /api/report/summary/list
*/
export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null
const year = queryParams.year ? parseInt(queryParams.year as string) : null
let sql = `
SELECT s.*, p.project_name, p.project_code,
e.employee_name as reviewer_name
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
LEFT JOIN wr_employee_info e ON s.reviewer_id = e.employee_id
WHERE 1=1
`
const params: any[] = []
let paramIndex = 1
if (projectId) {
sql += ` AND s.project_id = $${paramIndex++}`
params.push(projectId)
}
if (year) {
sql += ` AND s.report_year = $${paramIndex++}`
params.push(year)
}
sql += ' ORDER BY s.report_year DESC, s.report_week DESC, p.project_name'
const summaries = await query(sql, params)
return {
summaries: summaries.map((s: any) => ({
summaryId: s.summary_id,
projectId: s.project_id,
projectName: s.project_name,
projectCode: s.project_code,
reportYear: s.report_year,
reportWeek: s.report_week,
weekStartDate: s.week_start_date,
weekEndDate: s.week_end_date,
memberCount: s.member_count,
totalWorkHours: s.total_work_hours,
reviewerId: s.reviewer_id,
reviewerName: s.reviewer_name,
reviewerComment: s.reviewer_comment,
reviewedAt: s.reviewed_at,
summaryStatus: s.summary_status,
aggregatedAt: s.aggregated_at,
aiSummary: s.ai_summary,
aiSummaryAt: s.ai_summary_at
}))
}
})

View File

@@ -1,150 +0,0 @@
import { defineEventHandler, createError } from 'h3'
import { query, queryOne, execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
/**
* 기존 취합 보고서에 AI 요약 일괄 생성
* POST /api/report/summary/regenerate-ai
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
// AI 요약이 없는 취합 보고서 조회
const summaries = await query<any>(`
SELECT s.summary_id, s.project_id, s.report_year, s.report_week,
p.project_name
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
WHERE s.ai_work_summary IS NULL OR s.ai_plan_summary IS NULL
ORDER BY s.summary_id
`, [])
console.log(`AI 요약 생성 대상: ${summaries.length}`)
let successCount = 0
let errorCount = 0
for (const summary of summaries) {
try {
// 해당 프로젝트/주차의 Task 조회
const tasks = await query<any>(`
SELECT
t.task_type,
t.task_description,
t.task_hours,
t.is_completed,
e.employee_name as author_name
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE t.project_id = $1
AND r.report_year = $2
AND r.report_week = $3
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
ORDER BY t.task_type, e.employee_name
`, [summary.project_id, summary.report_year, summary.report_week])
if (tasks.length === 0) {
console.log(`Skip ${summary.summary_id}: no tasks`)
continue
}
// AI 요약 생성
const { workSummary, planSummary } = await generateAISummary(
tasks,
summary.project_name,
summary.report_year,
summary.report_week
)
// 업데이트
await execute(`
UPDATE wr_aggregated_report_summary
SET ai_work_summary = $1,
ai_plan_summary = $2,
ai_summary_at = NOW()
WHERE summary_id = $3
`, [workSummary, planSummary, summary.summary_id])
successCount++
console.log(`Generated AI summary for ${summary.project_name} (${summary.report_year}-W${summary.report_week})`)
} catch (e: any) {
console.error(`Error for summary ${summary.summary_id}:`, e.message)
errorCount++
}
}
return {
success: true,
total: summaries.length,
successCount,
errorCount
}
})
async function generateAISummary(tasks: any[], projectName: string, year: number, week: number) {
const workTasks = tasks.filter(t => t.task_type === 'WORK')
const planTasks = tasks.filter(t => t.task_type === 'PLAN')
const workPrompt = `주간보고 취합 전문가입니다. 아래 금주 실적을 간결하게 요약해주세요.
## 프로젝트: ${projectName}
## 기간: ${year}${week}주차
## 금주 실적 (${workTasks.length}건)
${workTasks.map(t => `- [${t.author_name}] ${t.is_completed ? '완료' : '진행중'} | ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
## 요약 규칙
1. 완료된 작업과 진행 중인 작업을 구분하여 핵심만 요약
2. 동일/유사한 작업은 하나로 통합
3. 담당자 이름은 생략하고 내용 위주로 작성
4. 3~5줄 이내로 간결하게
5. 마크다운 리스트 형식으로 작성`
const planPrompt = `주간보고 취합 전문가입니다. 아래 차주 계획을 간결하게 요약해주세요.
## 프로젝트: ${projectName}
## 기간: ${year}${week+1}주차 계획
## 차주 계획 (${planTasks.length}건)
${planTasks.map(t => `- [${t.author_name}] ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
## 요약 규칙
1. 주요 계획을 우선순위에 따라 요약
2. 동일/유사한 작업은 하나로 통합
3. 담당자 이름은 생략하고 내용 위주로 작성
4. 2~4줄 이내로 간결하게
5. 마크다운 리스트 형식으로 작성`
const [workRes, planRes] = await Promise.all([
openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
{ role: 'user', content: workPrompt }
],
temperature: 0.3,
max_tokens: 500
}),
openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
{ role: 'user', content: planPrompt }
],
temperature: 0.3,
max_tokens: 500
})
])
return {
workSummary: workRes.choices[0]?.message?.content || '요약 없음',
planSummary: planRes.choices[0]?.message?.content || '요약 없음'
}
}

View File

@@ -1,171 +0,0 @@
import { defineEventHandler, getQuery, createError } from 'h3'
import { query } from '../../../../utils/db'
/**
* 주차별 취합 상세 (프로젝트별 실적/계획 테이블용)
* GET /api/report/summary/week/detail?year=2026&week=1
*/
export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const year = queryParams.year ? parseInt(queryParams.year as string) : null
const week = queryParams.week ? parseInt(queryParams.week as string) : null
if (!year || !week) {
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
}
// 해당 주차 취합 보고서 목록
const summaries = await query(`
SELECT
s.summary_id,
s.project_id,
p.project_name,
p.project_code,
s.week_start_date,
s.week_end_date,
s.member_count,
s.total_work_hours,
s.ai_work_summary,
s.ai_plan_summary,
s.ai_summary_at,
s.summary_status,
s.aggregated_at
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
WHERE s.report_year = $1 AND s.report_week = $2
ORDER BY p.project_name
`, [year, week])
if (summaries.length === 0) {
throw createError({ statusCode: 404, message: '해당 주차의 취합 보고서가 없습니다.' })
}
// 프로젝트 ID 목록
const projectIds = summaries.map((s: any) => s.project_id)
// 해당 주차/프로젝트의 모든 Task 조회
const tasks = await query(`
SELECT
t.project_id,
t.task_type,
t.task_description,
t.task_hours,
t.is_completed,
r.author_id,
e.employee_name as author_name
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE r.report_year = $1
AND r.report_week = $2
AND t.project_id = ANY($3)
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
ORDER BY t.project_id, t.task_type, e.employee_name
`, [year, week, projectIds])
// Task를 프로젝트별로 그룹핑 + 프로젝트별 인원별 시간 집계 (실적/계획 통합)
const tasksByProject = new Map<number, { work: any[], plan: any[] }>()
const membersByProject = new Map<number, Map<number, { name: string, workHours: number, planHours: number }>>()
for (const task of tasks) {
// Task 그룹핑
if (!tasksByProject.has(task.project_id)) {
tasksByProject.set(task.project_id, { work: [], plan: [] })
}
const group = tasksByProject.get(task.project_id)!
const taskItem = {
description: task.task_description,
hours: parseFloat(task.task_hours) || 0,
isCompleted: task.is_completed,
authorId: task.author_id,
authorName: task.author_name
}
// 프로젝트별 인원별 시간 집계
if (!membersByProject.has(task.project_id)) {
membersByProject.set(task.project_id, new Map())
}
const members = membersByProject.get(task.project_id)!
if (!members.has(task.author_id)) {
members.set(task.author_id, { name: task.author_name, workHours: 0, planHours: 0 })
}
const hours = parseFloat(task.task_hours) || 0
const member = members.get(task.author_id)!
if (task.task_type === 'WORK') {
group.work.push(taskItem)
member.workHours += hours
} else {
group.plan.push(taskItem)
member.planHours += hours
}
}
// 전체 인원별 시간 집계
const memberHours = new Map<number, { name: string, workHours: number, planHours: number }>()
for (const task of tasks) {
const authorId = task.author_id
if (!memberHours.has(authorId)) {
memberHours.set(authorId, { name: task.author_name, workHours: 0, planHours: 0 })
}
const member = memberHours.get(authorId)!
const hours = parseFloat(task.task_hours) || 0
if (task.task_type === 'WORK') {
member.workHours += hours
} else {
member.planHours += hours
}
}
// 첫번째 summary에서 날짜 정보 추출
const weekInfo = {
reportYear: year,
reportWeek: week,
weekStartDate: summaries[0].week_start_date,
weekEndDate: summaries[0].week_end_date,
totalProjects: summaries.length,
totalWorkHours: summaries.reduce((sum: number, s: any) => sum + (parseFloat(s.total_work_hours) || 0), 0)
}
// 프로젝트별 데이터 구성
const projects = summaries.map((s: any) => {
const taskGroup = tasksByProject.get(s.project_id) || { work: [], plan: [] }
const projectMembers = membersByProject.get(s.project_id)
// 프로젝트별 인원 시간 배열 (실적+계획 통합)
const memberHoursList = projectMembers
? Array.from(projectMembers.values()).sort((a, b) => (b.workHours + b.planHours) - (a.workHours + a.planHours))
: []
return {
summaryId: s.summary_id,
projectId: s.project_id,
projectName: s.project_name,
projectCode: s.project_code,
memberCount: s.member_count,
totalWorkHours: parseFloat(s.total_work_hours) || 0,
aiWorkSummary: s.ai_work_summary,
aiPlanSummary: s.ai_plan_summary,
aiSummaryAt: s.ai_summary_at,
workTasks: taskGroup.work,
planTasks: taskGroup.plan,
memberHours: memberHoursList // { name, workHours, planHours }
}
})
// 전체 인원별 시간 배열로 변환
const members = Array.from(memberHours.entries()).map(([id, m]) => ({
employeeId: id,
employeeName: m.name,
workHours: m.workHours,
planHours: m.planHours,
availableHours: Math.max(0, 40 - m.planHours)
})).sort((a, b) => b.availableHours - a.availableHours)
return {
weekInfo,
projects,
members
}
})

View File

@@ -1,76 +0,0 @@
import { defineEventHandler, getQuery } from 'h3'
import { query } from '../../../utils/db'
/**
* 주차별 취합 목록
* GET /api/report/summary/weekly-list
*/
export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const year = queryParams.year ? parseInt(queryParams.year as string) : new Date().getFullYear()
// 주차별로 그룹핑
const rows = await query(`
SELECT
s.report_year,
s.report_week,
COUNT(DISTINCT s.project_id) as project_count,
SUM(s.member_count) as total_members,
SUM(COALESCE(s.total_work_hours, 0)) as total_work_hours,
MAX(s.aggregated_at) as latest_aggregated_at,
ARRAY_AGG(DISTINCT p.project_name ORDER BY p.project_name) as project_names
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
WHERE s.report_year = $1
GROUP BY s.report_year, s.report_week
ORDER BY s.report_week DESC
`, [year])
return {
weeks: rows.map((r: any) => {
const { monday, sunday } = getWeekDates(r.report_year, r.report_week)
return {
reportYear: r.report_year,
reportWeek: r.report_week,
weekStartDate: monday,
weekEndDate: sunday,
projectCount: parseInt(r.project_count),
totalMembers: parseInt(r.total_members),
totalWorkHours: parseFloat(r.total_work_hours) || 0,
latestAggregatedAt: r.latest_aggregated_at,
projects: r.project_names || []
}
})
}
})
// ISO 8601 주차 기준 월요일~일요일 계산
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
// 해당 연도의 1월 4일이 속한 주가 1주차 (ISO 8601)
const jan4 = new Date(year, 0, 4)
const jan4DayOfWeek = jan4.getDay() || 7 // 일요일=7
// 1주차의 월요일
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek + 1)
// 요청된 주차의 월요일
const monday = new Date(week1Monday)
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
// 일요일
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
return {
monday: formatDate(monday),
sunday: formatDate(sunday)
}
}
function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}

View File

@@ -1,48 +0,0 @@
import { query, execute } from '../../../../utils/db'
import { requireAuth } from '../../../../utils/session'
const ADMIN_EMAIL = 'coziny@gmail.com'
/**
* 주간보고 삭제
* DELETE /api/report/weekly/[id]/delete
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const reportId = getRouterParam(event, 'id')
if (!reportId) {
throw createError({ statusCode: 400, message: '보고서 ID가 필요합니다.' })
}
// 현재 사용자 정보 조회
const currentUser = await query<any>(`
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
`, [userId])
const isAdmin = currentUser[0]?.employee_email === ADMIN_EMAIL
// 보고서 정보 조회
const report = await query<any>(`
SELECT report_id, author_id FROM wr_weekly_report WHERE report_id = $1
`, [reportId])
if (!report[0]) {
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
}
// 권한 체크: 본인 또는 관리자만 삭제 가능
if (report[0].author_id !== userId && !isAdmin) {
throw createError({ statusCode: 403, message: '삭제 권한이 없습니다.' })
}
// 프로젝트 실적 먼저 삭제
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
// 주간보고 삭제
await execute(`DELETE FROM wr_weekly_report WHERE report_id = $1`, [reportId])
return {
success: true,
message: '주간보고가 삭제되었습니다.'
}
})

View File

@@ -1,136 +0,0 @@
import { query, queryOne } from '../../../../utils/db'
import { requireAuth } from '../../../../utils/session'
/**
* 주간보고 상세 조회
* GET /api/report/weekly/[id]/detail
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const reportId = getRouterParam(event, 'id')
// 마스터 조회
const report = await queryOne<any>(`
SELECT
r.*,
e.employee_name as author_name,
e.employee_email as author_email
FROM wr_weekly_report r
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE r.report_id = $1
`, [reportId])
if (!report) {
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
}
// 같은 주차의 이전/다음 보고서 조회
const prevReport = await queryOne<any>(`
SELECT r.report_id, e.employee_name
FROM wr_weekly_report r
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE r.report_year = $1 AND r.report_week = $2 AND r.report_id < $3
ORDER BY r.report_id DESC
LIMIT 1
`, [report.report_year, report.report_week, reportId])
const nextReport = await queryOne<any>(`
SELECT r.report_id, e.employee_name
FROM wr_weekly_report r
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE r.report_year = $1 AND r.report_week = $2 AND r.report_id > $3
ORDER BY r.report_id ASC
LIMIT 1
`, [report.report_year, report.report_week, reportId])
// Task 조회
const tasks = await query<any>(`
SELECT
t.task_id,
t.project_id,
p.project_code,
p.project_name,
t.task_type,
t.task_description,
t.task_hours,
t.is_completed
FROM wr_weekly_report_task t
JOIN wr_project_info p ON t.project_id = p.project_id
WHERE t.report_id = $1
ORDER BY t.project_id, t.task_type, t.task_id
`, [reportId])
// 프로젝트별로 그룹핑
const projectMap = new Map<number, any>()
for (const task of tasks) {
if (!projectMap.has(task.project_id)) {
projectMap.set(task.project_id, {
projectId: task.project_id,
projectCode: task.project_code,
projectName: task.project_name,
workTasks: [],
planTasks: []
})
}
const proj = projectMap.get(task.project_id)
const taskItem = {
taskId: task.task_id,
description: task.task_description,
hours: parseFloat(task.task_hours) || 0,
isCompleted: task.is_completed
}
if (task.task_type === 'WORK') {
proj.workTasks.push(taskItem)
} else {
proj.planTasks.push(taskItem)
}
}
return {
report: {
reportId: report.report_id,
authorId: report.author_id,
authorName: report.author_name,
authorEmail: report.author_email,
reportYear: report.report_year,
reportWeek: report.report_week,
weekStartDate: formatDateOnly(report.week_start_date),
weekEndDate: formatDateOnly(report.week_end_date),
issueDescription: report.issue_description,
vacationDescription: report.vacation_description,
remarkDescription: report.remark_description,
reportStatus: report.report_status,
submittedAt: report.submitted_at,
createdAt: report.created_at,
updatedAt: report.updated_at,
aiReview: report.ai_review,
aiReviewAt: report.ai_review_at
},
prevReport: prevReport ? { reportId: prevReport.report_id, authorName: prevReport.employee_name } : null,
nextReport: nextReport ? { reportId: nextReport.report_id, authorName: nextReport.employee_name } : null,
projects: Array.from(projectMap.values()),
tasks: tasks.map((t: any) => ({
taskId: t.task_id,
projectId: t.project_id,
projectCode: t.project_code,
projectName: t.project_name,
taskType: t.task_type,
taskDescription: t.task_description,
taskHours: parseFloat(t.task_hours) || 0,
isCompleted: t.is_completed
}))
}
})
// 날짜를 YYYY-MM-DD 형식으로 변환 (타임존 보정)
function formatDateOnly(date: Date | string | null): string {
if (!date) return ''
const d = new Date(date)
const kstOffset = 9 * 60 * 60 * 1000
const kstDate = new Date(d.getTime() + kstOffset)
return kstDate.toISOString().split('T')[0]
}

View File

@@ -1,45 +0,0 @@
import { execute, queryOne } from '../../../../utils/db'
import { getClientIp } from '../../../../utils/ip'
import { getCurrentUserEmail } from '../../../../utils/user'
import { requireAuth } from '../../../../utils/session'
/**
* 주간보고 제출
* POST /api/report/weekly/[id]/submit
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const reportId = getRouterParam(event, 'id')
const clientIp = getClientIp(event)
const userEmail = await getCurrentUserEmail(event)
// 보고서 조회 및 권한 확인
const report = await queryOne<any>(`
SELECT * FROM wr_weekly_report WHERE report_id = $1
`, [reportId])
if (!report) {
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
}
if (report.author_id !== userId) {
throw createError({ statusCode: 403, message: '본인의 보고서만 제출할 수 있습니다.' })
}
if (report.report_status !== 'DRAFT') {
throw createError({ statusCode: 400, message: '이미 제출된 보고서입니다.' })
}
await execute(`
UPDATE wr_weekly_report SET
report_status = 'SUBMITTED',
submitted_at = NOW(),
updated_at = NOW(),
updated_ip = $1,
updated_email = $2
WHERE report_id = $3
`, [clientIp, userEmail, reportId])
return { success: true }
})

View File

@@ -1,104 +0,0 @@
import { query, execute, queryOne } from '../../../../utils/db'
import { requireAuth } from '../../../../utils/session'
const ADMIN_EMAIL = 'coziny@gmail.com'
/**
* 주간보고 수정
* PUT /api/report/weekly/[id]/update
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const reportId = getRouterParam(event, 'id')
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
const user = await queryOne<any>(`SELECT employee_email FROM wr_employee_info WHERE employee_id = $1`, [userId])
const userEmail = user?.employee_email || ''
const isAdmin = userEmail === ADMIN_EMAIL
// 보고서 조회 및 권한 체크
const report = await queryOne<any>(`
SELECT report_id, author_id, report_status FROM wr_weekly_report WHERE report_id = $1
`, [reportId])
if (!report) {
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
}
// 관리자가 아니면 본인 보고서만 수정 가능
if (!isAdmin && report.author_id !== userId) {
throw createError({ statusCode: 403, message: '본인의 보고서만 수정할 수 있습니다.' })
}
// 취합완료된 보고서는 수정 불가 (관리자도)
if (report.report_status === 'AGGREGATED') {
throw createError({ statusCode: 400, message: '취합완료된 보고서는 수정할 수 없습니다.' })
}
const body = await readBody<{
reportYear?: number
reportWeek?: number
weekStartDate?: string
weekEndDate?: string
tasks: {
projectId: number
taskType: 'WORK' | 'PLAN'
taskDescription: string
taskHours: number
isCompleted?: boolean
}[]
issueDescription?: string
vacationDescription?: string
remarkDescription?: string
}>(event)
if (!body.tasks || body.tasks.length === 0) {
throw createError({ statusCode: 400, message: '최소 1개 이상의 Task가 필요합니다.' })
}
// 마스터 수정
await execute(`
UPDATE wr_weekly_report SET
report_year = COALESCE($1, report_year),
report_week = COALESCE($2, report_week),
week_start_date = COALESCE($3, week_start_date),
week_end_date = COALESCE($4, week_end_date),
issue_description = $5,
vacation_description = $6,
remark_description = $7,
updated_at = NOW(),
updated_ip = $8,
updated_email = $9
WHERE report_id = $10
`, [
body.reportYear || null,
body.reportWeek || null,
body.weekStartDate || null,
body.weekEndDate || null,
body.issueDescription || null,
body.vacationDescription || null,
body.remarkDescription || null,
clientIp, userEmail, reportId
])
// 기존 Task 삭제 후 재등록
await execute(`DELETE FROM wr_weekly_report_task WHERE report_id = $1`, [reportId])
for (const task of body.tasks) {
await execute(`
INSERT INTO wr_weekly_report_task (
report_id, project_id, task_type, task_description, task_hours, is_completed,
created_ip, created_email, updated_ip, updated_email
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)
`, [
reportId, task.projectId, task.taskType, task.taskDescription, task.taskHours || 0,
task.taskType === 'WORK' ? (task.isCompleted !== false) : null,
clientIp, userEmail
])
}
return {
success: true,
message: '주간보고가 수정되었습니다.'
}
})

View File

@@ -1,131 +0,0 @@
import { defineEventHandler, getQuery, createError } from 'h3'
import { query } from '../../../utils/db'
const ADMIN_EMAIL = 'admin@turbosoft.co.kr'
export default defineEventHandler(async (event) => {
const userEmail = event.node.req.headers['x-user-email'] as string
if (!userEmail) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
// 관리자만 취합 가능
if (userEmail !== ADMIN_EMAIL) {
throw createError({ statusCode: 403, message: '관리자만 취합할 수 있습니다.' })
}
const { year, week, projectIds } = getQuery(event)
if (!year || !week) {
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
}
// 프로젝트 ID 파싱
let projectIdList: number[] = []
if (projectIds) {
projectIdList = String(projectIds).split(',').map(Number).filter(n => !isNaN(n))
}
// 해당 주차의 모든 프로젝트별 Task 조회
let projectFilter = ''
const params: any[] = [Number(year), Number(week)]
if (projectIdList.length > 0) {
projectFilter = `AND t.project_id = ANY($3)`
params.push(projectIdList)
}
const tasks = await query(`
SELECT
t.task_id,
t.project_id,
p.project_code,
p.project_name,
t.task_type,
t.task_description,
t.task_hours,
t.is_completed,
r.report_id,
r.author_id,
e.employee_name as author_name
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_project_info p ON t.project_id = p.project_id
JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE r.report_year = $1 AND r.report_week = $2
${projectFilter}
ORDER BY p.project_name, t.task_type, e.employee_name
`, params)
// 프로젝트별로 그룹핑
const projectMap = new Map<number, {
projectId: number
projectCode: string
projectName: string
workTasks: any[]
planTasks: any[]
totalWorkHours: number
totalPlanHours: number
}>()
for (const task of tasks) {
if (!projectMap.has(task.project_id)) {
projectMap.set(task.project_id, {
projectId: task.project_id,
projectCode: task.project_code,
projectName: task.project_name,
workTasks: [],
planTasks: [],
totalWorkHours: 0,
totalPlanHours: 0
})
}
const proj = projectMap.get(task.project_id)!
const taskItem = {
taskId: task.task_id,
description: task.task_description,
hours: parseFloat(task.task_hours) || 0,
isCompleted: task.is_completed,
authorName: task.author_name
}
if (task.task_type === 'WORK') {
proj.workTasks.push(taskItem)
proj.totalWorkHours += taskItem.hours
} else {
proj.planTasks.push(taskItem)
proj.totalPlanHours += taskItem.hours
}
}
// 해당 주차에 보고서가 있는 모든 프로젝트 목록
const allProjects = await query(`
SELECT DISTINCT p.project_id, p.project_code, p.project_name
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_project_info p ON t.project_id = p.project_id
WHERE r.report_year = $1 AND r.report_week = $2
ORDER BY p.project_name
`, [Number(year), Number(week)])
// 해당 주차 보고서 수
const reportCount = await query(`
SELECT COUNT(DISTINCT report_id) as cnt
FROM wr_weekly_report
WHERE report_year = $1 AND report_week = $2
`, [Number(year), Number(week)])
return {
year: Number(year),
week: Number(week),
reportCount: parseInt(reportCount[0]?.cnt || '0'),
availableProjects: allProjects.map((p: any) => ({
projectId: p.project_id,
projectCode: p.project_code,
projectName: p.project_name
})),
projects: Array.from(projectMap.values())
}
})

View File

@@ -1,87 +0,0 @@
import { query, execute, queryOne } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 주간보고 작성
* POST /api/report/weekly/create
*/
export default defineEventHandler(async (event) => {
// 세션 기반 인증 사용 (레거시 쿠키 대신)
const userId = await requireAuth(event)
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
const user = await queryOne<any>(`SELECT employee_email FROM wr_employee_info WHERE employee_id = $1`, [userId])
const userEmail = user?.employee_email || ''
const body = await readBody<{
reportYear: number
reportWeek: number
weekStartDate: string
weekEndDate: string
tasks: {
projectId: number
taskType: 'WORK' | 'PLAN'
taskDescription: string
taskHours: number
isCompleted?: boolean
}[]
issueDescription?: string
vacationDescription?: string
remarkDescription?: string
}>(event)
// 필수값 체크
if (!body.reportYear || !body.reportWeek || !body.weekStartDate || !body.weekEndDate) {
throw createError({ statusCode: 400, message: '주차 정보가 필요합니다.' })
}
if (!body.tasks || body.tasks.length === 0) {
throw createError({ statusCode: 400, message: '최소 1개 이상의 Task가 필요합니다.' })
}
// 중복 체크
const existing = await queryOne<any>(`
SELECT report_id FROM wr_weekly_report
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
`, [userId, body.reportYear, body.reportWeek])
if (existing) {
throw createError({ statusCode: 409, message: '해당 주차에 이미 작성된 보고서가 있습니다.' })
}
// 마스터 등록
const result = await queryOne<any>(`
INSERT INTO wr_weekly_report (
author_id, report_year, report_week, week_start_date, week_end_date,
issue_description, vacation_description, remark_description,
report_status, created_ip, created_email, updated_ip, updated_email
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'DRAFT', $9, $10, $9, $10)
RETURNING report_id
`, [
userId, body.reportYear, body.reportWeek, body.weekStartDate, body.weekEndDate,
body.issueDescription || null, body.vacationDescription || null, body.remarkDescription || null,
clientIp, userEmail
])
const reportId = result.report_id
// Task 등록
for (const task of body.tasks) {
await execute(`
INSERT INTO wr_weekly_report_task (
report_id, project_id, task_type, task_description, task_hours, is_completed,
created_ip, created_email, updated_ip, updated_email
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)
`, [
reportId, task.projectId, task.taskType, task.taskDescription, task.taskHours || 0,
task.taskType === 'WORK' ? (task.isCompleted !== false) : null,
clientIp, userEmail
])
}
return {
success: true,
reportId,
message: '주간보고가 작성되었습니다.'
}
})

View File

@@ -1,44 +0,0 @@
import { query } from '../../../utils/db'
import { getWeekInfo, formatWeekString } from '../../../utils/week-calc'
import { requireAuth } from '../../../utils/session'
/**
* 이번 주 보고서 현황 조회
* GET /api/report/weekly/current-week
*/
export default defineEventHandler(async (event) => {
const userId = await requireAuth(event)
const weekInfo = getWeekInfo()
// 이번 주 내 보고서 목록
const reports = await query(`
SELECT r.*, p.project_name, p.project_code
FROM wr_weekly_report_detail r
JOIN wr_project_info p ON r.project_id = p.project_id
WHERE r.author_id = $1 AND r.report_year = $2 AND r.report_week = $3
ORDER BY p.project_name
`, [userId, weekInfo.year, weekInfo.week])
return {
weekInfo: {
year: weekInfo.year,
week: weekInfo.week,
weekString: formatWeekString(weekInfo.year, weekInfo.week),
startDate: weekInfo.startDateStr,
endDate: weekInfo.endDateStr
},
reports: reports.map((r: any) => ({
reportId: r.report_id,
projectId: r.project_id,
projectName: r.project_name,
projectCode: r.project_code,
reportStatus: r.report_status,
workDescription: r.work_description,
planDescription: r.plan_description,
issueDescription: r.issue_description,
workHours: r.work_hours,
updatedAt: r.updated_at
}))
}
})

View File

@@ -1,171 +0,0 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
const ADMIN_EMAIL = 'coziny@gmail.com'
/**
* 주간보고 목록 조회 (필터링 지원)
* GET /api/report/weekly/list
*
* Query params:
* - authorId: 작성자 ID
* - projectId: 프로젝트 ID
* - year: 연도
* - weekFrom: 시작 주차
* - weekTo: 종료 주차
* - startDate: 시작일 (YYYY-MM-DD)
* - endDate: 종료일 (YYYY-MM-DD)
* - status: 상태 (DRAFT, SUBMITTED, AGGREGATED)
* - viewAll: 전체 조회 (관리자만)
* - limit: 조회 개수 (기본 100)
*/
export default defineEventHandler(async (event) => {
// 세션 기반 인증 사용
const userId = await requireAuth(event)
// 현재 사용자 정보 조회 (관리자 여부 확인)
const currentUser = await query<any>(`
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
`, [userId])
const isAdmin = currentUser[0]?.employee_email === ADMIN_EMAIL
const q = getQuery(event)
const limit = parseInt(q.limit as string) || 100
// 필터 조건 구성
const conditions: string[] = []
const params: any[] = []
let paramIndex = 1
// 작성자 필터 (선택된 경우에만 적용)
if (q.authorId) {
conditions.push(`r.author_id = $${paramIndex++}`)
params.push(q.authorId)
}
// 프로젝트 필터
if (q.projectId) {
conditions.push(`EXISTS (
SELECT 1 FROM wr_weekly_report_project wrp
WHERE wrp.report_id = r.report_id AND wrp.project_id = $${paramIndex++}
)`)
params.push(q.projectId)
}
// 연도 필터
if (q.year) {
conditions.push(`r.report_year = $${paramIndex++}`)
params.push(q.year)
}
// 주차 필터 (단일)
if (q.week) {
conditions.push(`r.report_week = $${paramIndex++}`)
params.push(q.week)
}
// 특정 주차 이전 필터 (beforeYear, beforeWeek)
if (q.beforeYear && q.beforeWeek) {
conditions.push(`(r.report_year < $${paramIndex} OR (r.report_year = $${paramIndex} AND r.report_week < $${paramIndex + 1}))`)
params.push(q.beforeYear)
paramIndex++
params.push(q.beforeWeek)
paramIndex++
}
// 주차 범위 필터
if (q.weekFrom) {
conditions.push(`r.report_week >= $${paramIndex++}`)
params.push(q.weekFrom)
}
if (q.weekTo) {
conditions.push(`r.report_week <= $${paramIndex++}`)
params.push(q.weekTo)
}
// 날짜 범위 필터
if (q.startDate) {
conditions.push(`r.week_start_date >= $${paramIndex++}`)
params.push(q.startDate)
}
if (q.endDate) {
conditions.push(`r.week_end_date <= $${paramIndex++}`)
params.push(q.endDate)
}
// 상태 필터
if (q.status) {
conditions.push(`r.report_status = $${paramIndex++}`)
params.push(q.status)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
params.push(limit)
const reports = await query<any>(`
SELECT
r.report_id,
r.author_id,
e.employee_name as author_name,
e.employee_email as author_email,
r.report_year,
r.report_week,
r.week_start_date,
r.week_end_date,
r.issue_description,
r.vacation_description,
r.report_status,
r.submitted_at,
r.created_at,
r.updated_at,
r.ai_review,
(SELECT COUNT(DISTINCT project_id) FROM wr_weekly_report_task WHERE report_id = r.report_id) as project_count,
(SELECT string_agg(DISTINCT p.project_name, ', ')
FROM wr_weekly_report_task t
JOIN wr_project_info p ON t.project_id = p.project_id
WHERE t.report_id = r.report_id) as project_names,
(SELECT COALESCE(SUM(task_hours), 0) FROM wr_weekly_report_task WHERE report_id = r.report_id AND task_type = 'WORK') as total_work_hours,
(SELECT COALESCE(SUM(task_hours), 0) FROM wr_weekly_report_task WHERE report_id = r.report_id AND task_type = 'PLAN') as total_plan_hours
FROM wr_weekly_report r
JOIN wr_employee_info e ON r.author_id = e.employee_id
${whereClause}
ORDER BY r.report_year DESC, r.report_week DESC, e.employee_name
LIMIT $${paramIndex}
`, params)
return {
isAdmin,
reports: reports.map((r: any) => ({
reportId: r.report_id,
authorId: r.author_id,
authorName: r.author_name,
authorEmail: r.author_email,
reportYear: r.report_year,
reportWeek: r.report_week,
weekStartDate: formatDateOnly(r.week_start_date),
weekEndDate: formatDateOnly(r.week_end_date),
issueDescription: r.issue_description,
vacationDescription: r.vacation_description,
reportStatus: r.report_status,
submittedAt: r.submitted_at,
createdAt: r.created_at,
updatedAt: r.updated_at,
aiReview: r.ai_review,
projectCount: parseInt(r.project_count),
projectNames: r.project_names,
totalWorkHours: parseFloat(r.total_work_hours) || 0,
totalPlanHours: parseFloat(r.total_plan_hours) || 0
}))
}
})
// 날짜를 YYYY-MM-DD 형식으로 변환 (타임존 보정)
function formatDateOnly(date: Date | string | null): string {
if (!date) return ''
const d = new Date(date)
// 한국 시간 기준으로 날짜만 추출
const kstOffset = 9 * 60 * 60 * 1000
const kstDate = new Date(d.getTime() + kstOffset)
return kstDate.toISOString().split('T')[0]
}