From 185161db16ea9ad6a2d41c8e50d954ef62f6498a Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Mon, 5 Jan 2026 02:00:13 +0900 Subject: [PATCH] =?UTF-8?q?1=E3=85=8A=E3=85=8F=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/admin/bulk-register.post.ts | 263 ++++---- backend/api/admin/parse-image.post.ts | 169 +++++ backend/api/admin/parse-report.post.ts | 35 +- backend/api/project/my-projects.get.ts | 9 +- backend/api/report/review.post.ts | 167 +++++ backend/api/report/summary/[id]/detail.get.ts | 82 ++- backend/api/report/summary/aggregate.post.ts | 250 ++++++-- .../report/summary/available-projects.get.ts | 42 ++ backend/api/report/summary/list.get.ts | 40 +- .../api/report/summary/regenerate-ai.post.ts | 152 +++++ backend/api/report/summary/week/detail.get.ts | 171 +++++ backend/api/report/summary/weekly-list.get.ts | 44 ++ .../api/report/weekly/[id]/delete.delete.ts | 50 ++ backend/api/report/weekly/[id]/detail.get.ts | 106 +++- backend/api/report/weekly/[id]/update.put.ts | 128 ++-- backend/api/report/weekly/aggregate.get.ts | 131 ++++ backend/api/report/weekly/create.post.ts | 114 ++-- backend/api/report/weekly/list.get.ts | 132 +++- backend/utils/openai.ts | 220 +++++-- frontend/admin/bulk-import.vue | 543 +++++++++++++--- frontend/report/summary/[id].vue | 65 +- frontend/report/summary/[year]/[week].vue | 426 +++++++++++++ frontend/report/summary/index.vue | 243 ++++--- frontend/report/weekly/[id].vue | 595 +++++++++++++++--- frontend/report/weekly/aggregate.vue | 294 +++++++++ frontend/report/weekly/index.vue | 205 +++++- frontend/report/weekly/write.vue | 438 ++++++++++--- migrate-completed.mjs | 31 + package-lock.json | 22 + package.json | 1 + 30 files changed, 4331 insertions(+), 837 deletions(-) create mode 100644 backend/api/admin/parse-image.post.ts create mode 100644 backend/api/report/review.post.ts create mode 100644 backend/api/report/summary/available-projects.get.ts create mode 100644 backend/api/report/summary/regenerate-ai.post.ts create mode 100644 backend/api/report/summary/week/detail.get.ts create mode 100644 backend/api/report/summary/weekly-list.get.ts create mode 100644 backend/api/report/weekly/[id]/delete.delete.ts create mode 100644 backend/api/report/weekly/aggregate.get.ts create mode 100644 frontend/report/summary/[year]/[week].vue create mode 100644 frontend/report/weekly/aggregate.vue create mode 100644 migrate-completed.mjs diff --git a/backend/api/admin/bulk-register.post.ts b/backend/api/admin/bulk-register.post.ts index 51dc5f9..e74eb13 100644 --- a/backend/api/admin/bulk-register.post.ts +++ b/backend/api/admin/bulk-register.post.ts @@ -1,29 +1,27 @@ -import { query, queryOne, insertReturning, execute } from '../../utils/db' -import { getClientIp } from '../../utils/ip' +import { query, execute, queryOne } from '../../utils/db' const ADMIN_EMAIL = 'coziny@gmail.com' +interface TaskInput { + description: string + hours: number +} + interface ProjectInput { - projectId: number | null // null이면 신규 생성 + projectId: number | null projectName: string - workDescription: string | null - planDescription: string | null + workTasks: TaskInput[] + planTasks: TaskInput[] } interface ReportInput { - employeeId: number + employeeId: number | null + employeeName: string + employeeEmail: string projects: ProjectInput[] - issueDescription: string | null - vacationDescription: string | null - remarkDescription: string | null -} - -interface BulkRegisterBody { - reportYear: number - reportWeek: number - weekStartDate: string - weekEndDate: string - reports: ReportInput[] + issueDescription?: string + vacationDescription?: string + remarkDescription?: string } /** @@ -37,167 +35,152 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) } - const currentUser = await queryOne(` + const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown' + + const currentUser = await query(` SELECT employee_email FROM wr_employee_info WHERE employee_id = $1 `, [userId]) - if (!currentUser || currentUser.employee_email !== ADMIN_EMAIL) { + if (!currentUser[0] || currentUser[0].employee_email !== ADMIN_EMAIL) { throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' }) } - const body = await readBody(event) - const clientIp = getClientIp(event) + const adminEmail = currentUser[0].employee_email - if (!body.reports || body.reports.length === 0) { - throw createError({ statusCode: 400, message: '등록할 보고서가 없습니다.' }) - } + 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 { - // 1. 프로젝트 처리 (신규 생성 또는 기존 사용) - const projectIds: number[] = [] + let employeeId = report.employeeId + let isNewEmployee = false + const newProjects: string[] = [] + // 신규 직원 생성 + if (!employeeId && report.employeeName && report.employeeEmail) { + const newEmp = await queryOne(` + 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(` + 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(` + 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) { - // 신규 프로젝트 생성 + // 신규 프로젝트 생성 + if (!projectId && proj.projectName) { const year = new Date().getFullYear() - const lastProject = await queryOne(` - SELECT project_code FROM wr_project_info - WHERE project_code LIKE $1 - ORDER BY project_code DESC LIMIT 1 + const codeResult = await queryOne(` + 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')}` - let nextNum = 1 - if (lastProject?.project_code) { - const lastNum = parseInt(lastProject.project_code.split('-')[1]) || 0 - nextNum = lastNum + 1 - } - const newCode = `${year}-${String(nextNum).padStart(3, '0')}` - - const newProject = await insertReturning(` - INSERT INTO wr_project_info ( - project_code, project_name, project_type, - created_ip, created_email, updated_ip, updated_email - ) VALUES ($1, $2, 'SI', $3, $4, $3, $4) + const newProj = await queryOne(` + 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 - `, [newCode, proj.projectName, clientIp, ADMIN_EMAIL]) - - projectId = newProject.project_id + `, [projectCode, proj.projectName, clientIp, adminEmail]) + projectId = newProj.project_id + newProjects.push(proj.projectName) } - projectIds.push(projectId) - } - - // 2. 기존 주간보고 확인 (덮어쓰기) - const existingReport = await queryOne(` - SELECT report_id FROM wr_weekly_report - WHERE author_id = $1 AND report_year = $2 AND report_week = $3 - `, [report.employeeId, body.reportYear, body.reportWeek]) - - let reportId: number - - if (existingReport) { - // 기존 보고서 업데이트 - reportId = existingReport.report_id + if (!projectId) continue - // 기존 프로젝트 실적 삭제 - await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId]) + // 금주실적 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]) + } - // 마스터 업데이트 - await execute(` - UPDATE wr_weekly_report SET - issue_description = $1, - vacation_description = $2, - remark_description = $3, - report_status = 'SUBMITTED', - submitted_at = NOW(), - updated_at = NOW(), - updated_ip = $4, - updated_email = $5 - WHERE report_id = $6 - `, [ - report.issueDescription, - report.vacationDescription, - report.remarkDescription, - clientIp, - ADMIN_EMAIL, - reportId - ]) - } else { - // 신규 보고서 생성 - const newReport = await insertReturning(` - 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 - `, [ - report.employeeId, - body.reportYear, - body.reportWeek, - body.weekStartDate, - body.weekEndDate, - report.issueDescription, - report.vacationDescription, - report.remarkDescription, - clientIp, - ADMIN_EMAIL - ]) - reportId = newReport.report_id + // 차주계획 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]) + } } - // 3. 프로젝트별 실적 등록 - for (let i = 0; i < report.projects.length; i++) { - const proj = report.projects[i] - const projectId = projectIds[i] - - await execute(` - INSERT INTO wr_weekly_report_project ( - report_id, project_id, work_description, plan_description, - created_ip, created_email, updated_ip, updated_email - ) VALUES ($1, $2, $3, $4, $5, $6, $5, $6) - `, [ - reportId, - projectId, - proj.workDescription, - proj.planDescription, - clientIp, - ADMIN_EMAIL - ]) - } - - // 직원 정보 조회 - const employee = await queryOne(` - SELECT employee_name FROM wr_employee_info WHERE employee_id = $1 - `, [report.employeeId]) - results.push({ success: true, - employeeId: report.employeeId, - employeeName: employee?.employee_name, + employeeId, + employeeName: report.employeeName, + employeeEmail: report.employeeEmail, reportId, - isUpdate: !!existingReport + isUpdate, + isNewEmployee, + newProjects }) - } catch (err: any) { + } catch (e: any) { results.push({ success: false, - employeeId: report.employeeId, - error: err.message + employeeName: report.employeeName, + employeeEmail: report.employeeEmail, + error: e.message }) } } return { - success: true, - totalCount: body.reports.length, + totalCount: results.length, successCount: results.filter(r => r.success).length, results } diff --git a/backend/api/admin/parse-image.post.ts b/backend/api/admin/parse-image.post.ts new file mode 100644 index 0000000..a5f31b0 --- /dev/null +++ b/backend/api/admin/parse-image.post.ts @@ -0,0 +1,169 @@ +import { query } from '../../utils/db' +import { callOpenAIVision, REPORT_PARSE_SYSTEM_PROMPT } from '../../utils/openai' + +const ADMIN_EMAIL = 'coziny@gmail.com' + +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) => { + // 관리자 권한 체크 + const userId = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + const currentUser = await query(` + SELECT employee_email FROM wr_employee_info WHERE employee_id = $1 + `, [userId]) + + if (!currentUser[0] || currentUser[0].employee_email !== ADMIN_EMAIL) { + throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' }) + } + + 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(` + SELECT employee_id, employee_name, employee_email + FROM wr_employee_info + WHERE is_active = true + `) + + // 기존 프로젝트 목록 조회 + const projects = await query(` + 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 + })) + } +}) diff --git a/backend/api/admin/parse-report.post.ts b/backend/api/admin/parse-report.post.ts index 814280d..1de98af 100644 --- a/backend/api/admin/parse-report.post.ts +++ b/backend/api/admin/parse-report.post.ts @@ -3,10 +3,15 @@ import { callOpenAI, buildParseReportPrompt } from '../../utils/openai' const ADMIN_EMAIL = 'coziny@gmail.com' +interface ParsedTask { + description: string + hours: number +} + interface ParsedProject { projectName: string - workDescription: string | null - planDescription: string | null + workTasks: ParsedTask[] + planTasks: ParsedTask[] } interface ParsedReport { @@ -62,6 +67,28 @@ export default defineEventHandler(async (event) => { 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(` SELECT employee_id, employee_name, employee_email @@ -76,7 +103,7 @@ export default defineEventHandler(async (event) => { WHERE project_status != 'COMPLETED' `) - // 직원 매칭 + // 직원 및 프로젝트 매칭 const matchedReports = parsed.reports.map(report => { // 이메일로 정확 매칭 시도 let matchedEmployee = null @@ -114,6 +141,7 @@ export default defineEventHandler(async (event) => { matchedEmployeeName: matchedEmployee?.employee_name || null, matchedEmployeeEmail: matchedEmployee?.employee_email || null, isEmployeeMatched: !!matchedEmployee, + isNewEmployee: !matchedEmployee && !!report.employeeEmail, projects: matchedProjects } }) @@ -127,7 +155,6 @@ export default defineEventHandler(async (event) => { weekEndDate: parsed.weekEndDate, reports: matchedReports }, - // 선택용 목록 employees: employees.map((e: any) => ({ employeeId: e.employee_id, employeeName: e.employee_name, diff --git a/backend/api/project/my-projects.get.ts b/backend/api/project/my-projects.get.ts index 508577c..b837d48 100644 --- a/backend/api/project/my-projects.get.ts +++ b/backend/api/project/my-projects.get.ts @@ -13,9 +13,14 @@ export default defineEventHandler(async (event) => { // 내가 주간보고를 작성한 프로젝트 + 전체 활성 프로젝트 const projects = await query(` SELECT DISTINCT p.*, - CASE WHEN r.author_id IS NOT NULL THEN true ELSE false END as has_my_report + CASE WHEN t.project_id IS NOT NULL THEN true ELSE false END as has_my_report FROM wr_project_info p - LEFT JOIN wr_weekly_report_detail r ON p.project_id = r.project_id AND r.author_id = $1 + 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 `, [parseInt(userId)]) diff --git a/backend/api/report/review.post.ts b/backend/api/report/review.post.ts new file mode 100644 index 0000000..7816e60 --- /dev/null +++ b/backend/api/report/review.post.ts @@ -0,0 +1,167 @@ +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 +}) + +/** + * 주간보고 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 PMO 리뷰 요청 + const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)이자 주간보고 작성 코치입니다. +개발자들이 더 나은 주간보고를 작성할 수 있도록 구체적인 피드백과 가이드를 제공해주세요. + +[주간보고 작성의 목적] +- 프로젝트 진행 현황을 명확히 파악 +- 일정 지연이나 리스크를 사전에 감지 +- 팀원 간 업무 공유 및 협업 촉진 + +[검토 기준 - 엄격하게 적용] + +1. **실적의 구체성** (가장 중요!) + - "DB 작업", "화면 개발", "API 개발" 같은 모호한 표현 지양 + - 좋은 예시: "사용자 관리 테이블 3개(user, role, permission) 설계 및 생성" + - 좋은 예시: "로그인 API 개발 - JWT 토큰 발급, 리프레시 토큰 구현" + - 좋은 예시: "검색 화면 UI 구현 - 필터 조건 5개, 페이징, 엑셀 다운로드" + - 어떤 기능/모듈/화면인지, 무엇을 구체적으로 했는지 명시되어야 함 + +2. **일정의 명확성** + - "진행중"만 있고 완료 예정일이 없으면 부족 + - 언제 완료될 예정인지, 진척률은 얼마인지 표기 권장 + - 좋은 예시: "사용자 관리 화면 개발 (70% 완료, 1/10 완료 예정)" + +3. **시간 산정의 적절성** + - 8시간(1일) 이상 작업은 세부 내역이 필요 + - 16시간(2일) 이상인데 내용이 한 줄이면 분리 필요 + - "회의", "검토" 등은 별도 기재 권장 + +4. **차주 계획의 실현 가능성** + - 계획이 너무 추상적이면 실행하기 어려움 + - 구체적인 목표와 예상 산출물 명시 필요 + - 좋은 예시: "결제 모듈 연동 - PG사 API 연동, 결제 테스트 완료 목표" + +[피드백 작성 규칙] +- 각 Task별로 구체적인 개선 제안 제시 +- 잘 작성된 부분은 "✅" 로 인정 +- 보완이 필요한 부분은 "📝" 로 개선 방향 제시 +- 일정 관련 질문은 "📅" 로 표시 +- 리스크/우려사항은 "⚠️" 로 경고 +- **반드시 어떻게 수정하면 좋을지 예시를 들어 설명** +- 친절하지만 명확하게, 구체적인 작성 예시를 포함 +- 마지막에 전체적인 작성 팁 1-2개 추가 + +[피드백 톤] +- 비난하지 않고 코칭하는 느낌으로 +- "~하면 더 좋겠습니다", "~로 수정해보시면 어떨까요?" 형태로 +- 개선점뿐 아니라 잘한 점도 언급` + + const userPrompt = `다음 주간보고를 PMO 관점에서 상세히 리뷰해주세요. +특히 실적과 계획이 구체적으로 작성되었는지, 일정이 명확한지 중점적으로 검토해주세요. +모호한 표현이 있다면 어떻게 수정하면 좋을지 예시와 함께 피드백해주세요. + +${taskText}` + + try { + 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.7 + }) + + const review = response.choices[0]?.message?.content || '리뷰를 생성할 수 없습니다.' + const reviewedAt = new Date().toISOString() + + // DB에 저장 + await query(` + UPDATE wr_weekly_report + SET ai_review = $1, ai_review_at = $2 + WHERE report_id = $3 + `, [review, reviewedAt, reportId]) + + return { + success: true, + review, + reviewedAt + } + } catch (error: any) { + console.error('OpenAI API error:', error) + throw createError({ + statusCode: 500, + message: 'AI 리뷰 생성 중 오류가 발생했습니다: ' + error.message + }) + } +}) diff --git a/backend/api/report/summary/[id]/detail.get.ts b/backend/api/report/summary/[id]/detail.get.ts index 09cbb27..3642606 100644 --- a/backend/api/report/summary/[id]/detail.get.ts +++ b/backend/api/report/summary/[id]/detail.get.ts @@ -21,9 +21,9 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, message: '취합 보고서를 찾을 수 없습니다.' }) } - // 개별 보고서 목록 (새 구조: 마스터 + 프로젝트별 실적 조인) + // 개별 보고서 목록 (Task 기반) const reports = await query(` - SELECT + SELECT DISTINCT r.report_id, r.author_id, e.employee_name as author_name, @@ -32,16 +32,49 @@ export default defineEventHandler(async (event) => { r.vacation_description, r.remark_description, r.report_status, - r.submitted_at, - rp.work_description, - rp.plan_description + r.submitted_at FROM wr_weekly_report r - JOIN wr_weekly_report_project rp ON r.report_id = rp.report_id + 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 rp.project_id = $1 AND r.report_year = $2 AND r.report_week = $3 + 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() + 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, @@ -53,24 +86,31 @@ export default defineEventHandler(async (event) => { 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 + summaryStatus: summary.summary_status, + aggregatedAt: summary.aggregated_at, + aiSummary: summary.ai_summary, + aiSummaryAt: summary.ai_summary_at }, - reports: reports.map((r: any) => ({ - reportId: r.report_id, - authorId: r.author_id, - authorName: r.author_name, - authorPosition: r.employee_position, - workDescription: r.work_description, - planDescription: r.plan_description, - issueDescription: r.issue_description, - vacationDescription: r.vacation_description, - remarkDescription: r.remark_description, - reportStatus: r.report_status, - submittedAt: r.submitted_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 + } + }) } }) diff --git a/backend/api/report/summary/aggregate.post.ts b/backend/api/report/summary/aggregate.post.ts index 0355622..e829945 100644 --- a/backend/api/report/summary/aggregate.post.ts +++ b/backend/api/report/summary/aggregate.post.ts @@ -1,15 +1,21 @@ -import { query, queryOne, insertReturning, execute } from '../../../utils/db' +import { defineEventHandler, readBody, createError, getCookie } from 'h3' +import { query, queryOne, execute, insertReturning } from '../../../utils/db' import { getClientIp } from '../../../utils/ip' import { getCurrentUserEmail } from '../../../utils/user' +import OpenAI from 'openai' interface AggregateBody { - projectId: number + 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) => { @@ -22,75 +28,106 @@ export default defineEventHandler(async (event) => { const clientIp = getClientIp(event) const userEmail = await getCurrentUserEmail(event) - if (!body.projectId || !body.reportYear || !body.reportWeek) { - throw createError({ statusCode: 400, message: '프로젝트, 연도, 주차를 선택해주세요.' }) + if (!body.projectIds || body.projectIds.length === 0) { + throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' }) + } + if (!body.reportYear || !body.reportWeek) { + throw createError({ statusCode: 400, message: '연도와 주차를 선택해주세요.' }) } - // 해당 프로젝트/주차의 제출된 보고서 조회 (새 구조) - const reports = await query(` - SELECT - r.report_id, - r.author_id, - r.week_start_date, - r.week_end_date, - rp.detail_id - FROM wr_weekly_report r - JOIN wr_weekly_report_project rp ON r.report_id = rp.report_id - WHERE rp.project_id = $1 - AND r.report_year = $2 - AND r.report_week = $3 - AND r.report_status IN ('SUBMITTED', 'AGGREGATED') - ORDER BY r.report_id - `, [body.projectId, body.reportYear, body.reportWeek]) + let summaryCount = 0 + let totalMembers = 0 + const allReportIds: number[] = [] - if (reports.length === 0) { + // 각 프로젝트별로 취합 생성 + for (const projectId of body.projectIds) { + // 해당 프로젝트/주차의 Task 조회 (작성자 포함) + const tasks = await query(` + 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(` + 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(` + 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 reportIds = [...new Set(reports.map(r => r.report_id))] - const weekStartDate = reports[0].week_start_date - const weekEndDate = reports[0].week_end_date - - // 기존 취합 보고서 확인 - const existing = await queryOne(` - SELECT summary_id FROM wr_aggregated_report_summary - WHERE project_id = $1 AND report_year = $2 AND report_week = $3 - `, [body.projectId, body.reportYear, body.reportWeek]) - - let summaryId: number - - if (existing) { - // 기존 취합 업데이트 - await execute(` - UPDATE wr_aggregated_report_summary - SET report_ids = $1, - member_count = $2, - aggregated_at = NOW(), - updated_at = NOW(), - updated_ip = $3, - updated_email = $4 - WHERE summary_id = $5 - `, [reportIds, reportIds.length, clientIp, userEmail, existing.summary_id]) - summaryId = existing.summary_id - } else { - // 새 취합 생성 - const newSummary = await insertReturning(` - INSERT INTO wr_aggregated_report_summary ( - project_id, report_year, report_week, week_start_date, week_end_date, - report_ids, member_count, - created_ip, created_email, updated_ip, updated_email - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $8, $9) - RETURNING summary_id - `, [ - body.projectId, body.reportYear, body.reportWeek, - weekStartDate, weekEndDate, - reportIds, reportIds.length, - clientIp, userEmail - ]) - summaryId = newSummary.summary_id - } - // 개별 보고서 상태 업데이트 + const uniqueReportIds = [...new Set(allReportIds)] await execute(` UPDATE wr_weekly_report SET report_status = 'AGGREGATED', @@ -98,11 +135,88 @@ export default defineEventHandler(async (event) => { updated_ip = $1, updated_email = $2 WHERE report_id = ANY($3) - `, [clientIp, userEmail, reportIds]) + `, [clientIp, userEmail, uniqueReportIds]) return { success: true, - summaryId, - memberCount: reportIds.length + 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(`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: '요약 생성 실패' + } + } +} diff --git a/backend/api/report/summary/available-projects.get.ts b/backend/api/report/summary/available-projects.get.ts new file mode 100644 index 0000000..37cf0f6 --- /dev/null +++ b/backend/api/report/summary/available-projects.get.ts @@ -0,0 +1,42 @@ +import { defineEventHandler, getQuery, createError, getCookie } from 'h3' +import { query } from '../../../utils/db' + +export default defineEventHandler(async (event) => { + const userId = getCookie(event, 'user_id') + + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + 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) + })) + } +}) diff --git a/backend/api/report/summary/list.get.ts b/backend/api/report/summary/list.get.ts index 2727017..f8e7d87 100644 --- a/backend/api/report/summary/list.get.ts +++ b/backend/api/report/summary/list.get.ts @@ -33,22 +33,26 @@ export default defineEventHandler(async (event) => { const summaries = await query(sql, params) - return summaries.map((s: any) => ({ - summaryId: s.summary_id, - projectId: s.project_id, - projectName: s.project_name, - projectCode: s.project_code, - reportYear: s.report_year, - reportWeek: s.report_week, - weekStartDate: s.week_start_date, - weekEndDate: s.week_end_date, - memberCount: s.member_count, - totalWorkHours: s.total_work_hours, - reviewerId: s.reviewer_id, - reviewerName: s.reviewer_name, - reviewerComment: s.reviewer_comment, - reviewedAt: s.reviewed_at, - summaryStatus: s.summary_status, - aggregatedAt: s.aggregated_at - })) + 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 + })) + } }) diff --git a/backend/api/report/summary/regenerate-ai.post.ts b/backend/api/report/summary/regenerate-ai.post.ts new file mode 100644 index 0000000..106cd2c --- /dev/null +++ b/backend/api/report/summary/regenerate-ai.post.ts @@ -0,0 +1,152 @@ +import { defineEventHandler, createError, getCookie } from 'h3' +import { query, queryOne, execute } from '../../../utils/db' +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 = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + // AI 요약이 없는 취합 보고서 조회 + const summaries = await query(` + 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(` + 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 || '요약 없음' + } +} diff --git a/backend/api/report/summary/week/detail.get.ts b/backend/api/report/summary/week/detail.get.ts new file mode 100644 index 0000000..0d146e5 --- /dev/null +++ b/backend/api/report/summary/week/detail.get.ts @@ -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() + const membersByProject = new Map>() + + 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() + 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 + } +}) diff --git a/backend/api/report/summary/weekly-list.get.ts b/backend/api/report/summary/weekly-list.get.ts new file mode 100644 index 0000000..e28c49a --- /dev/null +++ b/backend/api/report/summary/weekly-list.get.ts @@ -0,0 +1,44 @@ +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, + MIN(s.week_start_date) as week_start_date, + MAX(s.week_end_date) as week_end_date, + 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) => ({ + reportYear: r.report_year, + reportWeek: r.report_week, + weekStartDate: r.week_start_date, + weekEndDate: r.week_end_date, + 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 || [] + })) + } +}) diff --git a/backend/api/report/weekly/[id]/delete.delete.ts b/backend/api/report/weekly/[id]/delete.delete.ts new file mode 100644 index 0000000..6793ffb --- /dev/null +++ b/backend/api/report/weekly/[id]/delete.delete.ts @@ -0,0 +1,50 @@ +import { query, execute } from '../../../../utils/db' + +const ADMIN_EMAIL = 'coziny@gmail.com' + +/** + * 주간보고 삭제 + * DELETE /api/report/weekly/[id]/delete + */ +export default defineEventHandler(async (event) => { + const userId = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + const reportId = getRouterParam(event, 'id') + if (!reportId) { + throw createError({ statusCode: 400, message: '보고서 ID가 필요합니다.' }) + } + + // 현재 사용자 정보 조회 + const currentUser = await query(` + SELECT employee_email FROM wr_employee_info WHERE employee_id = $1 + `, [userId]) + const isAdmin = currentUser[0]?.employee_email === ADMIN_EMAIL + + // 보고서 정보 조회 + const report = await query(` + 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 !== parseInt(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: '주간보고가 삭제되었습니다.' + } +}) diff --git a/backend/api/report/weekly/[id]/detail.get.ts b/backend/api/report/weekly/[id]/detail.get.ts index a3c199b..0d6db36 100644 --- a/backend/api/report/weekly/[id]/detail.get.ts +++ b/backend/api/report/weekly/[id]/detail.get.ts @@ -27,21 +27,71 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' }) } - // 프로젝트별 실적 조회 - const projects = await query(` + // 같은 주차의 이전/다음 보고서 조회 + const prevReport = await queryOne(` + 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(` + 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(` SELECT - rp.detail_id, - rp.project_id, + t.task_id, + t.project_id, p.project_code, p.project_name, - rp.work_description, - rp.plan_description - FROM wr_weekly_report_project rp - JOIN wr_project_info p ON rp.project_id = p.project_id - WHERE rp.report_id = $1 - ORDER BY rp.detail_id + 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() + + 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, @@ -50,23 +100,39 @@ export default defineEventHandler(async (event) => { authorEmail: report.author_email, reportYear: report.report_year, reportWeek: report.report_week, - weekStartDate: report.week_start_date, - weekEndDate: report.week_end_date, + 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 + updatedAt: report.updated_at, + aiReview: report.ai_review, + aiReviewAt: report.ai_review_at }, - projects: projects.map((p: any) => ({ - detailId: p.detail_id, - projectId: p.project_id, - projectCode: p.project_code, - projectName: p.project_name, - workDescription: p.work_description, - planDescription: p.plan_description + 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] +} diff --git a/backend/api/report/weekly/[id]/update.put.ts b/backend/api/report/weekly/[id]/update.put.ts index ded597c..c562da2 100644 --- a/backend/api/report/weekly/[id]/update.put.ts +++ b/backend/api/report/weekly/[id]/update.put.ts @@ -1,19 +1,6 @@ -import { execute, query, queryOne } from '../../../../utils/db' -import { getClientIp } from '../../../../utils/ip' -import { getCurrentUserEmail } from '../../../../utils/user' +import { query, execute, queryOne } from '../../../../utils/db' -interface ProjectItem { - projectId: number - workDescription?: string - planDescription?: string -} - -interface UpdateReportBody { - projects?: ProjectItem[] - issueDescription?: string - vacationDescription?: string - remarkDescription?: string -} +const ADMIN_EMAIL = 'coziny@gmail.com' /** * 주간보고 수정 @@ -26,67 +13,94 @@ export default defineEventHandler(async (event) => { } const reportId = getRouterParam(event, 'id') - const body = await readBody(event) - const clientIp = getClientIp(event) - const userEmail = await getCurrentUserEmail(event) + const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown' + const user = await queryOne(`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(` - SELECT * FROM wr_weekly_report WHERE report_id = $1 + SELECT report_id, author_id, report_status FROM wr_weekly_report WHERE report_id = $1 `, [reportId]) if (!report) { throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' }) } - if (report.author_id !== parseInt(userId)) { + // 관리자가 아니면 본인 보고서만 수정 가능 + if (!isAdmin && report.author_id !== parseInt(userId)) { throw createError({ statusCode: 403, message: '본인의 보고서만 수정할 수 있습니다.' }) } - if (report.report_status === 'SUBMITTED' || report.report_status === 'AGGREGATED') { - throw createError({ statusCode: 400, 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 - issue_description = $1, - vacation_description = $2, - remark_description = $3, + 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 = $4, - updated_email = $5 - WHERE report_id = $6 + updated_ip = $8, + updated_email = $9 + WHERE report_id = $10 `, [ - body.issueDescription ?? report.issue_description, - body.vacationDescription ?? report.vacation_description, - body.remarkDescription ?? report.remark_description, - clientIp, - userEmail, - reportId + body.reportYear || null, + body.reportWeek || null, + body.weekStartDate || null, + body.weekEndDate || null, + body.issueDescription || null, + body.vacationDescription || null, + body.remarkDescription || null, + clientIp, userEmail, reportId ]) - // 프로젝트별 실적 업데이트 - if (body.projects && body.projects.length > 0) { - // 기존 삭제 후 재등록 - await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId]) - - for (const proj of body.projects) { - await execute(` - INSERT INTO wr_weekly_report_project ( - report_id, project_id, work_description, plan_description, - created_ip, created_email, updated_ip, updated_email - ) VALUES ($1, $2, $3, $4, $5, $6, $5, $6) - `, [ - reportId, - proj.projectId, - proj.workDescription || null, - proj.planDescription || null, - clientIp, - userEmail - ]) - } + // 기존 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 } + return { + success: true, + message: '주간보고가 수정되었습니다.' + } }) diff --git a/backend/api/report/weekly/aggregate.get.ts b/backend/api/report/weekly/aggregate.get.ts new file mode 100644 index 0000000..d399f3c --- /dev/null +++ b/backend/api/report/weekly/aggregate.get.ts @@ -0,0 +1,131 @@ +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() + + 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()) + } +}) diff --git a/backend/api/report/weekly/create.post.ts b/backend/api/report/weekly/create.post.ts index 7ba0f0c..ac5caf1 100644 --- a/backend/api/report/weekly/create.post.ts +++ b/backend/api/report/weekly/create.post.ts @@ -1,22 +1,4 @@ -import { query, insertReturning, execute } from '../../../utils/db' -import { getWeekInfo } from '../../../utils/week-calc' -import { getClientIp } from '../../../utils/ip' -import { getCurrentUserEmail } from '../../../utils/user' - -interface ProjectItem { - projectId: number - workDescription?: string - planDescription?: string -} - -interface CreateReportBody { - reportYear?: number - reportWeek?: number - projects: ProjectItem[] - issueDescription?: string - vacationDescription?: string - remarkDescription?: string -} +import { query, execute, queryOne } from '../../../utils/db' /** * 주간보고 작성 @@ -28,73 +10,79 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) } - const body = await readBody(event) - const clientIp = getClientIp(event) - const userEmail = await getCurrentUserEmail(event) + const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown' + const user = await queryOne(`SELECT employee_email FROM wr_employee_info WHERE employee_id = $1`, [userId]) + const userEmail = user?.employee_email || '' - if (!body.projects || body.projects.length === 0) { - throw createError({ statusCode: 400, message: '최소 1개 이상의 프로젝트를 추가해주세요.' }) + 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: '주차 정보가 필요합니다.' }) } - // 주차 정보 (기본값: 이번 주) - const weekInfo = getWeekInfo() - const year = body.reportYear || weekInfo.year - const week = body.reportWeek || weekInfo.week + if (!body.tasks || body.tasks.length === 0) { + throw createError({ statusCode: 400, message: '최소 1개 이상의 Task가 필요합니다.' }) + } // 중복 체크 - const existing = await query(` - SELECT report_id FROM wr_weekly_report + const existing = await queryOne(` + SELECT report_id FROM wr_weekly_report WHERE author_id = $1 AND report_year = $2 AND report_week = $3 - `, [parseInt(userId), year, week]) + `, [userId, body.reportYear, body.reportWeek]) - if (existing.length > 0) { - throw createError({ statusCode: 409, message: '이미 해당 주차 보고서가 존재합니다.' }) + if (existing) { + throw createError({ statusCode: 409, message: '해당 주차에 이미 작성된 보고서가 있습니다.' }) } - // 주차 날짜 계산 - const dates = getWeekInfo(new Date(year, 0, 4 + (week - 1) * 7)) - - // 마스터 생성 - const report = await insertReturning(` + // 마스터 등록 + const result = await queryOne(` INSERT INTO wr_weekly_report ( - author_id, report_year, report_week, - week_start_date, week_end_date, + 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 * + RETURNING report_id `, [ - parseInt(userId), - year, - week, - dates.startDateStr, - dates.endDateStr, - body.issueDescription || null, - body.vacationDescription || null, - body.remarkDescription || null, - clientIp, - userEmail + userId, body.reportYear, body.reportWeek, body.weekStartDate, body.weekEndDate, + body.issueDescription || null, body.vacationDescription || null, body.remarkDescription || null, + clientIp, userEmail ]) - // 프로젝트별 실적 저장 - for (const proj of body.projects) { + const reportId = result.report_id + + // Task 등록 + for (const task of body.tasks) { await execute(` - INSERT INTO wr_weekly_report_project ( - report_id, project_id, work_description, plan_description, + 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, $5, $6) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8) `, [ - report.report_id, - proj.projectId, - proj.workDescription || null, - proj.planDescription || null, - clientIp, - userEmail + reportId, task.projectId, task.taskType, task.taskDescription, task.taskHours || 0, + task.taskType === 'WORK' ? (task.isCompleted !== false) : null, + clientIp, userEmail ]) } return { success: true, - reportId: report.report_id + reportId, + message: '주간보고가 작성되었습니다.' } }) diff --git a/backend/api/report/weekly/list.get.ts b/backend/api/report/weekly/list.get.ts index 17bbba7..e9f6437 100644 --- a/backend/api/report/weekly/list.get.ts +++ b/backend/api/report/weekly/list.get.ts @@ -1,8 +1,22 @@ import { query } from '../../../utils/db' +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 = getCookie(event, 'user_id') @@ -10,14 +24,89 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) } - const queryParams = getQuery(event) - const limit = parseInt(queryParams.limit as string) || 20 + // 현재 사용자 정보 조회 (관리자 여부 확인) + const currentUser = await query(` + 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 viewAll = q.viewAll === 'true' + + // 필터 조건 구성 + const conditions: string[] = [] + const params: any[] = [] + let paramIndex = 1 + + // 관리자가 viewAll이면 전체 조회, 아니면 본인 것만 + if (!isAdmin || !viewAll) { + // 작성자 필터 (본인 또는 지정된 작성자) + if (q.authorId) { + conditions.push(`r.author_id = $${paramIndex++}`) + params.push(q.authorId) + } else if (!isAdmin) { + // 관리자가 아니면 본인 것만 + conditions.push(`r.author_id = $${paramIndex++}`) + params.push(userId) + } + } else if (q.authorId) { + // 관리자가 viewAll이어도 작성자 필터가 있으면 적용 + 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.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(` 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, @@ -27,29 +116,50 @@ export default defineEventHandler(async (event) => { r.report_status, r.submitted_at, r.created_at, - (SELECT COUNT(*) FROM wr_weekly_report_project WHERE report_id = r.report_id) as project_count + (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 - WHERE r.author_id = $1 - ORDER BY r.report_year DESC, r.report_week DESC - LIMIT $2 - `, [userId, limit]) + ${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: r.week_start_date, - weekEndDate: r.week_end_date, + 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, - projectCount: parseInt(r.project_count) + 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] +} diff --git a/backend/utils/openai.ts b/backend/utils/openai.ts index 8252778..a700dc8 100644 --- a/backend/utils/openai.ts +++ b/backend/utils/openai.ts @@ -4,9 +4,47 @@ const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions' +// 모델별 파라미터 설정 +const MODEL_CONFIG: Record = { + // 최신 모델 (max_completion_tokens 사용) + 'gpt-5.1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'gpt-5': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'gpt-4.1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'gpt-4.1-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'gpt-4.1-nano': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'o1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'o1-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'o1-pro': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + 'o3-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }, + + // 이전 모델 (max_tokens 사용) + 'gpt-4o': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 }, + 'gpt-4o-mini': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 }, + 'gpt-4-turbo': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 }, + 'gpt-4': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 }, + 'gpt-3.5-turbo': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 }, +} + +// 기본 모델 설정 +const DEFAULT_MODEL = 'gpt-5.1' + +function getModelConfig(model: string) { + if (MODEL_CONFIG[model]) { + return MODEL_CONFIG[model] + } + + for (const key of Object.keys(MODEL_CONFIG)) { + if (model.startsWith(key)) { + return MODEL_CONFIG[key] + } + } + + return { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 } +} + interface ChatMessage { role: 'system' | 'user' | 'assistant' - content: string + content: string | Array<{ type: string; text?: string; image_url?: { url: string } }> } interface OpenAIResponse { @@ -17,25 +55,37 @@ interface OpenAIResponse { }[] } -export async function callOpenAI(messages: ChatMessage[], jsonMode = true): Promise { +export async function callOpenAI( + messages: ChatMessage[], + jsonMode = true, + model = DEFAULT_MODEL +): Promise { const apiKey = process.env.OPENAI_API_KEY if (!apiKey || apiKey === 'your-openai-api-key-here') { throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.') } + const config = getModelConfig(model) + + const requestBody: any = { + model, + messages, + temperature: 0.1, + [config.maxTokensParam]: config.defaultMaxTokens, + } + + if (jsonMode) { + requestBody.response_format = { type: 'json_object' } + } + const response = await fetch(OPENAI_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages, - temperature: 0.1, - ...(jsonMode && { response_format: { type: 'json_object' } }) - }) + body: JSON.stringify(requestBody) }) if (!response.ok) { @@ -48,54 +98,146 @@ export async function callOpenAI(messages: ChatMessage[], jsonMode = true): Prom } /** - * 주간보고 텍스트 분석 프롬프트 + * 이미지 분석용 OpenAI 호출 (Vision) */ -export function buildParseReportPrompt(rawText: string): ChatMessage[] { - return [ - { - role: 'system', - content: `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다. +export async function callOpenAIVision( + systemPrompt: string, + imageBase64List: string[], + model = DEFAULT_MODEL +): Promise { + const apiKey = process.env.OPENAI_API_KEY + + if (!apiKey || apiKey === 'your-openai-api-key-here') { + throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.') + } + + const config = getModelConfig(model) + + const imageContents = imageBase64List.map(base64 => ({ + type: 'image_url' as const, + image_url: { + url: base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}` + } + })) + + const requestBody: any = { + model, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: [ + { type: 'text', text: '이 이미지들에서 주간보고 내용을 추출해주세요.' }, + ...imageContents + ] + } + ], + temperature: 0.1, + [config.maxTokensParam]: config.defaultMaxTokens, + response_format: { type: 'json_object' } + } + + const response = await fetch(OPENAI_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify(requestBody) + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`OpenAI Vision API 오류: ${response.status} - ${error}`) + } + + const data = await response.json() as OpenAIResponse + return data.choices[0].message.content +} -입력된 텍스트에서 다음 정보를 추출하세요: -1. 직원 정보 (이름, 이메일) -2. 프로젝트별 실적 (프로젝트명, 금주실적, 차주계획) -3. 공통사항 (이슈/리스크, 휴가일정, 기타사항) -4. 보고 주차 정보 (텍스트에서 날짜나 주차 정보 추출) +/** + * 주간보고 분석 시스템 프롬프트 (Task 기반) + */ +export const REPORT_PARSE_SYSTEM_PROMPT = `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다. + +## 핵심 원칙 +- **원문의 내용을 그대로 유지하세요!** +- **Task는 적당히 묶어서 정리하세요. 너무 세분화하지 마세요!** +- 하나의 Task에 여러 줄이 들어갈 수 있습니다. + +## Task 분리 규칙 (중요!) + +❌ 잘못된 예 (너무 세분화): +- Task 1: "API 개발" +- Task 2: "API 테스트" + +✅ 올바른 예 (적절히 묶기): +- Task 1: "API 개발 및 테스트 완료" + +❌ 잘못된 예 (프로젝트명 반복): +- "PIMS 고도화 - 사용자 인증 개발" + +✅ 올바른 예 (프로젝트명 제외): +- "사용자 인증 개발" + +## 완료여부(isCompleted) 판단 규칙 ★중요★ + +금주 실적(workTasks)의 완료여부를 판단합니다: +- 기본값: true (완료) +- false (진행중): 차주 계획(planTasks)에 비슷한/연관된 작업이 있는 경우 + +예시: +- 실적: "로그인 API 개발" + 계획: "로그인 API 테스트" → isCompleted: false (연관 작업 있음) +- 실적: "DB 백업 완료" + 계획에 관련 없음 → isCompleted: true + +## 수행시간 예측 기준 +- **0시간**: "없음", "특이사항 없음", "해당없음", "한 게 없다", "작업 없음" 등 실제 작업이 없는 경우 +- 단순 작업: 2~4시간 +- 일반 작업: 8시간 (1일) +- 복잡한 작업: 16~24시간 (2~3일) + +## JSON 출력 형식 -반드시 아래 JSON 형식으로 응답하세요: { "reportYear": 2025, - "reportWeek": 1, + "reportWeek": 2, "weekStartDate": "2025-01-06", - "weekEndDate": "2025-01-10", + "weekEndDate": "2025-01-12", "reports": [ { "employeeName": "홍길동", "employeeEmail": "hong@example.com", "projects": [ { - "projectName": "프로젝트명", - "workDescription": "금주 실적 내용", - "planDescription": "차주 계획 내용" + "projectName": "PIMS 고도화", + "workTasks": [ + { "description": "사용자 인증 모듈 개발", "hours": 16, "isCompleted": false }, + { "description": "DB 백업 스크립트 작성", "hours": 4, "isCompleted": true } + ], + "planTasks": [ + { "description": "사용자 인증 테스트 및 배포", "hours": 8 } + ] } ], - "issueDescription": "이슈/리스크 내용 또는 null", - "vacationDescription": "휴가 일정 또는 null", - "remarkDescription": "기타 사항 또는 null" + "issueDescription": "개발서버 메모리 부족", + "vacationDescription": null, + "remarkDescription": null } ] } -주의사항: -- 이메일이 없으면 employeeEmail은 null로 -- 프로젝트가 여러개면 projects 배열에 모두 포함 -- 날짜 형식은 YYYY-MM-DD -- 주차 정보가 없으면 현재 날짜 기준으로 추정 -- 실적/계획이 명확히 구분 안되면 workDescription에 통합` - }, - { - role: 'user', - content: rawText - } +## 주의사항 +- Task description에 프로젝트명을 포함하지 마세요 +- 비슷한 작업은 하나의 Task로 묶으세요 +- 한 Task 내 여러 항목은 \\n으로 줄바꿈 +- 이메일이 없으면 employeeEmail은 null` + +/** + * 주간보고 텍스트 분석 프롬프트 + */ +export function buildParseReportPrompt(rawText: string): ChatMessage[] { + return [ + { role: 'system', content: REPORT_PARSE_SYSTEM_PROMPT }, + { role: 'user', content: rawText } ] } diff --git a/frontend/admin/bulk-import.vue b/frontend/admin/bulk-import.vue index 081bcff..50cf64d 100644 --- a/frontend/admin/bulk-import.vue +++ b/frontend/admin/bulk-import.vue @@ -7,39 +7,112 @@ 주간보고 일괄등록 - +
- 1단계: 주간보고 내용 붙여넣기 + 1단계: 주간보고 내용 입력 +
-
- - + > +
+
+ +
-
- + + +
+
+ +
+ + +

+ 이미지를 드래그하거나 클릭해서 업로드
+ (최대 10장, PNG/JPG) +

+
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
@@ -55,22 +128,32 @@
-
-
- - +
+
+ +
+ + + {{ parsedData.reportYear }}년 {{ parsedData.reportWeek }}주차 + + +
-
- - +
+ +
+ + ~ + +
-
- - -
-
- - +
+ +
@@ -79,9 +162,10 @@
+ :class="getHeaderClass(report)">
- 매칭됨 + 기존직원 + 신규직원 매칭필요 {{ report.employeeName }} {{ report.employeeEmail || '이메일 없음' }} @@ -95,47 +179,105 @@
- - + +
+ + +
+ + +
+
+
+ + +
+
+ + +
+
- -
-
+ +
+
- -
- -
- + + +
+
+
- - +
+ + +
+
+
+
+ +
+ +
+ +
{{ formatHours(task.hours) }}
+
+ +
+
+ +
- - +
+ + +
+
+
+ +
+ +
{{ formatHours(task.hours) }}
+
+ +
+
@@ -143,16 +285,16 @@
- - + +
- - + +
- - + +
@@ -162,11 +304,7 @@
-
+ +
+
+ AI 요약 + + ({{ formatDateTime(summary.aiSummaryAt) }}) + +
+
+
+
+
+
@@ -121,17 +134,29 @@ 금주 실적
-
{{ report.workDescription || '-' }}
+
+
+ + {{ task.isCompleted ? '완료' : '진행' }} + + {{ task.description }} + ({{ task.hours }}h) +
+
+
-
-
+
-
{{ report.planDescription }}
+
+ {{ task.description }} + ({{ task.hours }}h) +
@@ -237,4 +262,38 @@ function formatDateTime(dateStr: string) { const d = new Date(dateStr) return d.toLocaleString('ko-KR') } + +// 간단한 마크다운 렌더링 +function renderMarkdown(text: string): string { + if (!text) return '' + return text + // 헤더 + .replace(/^### (.+)$/gm, '
$1
') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // 볼드 + .replace(/\*\*(.+?)\*\*/g, '$1') + // 이탤릭 + .replace(/\*(.+?)\*/g, '$1') + // 리스트 + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    ') + // 줄바꿈 + .replace(/\n/g, '
    ') +} + + diff --git a/frontend/report/summary/[year]/[week].vue b/frontend/report/summary/[year]/[week].vue new file mode 100644 index 0000000..4d245e6 --- /dev/null +++ b/frontend/report/summary/[year]/[week].vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/report/summary/index.vue b/frontend/report/summary/index.vue index 876d454..b5cdb48 100644 --- a/frontend/report/summary/index.vue +++ b/frontend/report/summary/index.vue @@ -6,7 +6,7 @@

    취합 보고서

    -

    프로젝트별 주간보고 취합 목록

    +

    주차별 취합 보고서 목록

    -
  • - +
    - + + - - - + - - - + + - - + @@ -105,7 +92,7 @@
    주차주차기간 프로젝트기간 참여인원 총 시간상태 취합일시 상세
    - W{{ String(summary.reportWeek).padStart(2, '0') }} - {{ summary.projectName }} - {{ formatDateRange(summary.weekStartDate, summary.weekEndDate) }} + {{ week.reportWeek }}주차 - {{ summary.memberCount }}명 + {{ formatDateRange(week.weekStartDate, week.weekEndDate) }} - {{ summary.totalWorkHours ? summary.totalWorkHours + 'h' : '-' }} - - - {{ getStatusText(summary.summaryStatus) }} + + {{ p }} + + + +{{ week.projects.length - 3 }}개 - {{ formatDateTime(summary.aggregatedAt) }} + {{ week.totalMembers }}명 + + {{ week.totalWorkHours ? formatHours(week.totalWorkHours) : '-' }} + + {{ formatDateTime(week.latestAggregatedAt) }}
    +

    취합된 보고서가 없습니다.

    - - + + + - - + + - - + + - - +
    주차기간주차기간작성자 프로젝트상태작성일상태제출일
    + 로딩 중...
    + -

    작성한 주간보고가 없습니다.

    +

    조회된 주간보고가 없습니다.

    - {{ r.reportYear }}년 {{ r.reportWeek }}주차 + {{ r.reportYear }}년 {{ r.reportWeek }}주 + + {{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }} + + {{ r.authorName }} {{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }} - {{ r.projectCount }}개 프로젝트 + + {{ r.projectNames || '-' }} + + {{ r.projectCount }}건 {{ getStatusText(r.reportStatus) }} {{ formatDateTime(r.createdAt) }}{{ formatDateTime(r.submittedAt || r.createdAt) }}
    +
    @@ -68,7 +160,23 @@ const { fetchCurrentUser } = useAuth() const router = useRouter() const reports = ref([]) +const employees = ref([]) +const projects = ref([]) const isLoading = ref(true) +const isAdmin = ref(false) + +const currentYear = new Date().getFullYear() +const yearOptions = [currentYear, currentYear - 1, currentYear - 2] + +const filters = ref({ + viewAll: false, + authorId: '', + projectId: '', + year: '', + startDate: '', + endDate: '', + status: '' +}) onMounted(async () => { const user = await fetchCurrentUser() @@ -76,14 +184,52 @@ onMounted(async () => { router.push('/login') return } + + isAdmin.value = user.employeeEmail === 'coziny@gmail.com' + + // 직원, 프로젝트 목록 로드 (관리자용) + if (isAdmin.value) { + await loadFilterOptions() + } + loadReports() }) +async function loadFilterOptions() { + try { + // 직원 목록 + const empRes = await $fetch('/api/employee/list') + employees.value = empRes.employees || [] + + // 프로젝트 목록 + const projRes = await $fetch('/api/project/list') + projects.value = projRes.projects || [] + } catch (e) { + console.error(e) + } +} + async function loadReports() { isLoading.value = true try { - const res = await $fetch('/api/report/weekly/list') + const params = new URLSearchParams() + + if (filters.value.viewAll) params.append('viewAll', 'true') + if (filters.value.authorId) params.append('authorId', filters.value.authorId) + if (filters.value.projectId) params.append('projectId', filters.value.projectId) + if (filters.value.year) params.append('year', filters.value.year) + if (filters.value.startDate) params.append('startDate', filters.value.startDate) + if (filters.value.endDate) params.append('endDate', filters.value.endDate) + if (filters.value.status) params.append('status', filters.value.status) + + const res = await $fetch(`/api/report/weekly/list?${params.toString()}`) reports.value = res.reports || [] + + // 일반 사용자도 프로젝트 필터 사용할 수 있도록 + if (!isAdmin.value && projects.value.length === 0) { + const projRes = await $fetch('/api/project/list') + projects.value = projRes.projects || [] + } } catch (e) { console.error(e) } finally { @@ -91,15 +237,28 @@ async function loadReports() { } } +function resetFilters() { + filters.value = { + viewAll: false, + authorId: '', + projectId: '', + year: '', + startDate: '', + endDate: '', + status: '' + } + loadReports() +} + function formatDate(dateStr: string) { if (!dateStr) return '' - return dateStr.split('T')[0] + return dateStr.split('T')[0].replace(/-/g, '.') } function formatDateTime(dateStr: string) { - if (!dateStr) return '' + if (!dateStr) return '-' const d = new Date(dateStr) - return d.toLocaleDateString('ko-KR') + return `${d.getMonth() + 1}/${d.getDate()}` } function getStatusBadgeClass(status: string) { @@ -120,3 +279,9 @@ function getStatusText(status: string) { return texts[status] || status } + + diff --git a/frontend/report/weekly/write.vue b/frontend/report/weekly/write.vue index 7a84157..244cab4 100644 --- a/frontend/report/weekly/write.vue +++ b/frontend/report/weekly/write.vue @@ -3,47 +3,146 @@
    -
    -

    - 주간보고 작성 -

    - {{ weekInfo.weekString }} ({{ weekInfo.startDateStr }} ~ {{ weekInfo.endDateStr }}) -
    +

    + 주간보고 작성 +

    - + +
    +
    보고 주차
    +
    +
    +
    +
    + + + {{ form.reportYear }}년 {{ form.reportWeek }}주차 + + +
    +
    +
    +
    + + ~ + +
    +
    +
    + + +
    +
    +
    +
    + +
    - 프로젝트별 실적 + 프로젝트별 실적/계획
    -
    - -

    프로젝트를 추가해주세요.

    +
    + 프로젝트를 추가해주세요.
    -
    -
    -
    - {{ proj.projectName }} - ({{ proj.projectCode }}) -
    -
    -
    - - +
    +
    + +
    +
    + + +
    +
    +
    +
    + + +
    + +
    + +
    {{ formatHours(task.hours) }}
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + +
    {{ formatHours(task.hours) }}
    +
    + +
    +
    +
    +
    -
    - - +
    +
    +
    + + +
    +
    +
    +
    + 금주 실적 합계 +
    {{ formatHoursDisplay(totalWorkHours) }}
    +
    +
    + 차주 계획 합계 +
    {{ formatHoursDisplay(totalPlanHours) }}
    @@ -51,24 +150,21 @@
    -
    - 공통 사항 -
    +
    공통 사항
    -
    - - -
    -
    - - -
    -
    - - +
    +
    + + +
    +
    + + +
    +
    + + +
    @@ -76,16 +172,16 @@
    취소 -
    -