1ㅊㅏ완료

This commit is contained in:
2026-01-05 02:00:13 +09:00
parent 1bbad6efa7
commit 185161db16
30 changed files with 4331 additions and 837 deletions

View File

@@ -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<any>(`
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
const currentUser = await query<any>(`
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<BulkRegisterBody>(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[] = []
for (const proj of report.projects) {
let projectId = proj.projectId
if (!projectId) {
// 신규 프로젝트 생성
const year = new Date().getFullYear()
const lastProject = await queryOne<any>(`
SELECT project_code FROM wr_project_info
WHERE project_code LIKE $1
ORDER BY project_code DESC LIMIT 1
`, [`${year}-%`])
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)
RETURNING project_id
`, [newCode, proj.projectName, clientIp, ADMIN_EMAIL])
projectId = newProject.project_id
// 신규 직원 생성
if (!employeeId && report.employeeName && report.employeeEmail) {
const newEmp = await queryOne<any>(`
INSERT INTO wr_employee_info (employee_name, employee_email, is_active, created_ip, created_email, updated_ip, updated_email)
VALUES ($1, $2, true, $3, $4, $3, $4)
RETURNING employee_id
`, [report.employeeName, report.employeeEmail, clientIp, adminEmail])
employeeId = newEmp.employee_id
isNewEmployee = true
}
projectIds.push(projectId)
if (!employeeId) {
results.push({
success: false,
employeeName: report.employeeName,
employeeEmail: report.employeeEmail,
error: '직원 정보가 없습니다.'
})
continue
}
// 2. 기존 주간보고 확인 (덮어쓰기)
const existingReport = await queryOne<any>(`
// 기존 보고 확인 및 삭제 (덮어쓰기)
const existing = await queryOne<any>(`
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])
`, [employeeId, body.reportYear, body.reportWeek])
let reportId: number
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
}
if (existingReport) {
// 기존 보고서 업데이트
reportId = existingReport.report_id
// 기존 프로젝트 실적 삭제
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
// 마스터 업데이트
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(`
// 주간보고 마스터 등록
const newReport = await queryOne<any>(`
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, submitted_at,
created_ip, created_email, updated_ip, updated_email
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
employeeId, body.reportYear, body.reportWeek, body.weekStartDate, body.weekEndDate,
report.issueDescription || null, report.vacationDescription || null, report.remarkDescription || null,
clientIp, adminEmail
])
reportId = newReport.report_id
const reportId = newReport.report_id
// 프로젝트별 Task 등록
for (const proj of report.projects) {
let projectId = proj.projectId
// 신규 프로젝트 생성
if (!projectId && proj.projectName) {
const year = new Date().getFullYear()
const codeResult = await queryOne<any>(`
SELECT COALESCE(MAX(CAST(SUBSTRING(project_code FROM 6) AS INTEGER)), 0) + 1 as next_num
FROM wr_project_info WHERE project_code LIKE $1
`, [`${year}-%`])
const projectCode = `${year}-${String(codeResult.next_num).padStart(3, '0')}`
const newProj = await queryOne<any>(`
INSERT INTO wr_project_info (project_code, project_name, project_status, created_ip, created_email, updated_ip, updated_email)
VALUES ($1, $2, 'IN_PROGRESS', $3, $4, $3, $4)
RETURNING project_id
`, [projectCode, proj.projectName, clientIp, adminEmail])
projectId = newProj.project_id
newProjects.push(proj.projectName)
}
// 3. 프로젝트별 실적 등록
for (let i = 0; i < report.projects.length; i++) {
const proj = report.projects[i]
const projectId = projectIds[i]
if (!projectId) continue
// 금주실적 Task 등록
for (const task of proj.workTasks || []) {
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)
`, [
reportId,
projectId,
proj.workDescription,
proj.planDescription,
clientIp,
ADMIN_EMAIL
])
) VALUES ($1, $2, 'WORK', $3, $4, $5, $6, $7, $6, $7)
`, [reportId, projectId, task.description, task.hours || 0, task.isCompleted !== false, clientIp, adminEmail])
}
// 직원 정보 조회
const employee = await queryOne<any>(`
SELECT employee_name FROM wr_employee_info WHERE employee_id = $1
`, [report.employeeId])
// 차주계획 Task 등록
for (const task of proj.planTasks || []) {
await execute(`
INSERT INTO wr_weekly_report_task (
report_id, project_id, task_type, task_description, task_hours,
created_ip, created_email, updated_ip, updated_email
) VALUES ($1, $2, 'PLAN', $3, $4, $5, $6, $5, $6)
`, [reportId, projectId, task.description, task.hours || 0, clientIp, adminEmail])
}
}
results.push({
success: true,
employeeId: 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
}

View File

@@ -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<any>(`
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<any>(`
SELECT employee_id, employee_name, employee_email
FROM wr_employee_info
WHERE is_active = true
`)
// 기존 프로젝트 목록 조회
const projects = await query<any>(`
SELECT project_id, project_code, project_name
FROM wr_project_info
WHERE project_status != 'COMPLETED'
`)
// 직원 및 프로젝트 매칭
const matchedReports = parsed.reports.map(report => {
let matchedEmployee = null
if (report.employeeEmail) {
matchedEmployee = employees.find(
(e: any) => e.employee_email.toLowerCase() === report.employeeEmail?.toLowerCase()
)
}
if (!matchedEmployee) {
matchedEmployee = employees.find(
(e: any) => e.employee_name === report.employeeName
)
}
const matchedProjects = report.projects.map(proj => {
const existingProject = projects.find((p: any) =>
p.project_name.includes(proj.projectName) ||
proj.projectName.includes(p.project_name)
)
return {
...proj,
matchedProjectId: existingProject?.project_id || null,
matchedProjectCode: existingProject?.project_code || null,
matchedProjectName: existingProject?.project_name || null,
isNewProject: !existingProject
}
})
return {
...report,
matchedEmployeeId: matchedEmployee?.employee_id || null,
matchedEmployeeName: matchedEmployee?.employee_name || null,
matchedEmployeeEmail: matchedEmployee?.employee_email || null,
isEmployeeMatched: !!matchedEmployee,
isNewEmployee: !matchedEmployee && !!report.employeeEmail,
projects: matchedProjects
}
})
return {
success: true,
parsed: {
reportYear: parsed.reportYear,
reportWeek: parsed.reportWeek,
weekStartDate: parsed.weekStartDate,
weekEndDate: parsed.weekEndDate,
reports: matchedReports
},
employees: employees.map((e: any) => ({
employeeId: e.employee_id,
employeeName: e.employee_name,
employeeEmail: e.employee_email
})),
projects: projects.map((p: any) => ({
projectId: p.project_id,
projectCode: p.project_code,
projectName: p.project_name
}))
}
})

View File

@@ -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<any>(`
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,

View File

@@ -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)])

View File

@@ -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
})
}
})

View File

@@ -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<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,
@@ -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) => ({
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,
workDescription: r.work_description,
planDescription: r.plan_description,
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,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,42 +28,61 @@ 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<any>(`
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,
rp.detail_id
r.week_end_date
FROM wr_weekly_report r
JOIN wr_weekly_report_project rp ON r.report_id = rp.report_id
WHERE rp.project_id = $1
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 r.report_id
`, [body.projectId, body.reportYear, body.reportWeek])
ORDER BY t.task_type, e.employee_name
`, [projectId, body.reportYear, body.reportWeek])
if (reports.length === 0) {
throw createError({ statusCode: 400, message: '취합할 보고서가 없습니다.' })
}
if (tasks.length === 0) continue
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 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
`, [body.projectId, body.reportYear, body.reportWeek])
let summaryId: number
`, [projectId, body.reportYear, body.reportWeek])
if (existing) {
// 기존 취합 업데이트
@@ -65,32 +90,44 @@ export default defineEventHandler(async (event) => {
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 = $3,
updated_email = $4
WHERE summary_id = $5
`, [reportIds, reportIds.length, clientIp, userEmail, existing.summary_id])
summaryId = existing.summary_id
updated_ip = $6,
updated_email = $7
WHERE summary_id = $8
`, [reportIds, reportIds.length, totalWorkHours, workSummary, planSummary, clientIp, userEmail, existing.summary_id])
} else {
// 새 취합 생성
const newSummary = await insertReturning<any>(`
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,
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, $8, $9)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11, $12, $11, $12)
RETURNING summary_id
`, [
body.projectId, body.reportYear, body.reportWeek,
projectId, body.reportYear, body.reportWeek,
weekStartDate, weekEndDate,
reportIds, reportIds.length,
reportIds, reportIds.length, totalWorkHours, workSummary, planSummary,
clientIp, userEmail
])
summaryId = newSummary.summary_id
}
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',
@@ -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<any>(`SELECT project_name FROM wr_project_info WHERE project_id = $1`, [projectId])
const projectName = project?.project_name || '프로젝트'
// Task를 실적/계획으로 분류
const workTasks = tasks.filter(t => t.task_type === 'WORK')
const planTasks = tasks.filter(t => t.task_type === 'PLAN')
// 금주 실적 요약
const workPrompt = `당신은 주간보고 취합 전문가입니다. 아래 금주 실적을 간결하게 요약해주세요.
## 프로젝트: ${projectName}
## 기간: ${year}${week}주차
## 금주 실적 (${workTasks.length}건)
${workTasks.map(t => `- [${t.author_name}] ${t.is_completed ? '완료' : '진행중'} | ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
## 요약 규칙
1. 완료된 작업과 진행 중인 작업을 구분하여 핵심만 요약
2. 동일/유사한 작업은 하나로 통합
3. 담당자 이름은 생략하고 내용 위주로 작성
4. 3~5줄 이내로 간결하게
5. 마크다운 리스트 형식으로 작성`
// 차주 계획 요약
const planPrompt = `당신은 주간보고 취합 전문가입니다. 아래 차주 계획을 간결하게 요약해주세요.
## 프로젝트: ${projectName}
## 기간: ${year}${week+1}주차 계획
## 차주 계획 (${planTasks.length}건)
${planTasks.map(t => `- [${t.author_name}] ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'}
## 요약 규칙
1. 주요 계획을 우선순위에 따라 요약
2. 동일/유사한 작업은 하나로 통합
3. 담당자 이름은 생략하고 내용 위주로 작성
4. 2~4줄 이내로 간결하게
5. 마크다운 리스트 형식으로 작성`
try {
const [workRes, planRes] = await Promise.all([
openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
{ role: 'user', content: workPrompt }
],
temperature: 0.3,
max_tokens: 500
}),
openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' },
{ role: 'user', content: planPrompt }
],
temperature: 0.3,
max_tokens: 500
})
])
return {
workSummary: workRes.choices[0]?.message?.content || '요약 없음',
planSummary: planRes.choices[0]?.message?.content || '요약 없음'
}
} catch (error) {
console.error('OpenAI 요약 생성 실패:', error)
return {
workSummary: '요약 생성 실패',
planSummary: '요약 생성 실패'
}
}
}

View File

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

View File

@@ -33,7 +33,8 @@ export default defineEventHandler(async (event) => {
const summaries = await query(sql, params)
return summaries.map((s: any) => ({
return {
summaries: summaries.map((s: any) => ({
summaryId: s.summary_id,
projectId: s.project_id,
projectName: s.project_name,
@@ -49,6 +50,9 @@ export default defineEventHandler(async (event) => {
reviewerComment: s.reviewer_comment,
reviewedAt: s.reviewed_at,
summaryStatus: s.summary_status,
aggregatedAt: s.aggregated_at
aggregatedAt: s.aggregated_at,
aiSummary: s.ai_summary,
aiSummaryAt: s.ai_summary_at
}))
}
})

View File

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

View File

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

View File

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

View File

@@ -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<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 !== 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: '주간보고가 삭제되었습니다.'
}
})

View File

@@ -27,21 +27,71 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
}
// 프로젝트별 실적 조회
const projects = await query<any>(`
// 같은 주차의 이전/다음 보고서 조회
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
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<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,
@@ -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]
}

View File

@@ -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<UpdateReportBody>(event)
const clientIp = getClientIp(event)
const userEmail = await getCurrentUserEmail(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 isAdmin = userEmail === ADMIN_EMAIL
// 보고서 조회 및 권한 확인
// 보고서 조회 및 권한 체크
const report = await queryOne<any>(`
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])
// 기존 Task 삭제 후 재등록
await execute(`DELETE FROM wr_weekly_report_task WHERE report_id = $1`, [reportId])
for (const proj of body.projects) {
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)
`, [
reportId,
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 }
return {
success: true,
message: '주간보고가 수정되었습니다.'
}
})

View File

@@ -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<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,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<CreateReportBody>(event)
const clientIp = getClientIp(event)
const userEmail = await getCurrentUserEmail(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 || ''
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(`
const existing = await queryOne<any>(`
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<any>(`
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: '주간보고가 작성되었습니다.'
}
})

View File

@@ -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<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 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<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,
@@ -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]
}

View File

@@ -4,9 +4,47 @@
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'
// 모델별 파라미터 설정
const MODEL_CONFIG: Record<string, { maxTokensParam: string; defaultMaxTokens: number }> = {
// 최신 모델 (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<string> {
export async function callOpenAI(
messages: ChatMessage[],
jsonMode = true,
model = DEFAULT_MODEL
): Promise<string> {
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 [
export async function callOpenAIVision(
systemPrompt: string,
imageBase64List: string[],
model = DEFAULT_MODEL
): Promise<string> {
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: 'system',
content: `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다.
role: 'user',
content: [
{ type: 'text', text: '이 이미지들에서 주간보고 내용을 추출해주세요.' },
...imageContents
]
}
],
temperature: 0.1,
[config.maxTokensParam]: config.defaultMaxTokens,
response_format: { type: 'json_object' }
}
입력된 텍스트에서 다음 정보를 추출하세요:
1. 직원 정보 (이름, 이메일)
2. 프로젝트별 실적 (프로젝트명, 금주실적, 차주계획)
3. 공통사항 (이슈/리스크, 휴가일정, 기타사항)
4. 보고 주차 정보 (텍스트에서 날짜나 주차 정보 추출)
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
}
/**
* 주간보고 분석 시스템 프롬프트 (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 }
]
}

View File

@@ -7,12 +7,26 @@
<i class="bi bi-file-earmark-arrow-up me-2"></i>주간보고 일괄등록
</h4>
<!-- Step 1: 텍스트 입력 -->
<!-- Step 1: 입력 방식 선택 -->
<div class="card mb-4" v-if="step === 1">
<div class="card-header">
<strong>1단계:</strong> 주간보고 내용 붙여넣기
<strong>1단계:</strong> 주간보고 내용 입력
<ul class="nav nav-tabs card-header-tabs float-end">
<li class="nav-item">
<a class="nav-link" :class="{ active: inputMode === 'text' }" href="#" @click.prevent="inputMode = 'text'">
<i class="bi bi-fonts me-1"></i>텍스트
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: inputMode === 'image' }" href="#" @click.prevent="inputMode = 'image'">
<i class="bi bi-image me-1"></i>이미지
</a>
</li>
</ul>
</div>
<div class="card-body">
<!-- 텍스트 입력 모드 -->
<div v-if="inputMode === 'text'">
<div class="mb-3">
<label class="form-label">직원들의 주간보고 내용을 붙여넣으세요</label>
<textarea
@@ -21,19 +35,19 @@
rows="15"
placeholder="예시:
홍길동 (hong@turbosoft.co.kr)
- PIMS 고도화: API 개발 완료
- 차주: 테스트 진행
- PIMS 고도화: API 개발 완료 (8시간), UI 수정 (4시간)
- 차주: 테스트 진행 예정 (16시간)
- 이슈: 서버 메모리 부족
김철수 (kim@turbosoft.co.kr)
- I-PIMS 유지보수: 버그수정 3건
- I-PIMS 유지보수: 버그수정 3건 (12시간)
- 휴가: 1/10(금) 연차"
></textarea>
</div>
<div class="d-flex justify-content-end">
<button
class="btn btn-primary"
@click="parseReport"
@click="parseText"
:disabled="isParsing || !rawText.trim()"
>
<span v-if="isParsing" class="spinner-border spinner-border-sm me-1"></span>
@@ -42,6 +56,65 @@
</button>
</div>
</div>
<!-- 이미지 입력 모드 -->
<div v-if="inputMode === 'image'">
<div class="mb-3">
<label class="form-label">카카오톡, 슬랙 메신저 캡처 이미지를 업로드하세요</label>
<div
class="upload-zone p-5 text-center border rounded"
:class="{ 'border-primary bg-light': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="($refs.fileInput as HTMLInputElement).click()"
>
<input
ref="fileInput"
type="file"
multiple
accept="image/*"
class="d-none"
@change="handleFileSelect"
/>
<i class="bi bi-cloud-arrow-up display-4 text-muted"></i>
<p class="mt-2 mb-0 text-muted">
이미지를 드래그하거나 클릭해서 업로드<br>
<small>(최대 10, PNG/JPG)</small>
</p>
</div>
</div>
<div v-if="uploadedImages.length > 0" class="mb-3">
<label class="form-label">업로드된 이미지 ({{ uploadedImages.length }})</label>
<div class="d-flex flex-wrap gap-2">
<div v-for="(img, idx) in uploadedImages" :key="idx" class="position-relative">
<img :src="img" class="rounded border" style="width: 120px; height: 120px; object-fit: cover;" />
<button
type="button"
class="btn btn-sm btn-danger position-absolute top-0 end-0 rounded-circle"
style="transform: translate(30%, -30%); width: 24px; height: 24px; padding: 0;"
@click="removeImage(idx)"
>
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button
class="btn btn-primary"
@click="parseImages"
:disabled="isParsing || uploadedImages.length === 0"
>
<span v-if="isParsing" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-robot me-1"></i>
AI 분석 ({{ uploadedImages.length }})
</button>
</div>
</div>
</div>
</div>
<!-- Step 2: 분석 결과 확인 -->
@@ -55,22 +128,32 @@
</div>
<div class="card-body">
<!-- 주차 정보 -->
<div class="row mb-4">
<div class="col-md-2">
<label class="form-label">연도</label>
<input type="number" class="form-control" v-model="parsedData.reportYear" />
<div class="row mb-4 align-items-end">
<div class="col-auto">
<label class="form-label">보고 주차</label>
<div class="input-group">
<button class="btn btn-outline-secondary" type="button" @click="changeWeek(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<span class="input-group-text bg-white" style="min-width: 180px;">
<strong>{{ parsedData.reportYear }} {{ parsedData.reportWeek }}주차</strong>
</span>
<button class="btn btn-outline-secondary" type="button" @click="changeWeek(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
<div class="col-md-2">
<label class="form-label">주차</label>
<input type="number" class="form-control" v-model="parsedData.reportWeek" />
</div>
<div class="col-md-3">
<label class="form-label">시작일</label>
<input type="date" class="form-control" v-model="parsedData.weekStartDate" />
<div class="col-auto">
<label class="form-label">기간</label>
<div class="input-group">
<input type="date" class="form-control" v-model="parsedData.weekStartDate" @change="updateWeekFromDate" />
<span class="input-group-text">~</span>
<input type="date" class="form-control" v-model="parsedData.weekEndDate" readonly />
</div>
<div class="col-md-3">
<label class="form-label">종료일</label>
<input type="date" class="form-control" v-model="parsedData.weekEndDate" />
</div>
<div class="col-auto">
<button class="btn btn-outline-primary btn-sm" @click="setLastWeek">지난주</button>
<button class="btn btn-outline-secondary btn-sm ms-1" @click="setThisWeek">이번주</button>
</div>
</div>
@@ -79,9 +162,10 @@
<!-- 직원별 보고서 -->
<div v-for="(report, rIdx) in parsedData.reports" :key="rIdx" class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"
:class="report.isEmployeeMatched ? 'bg-light' : 'bg-warning bg-opacity-25'">
:class="getHeaderClass(report)">
<div>
<span v-if="report.isEmployeeMatched" class="badge bg-success me-2">매칭됨</span>
<span v-if="report.isEmployeeMatched" class="badge bg-success me-2">기존직원</span>
<span v-else-if="report.isNewEmployee" class="badge bg-info me-2">신규직원</span>
<span v-else class="badge bg-warning text-dark me-2">매칭필요</span>
<strong>{{ report.employeeName }}</strong>
<small class="text-muted ms-2">{{ report.employeeEmail || '이메일 없음' }}</small>
@@ -95,47 +179,105 @@
<!-- 직원 선택 -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">직원 선택 <span class="text-danger">*</span></label>
<select class="form-select" v-model="report.matchedEmployeeId"
:class="{'is-invalid': !report.matchedEmployeeId}">
<label class="form-label">직원</label>
<div class="form-check mb-2">
<input type="radio" class="form-check-input" :id="'emp-existing-'+rIdx" :value="false" v-model="report.createNewEmployee" />
<label class="form-check-label" :for="'emp-existing-'+rIdx">기존 직원 선택</label>
</div>
<select v-if="!report.createNewEmployee" class="form-select" v-model="report.matchedEmployeeId"
:class="{'is-invalid': !report.matchedEmployeeId && !report.createNewEmployee}">
<option :value="null">-- 선택 --</option>
<option v-for="emp in employees" :key="emp.employeeId" :value="emp.employeeId">
{{ emp.employeeName }} ({{ emp.employeeEmail }})
</option>
</select>
<div class="form-check mt-2">
<input type="radio" class="form-check-input" :id="'emp-new-'+rIdx" :value="true" v-model="report.createNewEmployee" />
<label class="form-check-label" :for="'emp-new-'+rIdx">신규 직원 생성</label>
</div>
<div v-if="report.createNewEmployee" class="mt-2 p-3 bg-light rounded">
<div class="mb-2">
<label class="form-label small">이름 <span class="text-danger">*</span></label>
<input type="text" class="form-control form-control-sm" v-model="report.employeeName" />
</div>
<div>
<label class="form-label small">이메일 <span class="text-danger">*</span></label>
<input type="email" class="form-control form-control-sm" v-model="report.employeeEmail" />
</div>
</div>
</div>
</div>
<!-- 프로젝트별 실적 -->
<div v-for="(proj, pIdx) in report.projects" :key="pIdx" class="border rounded p-3 mb-2">
<div class="row mb-2">
<!-- 프로젝트별 Task -->
<div v-for="(proj, pIdx) in report.projects" :key="pIdx" class="border rounded p-3 mb-3">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">프로젝트</label>
<div class="input-group">
<label class="form-label">
프로젝트
<span v-if="!proj.matchedProjectId" class="badge bg-info ms-1">신규생성</span>
</label>
<select class="form-select" v-model="proj.matchedProjectId">
<option :value="null"> 신규 생성</option>
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
{{ p.projectCode }} - {{ p.projectName }}
</option>
</select>
</div>
<input
v-if="!proj.matchedProjectId"
type="text"
class="form-control mt-2"
v-model="proj.projectName"
placeholder="신규 프로젝트명"
/>
<input v-if="!proj.matchedProjectId" type="text" class="form-control mt-2"
v-model="proj.projectName" placeholder="신규 프로젝트명" />
</div>
</div>
<div class="row">
<!-- 금주 실적 Task -->
<div class="col-md-6">
<label class="form-label">금주 실적</label>
<textarea class="form-control" v-model="proj.workDescription" rows="2"></textarea>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-primary">금주 실적</label>
<button type="button" class="btn btn-sm btn-outline-primary" @click="addParsedTask(report, proj, 'work')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in proj.workTasks" :key="'work-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<div class="form-check pt-1">
<input type="checkbox" class="form-check-input" v-model="task.isCompleted" />
</div>
<textarea class="form-control form-control-sm auto-resize" v-model="task.description"
@input="autoResize($event)" placeholder="작업 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 60px;"
v-model.number="task.hours" min="0" step="0.5" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeParsedTask(proj.workTasks, tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<!-- 차주 계획 Task -->
<div class="col-md-6">
<label class="form-label">차주 계획</label>
<textarea class="form-control" v-model="proj.planDescription" rows="2"></textarea>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-success">차주 계획</label>
<button type="button" class="btn btn-sm btn-outline-success" @click="addParsedTask(report, proj, 'plan')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in proj.planTasks" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<textarea class="form-control form-control-sm auto-resize" v-model="task.description"
@input="autoResize($event)" placeholder="계획 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 60px;"
v-model.number="task.hours" min="0" step="0.5" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeParsedTask(proj.planTasks, tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -143,16 +285,16 @@
<!-- 공통사항 -->
<div class="row mt-3">
<div class="col-md-4">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" v-model="report.issueDescription" rows="2"></textarea>
<label class="form-label small">이슈/리스크</label>
<textarea class="form-control form-control-sm" v-model="report.issueDescription" rows="2"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">휴가일정</label>
<textarea class="form-control" v-model="report.vacationDescription" rows="2"></textarea>
<label class="form-label small">휴가일정</label>
<textarea class="form-control form-control-sm" v-model="report.vacationDescription" rows="2"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">기타사항</label>
<textarea class="form-control" v-model="report.remarkDescription" rows="2"></textarea>
<label class="form-label small">기타사항</label>
<textarea class="form-control form-control-sm" v-model="report.remarkDescription" rows="2"></textarea>
</div>
</div>
</div>
@@ -162,11 +304,7 @@
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary" @click="step = 1">취소</button>
<button
class="btn btn-primary"
@click="bulkRegister"
:disabled="isRegistering || !canRegister"
>
<button class="btn btn-primary" @click="bulkRegister" :disabled="isRegistering || !canRegister">
<span v-if="isRegistering" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-check-lg me-1"></i>
일괄 등록 ({{ enabledCount }})
@@ -190,10 +328,17 @@
<span>
<i :class="r.success ? 'bi bi-check-circle text-success' : 'bi bi-x-circle text-danger'" class="me-2"></i>
{{ r.employeeName }}
<small class="text-muted ms-1">({{ r.employeeEmail }})</small>
</span>
<span v-if="r.success" class="badge" :class="r.isUpdate ? 'bg-warning' : 'bg-success'">
<span v-if="r.success">
<span v-if="r.isNewEmployee" class="badge bg-info me-1">직원생성</span>
<span v-if="r.newProjects?.length" class="badge bg-secondary me-1">
프로젝트 {{ r.newProjects.length }} 생성
</span>
<span class="badge" :class="r.isUpdate ? 'bg-warning' : 'bg-success'">
{{ r.isUpdate ? '덮어쓰기' : '신규등록' }}
</span>
</span>
<span v-else class="text-danger small">{{ r.error }}</span>
</li>
</ul>
@@ -216,7 +361,10 @@ const { fetchCurrentUser } = useAuth()
const router = useRouter()
const step = ref(1)
const inputMode = ref<'text' | 'image'>('text')
const rawText = ref('')
const uploadedImages = ref<string[]>([])
const isDragging = ref(false)
const isParsing = ref(false)
const isRegistering = ref(false)
@@ -238,7 +386,12 @@ const enabledCount = computed(() =>
const canRegister = computed(() => {
const enabledReports = parsedData.value.reports?.filter((r: any) => r.enabled) || []
return enabledReports.length > 0 && enabledReports.every((r: any) => r.matchedEmployeeId)
return enabledReports.length > 0 && enabledReports.every((r: any) => {
if (r.createNewEmployee) {
return r.employeeName && r.employeeEmail
}
return r.matchedEmployeeId
})
})
onMounted(async () => {
@@ -248,7 +401,6 @@ onMounted(async () => {
return
}
// 관리자 체크
if (user.employeeEmail !== 'coziny@gmail.com') {
alert('관리자만 접근할 수 있습니다.')
router.push('/')
@@ -256,19 +408,198 @@ onMounted(async () => {
}
})
async function parseReport() {
function getHeaderClass(report: any) {
if (report.isEmployeeMatched) return 'bg-light'
if (report.isNewEmployee) return 'bg-info bg-opacity-25'
return 'bg-warning bg-opacity-25'
}
// 시간 표시 함수
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '-'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}h`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}h`
}
// textarea 자동 높이 조절
function autoResize(e: Event) {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
function resizeAllTextareas() {
nextTick(() => {
document.querySelectorAll('.auto-resize').forEach((el) => {
const textarea = el as HTMLTextAreaElement
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
})
})
}
// 이미지 관련 함수들
function handleDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (files) processFiles(files)
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) processFiles(input.files)
}
function processFiles(files: FileList) {
const maxFiles = 10 - uploadedImages.value.length
const toProcess = Array.from(files).slice(0, maxFiles)
toProcess.forEach(file => {
if (!file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = (e) => {
if (e.target?.result) {
uploadedImages.value.push(e.target.result as string)
}
}
reader.readAsDataURL(file)
})
}
function removeImage(idx: number) {
uploadedImages.value.splice(idx, 1)
}
// Task 추가/삭제
function addParsedTask(report: any, proj: any, type: 'work' | 'plan') {
const taskArray = type === 'work' ? proj.workTasks : proj.planTasks
if (type === 'work') {
taskArray.push({ description: '', hours: 0, isCompleted: true })
} else {
taskArray.push({ description: '', hours: 0 })
}
}
function removeParsedTask(taskArray: any[], idx: number) {
if (taskArray.length > 0) {
taskArray.splice(idx, 1)
}
}
// 주차 계산 함수들
function getMonday(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
return d
}
function getSunday(monday: Date): Date {
const d = new Date(monday)
d.setDate(d.getDate() + 6)
return d
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}
function getWeekNumber(date: Date): { year: number; week: number } {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7)
const week1 = new Date(d.getFullYear(), 0, 4)
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
return { year: d.getFullYear(), week: weekNum }
}
function setWeekDates(monday: Date) {
const sunday = getSunday(monday)
const weekInfo = getWeekNumber(monday)
parsedData.value.weekStartDate = formatDate(monday)
parsedData.value.weekEndDate = formatDate(sunday)
parsedData.value.reportYear = weekInfo.year
parsedData.value.reportWeek = weekInfo.week
}
function changeWeek(delta: number) {
const currentMonday = new Date(parsedData.value.weekStartDate)
currentMonday.setDate(currentMonday.getDate() + (delta * 7))
setWeekDates(currentMonday)
}
function setLastWeek() {
const today = new Date()
const lastWeekMonday = getMonday(today)
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
setWeekDates(lastWeekMonday)
}
function setThisWeek() {
const today = new Date()
const thisWeekMonday = getMonday(today)
setWeekDates(thisWeekMonday)
}
function updateWeekFromDate() {
const startDate = new Date(parsedData.value.weekStartDate)
const monday = getMonday(startDate)
setWeekDates(monday)
}
// 분석 결과 처리
function handleParseResult(res: any) {
parsedData.value = res.parsed
parsedData.value.reports.forEach((r: any) => {
r.enabled = true
r.createNewEmployee = r.isNewEmployee
// workTasks, planTasks가 없으면 빈 배열로 초기화
r.projects.forEach((p: any) => {
p.workTasks = (p.workTasks || []).map((t: any) => ({
...t,
isCompleted: t.isCompleted !== false
}))
p.planTasks = p.planTasks || []
})
})
employees.value = res.employees
projects.value = res.projects
step.value = 2
resizeAllTextareas()
}
// 텍스트 분석
async function parseText() {
isParsing.value = true
try {
const res = await $fetch<any>('/api/admin/parse-report', {
method: 'POST',
body: { rawText: rawText.value }
})
handleParseResult(res)
} catch (e: any) {
alert(e.data?.message || e.message || 'AI 분석에 실패했습니다.')
} finally {
isParsing.value = false
}
}
parsedData.value = res.parsed
parsedData.value.reports.forEach((r: any) => r.enabled = true)
employees.value = res.employees
projects.value = res.projects
step.value = 2
// 이미지 분석
async function parseImages() {
isParsing.value = true
try {
const res = await $fetch<any>('/api/admin/parse-image', {
method: 'POST',
body: { images: uploadedImages.value }
})
handleParseResult(res)
} catch (e: any) {
alert(e.data?.message || e.message || 'AI 분석에 실패했습니다.')
} finally {
@@ -282,12 +613,14 @@ async function bulkRegister() {
const enabledReports = parsedData.value.reports
.filter((r: any) => r.enabled)
.map((r: any) => ({
employeeId: r.matchedEmployeeId,
employeeId: r.createNewEmployee ? null : r.matchedEmployeeId,
employeeName: r.employeeName,
employeeEmail: r.employeeEmail,
projects: r.projects.map((p: any) => ({
projectId: p.matchedProjectId,
projectName: p.projectName,
workDescription: p.workDescription,
planDescription: p.planDescription
workTasks: (p.workTasks || []).filter((t: any) => t.description?.trim()),
planTasks: (p.planTasks || []).filter((t: any) => t.description?.trim())
})),
issueDescription: r.issueDescription,
vacationDescription: r.vacationDescription,
@@ -317,6 +650,7 @@ async function bulkRegister() {
function reset() {
step.value = 1
rawText.value = ''
uploadedImages.value = []
parsedData.value = {
reportYear: new Date().getFullYear(),
reportWeek: 1,
@@ -327,3 +661,32 @@ function reset() {
registerResult.value = {}
}
</script>
<style scoped>
.upload-zone {
cursor: pointer;
transition: all 0.2s;
border-style: dashed !important;
border-width: 2px !important;
}
.upload-zone:hover {
border-color: #0d6efd !important;
background-color: #f8f9fa;
}
.nav-tabs .nav-link {
color: #6c757d;
}
.nav-tabs .nav-link.active {
color: #0d6efd;
font-weight: 500;
}
.auto-resize {
resize: none;
overflow: hidden;
min-height: 38px;
}
</style>

View File

@@ -43,6 +43,19 @@
</div>
</div>
<!-- AI 요약 -->
<div class="card mb-4" v-if="summary.aiSummary">
<div class="card-header bg-primary bg-opacity-10">
<i class="bi bi-robot me-2"></i><strong>AI 요약</strong>
<small class="text-muted ms-2" v-if="summary.aiSummaryAt">
({{ formatDateTime(summary.aiSummaryAt) }})
</small>
</div>
<div class="card-body">
<div class="ai-summary" v-html="renderMarkdown(summary.aiSummary)"></div>
</div>
</div>
<!-- PM 검토 영역 -->
<div class="card mb-4">
<div class="card-header">
@@ -121,17 +134,29 @@
<i class="bi bi-check-circle me-1"></i>금주 실적
</label>
<div class="p-2 bg-light rounded">
<pre class="mb-0 small" style="white-space: pre-wrap;">{{ report.workDescription || '-' }}</pre>
<div v-if="report.workTasks && report.workTasks.length > 0">
<div v-for="(task, idx) in report.workTasks" :key="idx" class="mb-1">
<span class="badge me-1" :class="task.isCompleted ? 'bg-success' : 'bg-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span class="small" style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.hours }}h)</span>
</div>
</div>
<div v-else class="small text-muted">-</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="mb-3" v-if="report.planDescription">
<div class="mb-3" v-if="report.planTasks && report.planTasks.length > 0">
<label class="form-label text-muted small">
<i class="bi bi-calendar-event me-1"></i>차주 계획
</label>
<div class="p-2 bg-light rounded">
<pre class="mb-0 small" style="white-space: pre-wrap;">{{ report.planDescription }}</pre>
<div v-for="(task, idx) in report.planTasks" :key="idx" class="mb-1">
<span class="small" style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.hours }}h)</span>
</div>
</div>
</div>
@@ -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, '<h5 class="mt-3 mb-2">$1</h5>')
.replace(/^## (.+)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
.replace(/^# (.+)$/gm, '<h3 class="mt-3 mb-2">$1</h3>')
// 볼드
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// 이탤릭
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// 리스트
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>\n?)+/g, '<ul class="mb-2">$&</ul>')
// 줄바꿈
.replace(/\n/g, '<br>')
}
</script>
<style scoped>
.ai-summary {
line-height: 1.7;
}
.ai-summary h3, .ai-summary h4, .ai-summary h5 {
color: #333;
}
.ai-summary ul {
padding-left: 1.5rem;
}
.ai-summary li {
margin-bottom: 0.25rem;
}
</style>

View File

@@ -0,0 +1,426 @@
<template>
<div>
<AppHeader />
<div class="container-fluid py-4">
<div class="mb-4">
<NuxtLink to="/report/summary" class="text-decoration-none">
<i class="bi bi-arrow-left me-1"></i> 목록으로
</NuxtLink>
</div>
<div v-if="weekInfo">
<!-- 주차 헤더 -->
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-calendar-week me-2"></i>
{{ weekInfo.reportYear }} {{ weekInfo.reportWeek }}주차 취합 보고서
</h5>
<button class="btn btn-light btn-sm" @click="doReaggregate" :disabled="isReaggregating">
<span v-if="isReaggregating">
<span class="spinner-border spinner-border-sm me-1"></span>처리중...
</span>
<span v-else>
<i class="bi bi-arrow-repeat me-1"></i>취합 다시하기
</span>
</button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<label class="form-label text-muted small">기간</label>
<p class="mb-0 fw-bold">{{ formatDate(weekInfo.weekStartDate) }} ~ {{ formatDate(weekInfo.weekEndDate) }}</p>
</div>
<div class="col-md-3">
<label class="form-label text-muted small">프로젝트</label>
<p class="mb-0 fw-bold">{{ weekInfo.totalProjects }}</p>
</div>
<div class="col-md-3">
<label class="form-label text-muted small"> 투입시간</label>
<p class="mb-0 fw-bold">{{ formatHours(weekInfo.totalWorkHours) }}</p>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-outline-primary btn-sm" @click="exportToExcel">
<i class="bi bi-file-earmark-excel me-1"></i>Excel 다운로드
</button>
</div>
</div>
</div>
</div>
<!-- 프로젝트별 실적/계획 테이블 -->
<div class="card mb-4">
<div class="card-header">
<i class="bi bi-table me-2"></i>프로젝트별 주간보고
</div>
<div class="table-responsive">
<table class="table table-bordered mb-0 summary-table">
<thead class="table-light">
<tr>
<th style="width: 280px; min-width: 280px;" class="align-middle text-center">프로젝트</th>
<th class="align-middle text-center">
금주 실적
<i class="bi bi-robot text-info ms-1" title="AI 요약"></i>
</th>
<th class="align-middle text-center">
차주 계획
<i class="bi bi-robot text-info ms-1" title="AI 요약"></i>
</th>
</tr>
</thead>
<tbody>
<tr v-for="proj in projects" :key="proj.projectId">
<td class="project-cell">
<div class="fw-bold text-primary mb-2">{{ proj.projectName }}</div>
<!-- 인원별 실적/계획 미니 테이블 -->
<table class="table table-sm table-bordered mb-0 mini-table" v-if="proj.memberHours?.length > 0">
<thead>
<tr class="table-secondary">
<th class="text-center" style="width: 40%">개발자</th>
<th class="text-center" style="width: 30%">실적</th>
<th class="text-center" style="width: 30%">계획</th>
</tr>
</thead>
<tbody>
<tr v-for="(mh, idx) in proj.memberHours" :key="idx">
<td class="small">{{ mh.name }}</td>
<td class="text-center">
<span class="badge bg-primary">{{ formatMemberHours(mh.workHours) }}</span>
</td>
<td class="text-center">
<span class="badge bg-info">{{ formatMemberHours(mh.planHours) }}</span>
</td>
</tr>
</tbody>
<tfoot>
<tr class="table-light fw-bold">
<td class="text-center small">합계</td>
<td class="text-center">
<span class="badge bg-primary">{{ formatMemberHours(sumHours(proj.memberHours, 'workHours')) }}</span>
</td>
<td class="text-center">
<span class="badge bg-info">{{ formatMemberHours(sumHours(proj.memberHours, 'planHours')) }}</span>
</td>
</tr>
</tfoot>
</table>
</td>
<!-- 금주 실적 -->
<td class="task-cell">
<div v-if="!showRaw[proj.projectId]?.work">
<div class="ai-badge mb-2">
<i class="bi bi-robot me-1"></i>AI 요약
</div>
<div class="ai-content" v-html="renderMarkdown(proj.aiWorkSummary || '요약 없음')"></div>
<button class="btn btn-sm btn-link p-0 mt-2" @click="toggleRaw(proj.projectId, 'work')">
<i class="bi bi-list-ul me-1"></i>원문보기 ({{ proj.workTasks.length }})
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-link p-0 mb-2" @click="toggleRaw(proj.projectId, 'work')">
<i class="bi bi-robot me-1"></i>AI 요약보기
</button>
<div v-if="proj.workTasks.length > 0">
<div v-for="(task, idx) in proj.workTasks" :key="'w'+idx" class="task-item">
<span class="badge me-1" :class="task.isCompleted ? 'bg-success' : 'bg-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span class="task-desc">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.authorName }}, {{ task.hours }}h)</span>
</div>
</div>
<div v-else class="text-muted">-</div>
</div>
</td>
<!-- 차주 계획 -->
<td class="task-cell">
<div v-if="!showRaw[proj.projectId]?.plan">
<div class="ai-badge mb-2">
<i class="bi bi-robot me-1"></i>AI 요약
</div>
<div class="ai-content" v-html="renderMarkdown(proj.aiPlanSummary || '요약 없음')"></div>
<button class="btn btn-sm btn-link p-0 mt-2" @click="toggleRaw(proj.projectId, 'plan')">
<i class="bi bi-list-ul me-1"></i>원문보기 ({{ proj.planTasks.length }})
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-link p-0 mb-2" @click="toggleRaw(proj.projectId, 'plan')">
<i class="bi bi-robot me-1"></i>AI 요약보기
</button>
<div v-if="proj.planTasks.length > 0">
<div v-for="(task, idx) in proj.planTasks" :key="'p'+idx" class="task-item">
<span class="task-desc">{{ task.description }}</span>
<span class="text-muted small ms-1">({{ task.authorName }}, {{ task.hours }}h)</span>
</div>
</div>
<div v-else class="text-muted">-</div>
</div>
</td>
</tr>
<tr v-if="projects.length === 0">
<td colspan="3" class="text-center py-5 text-muted">
데이터가 없습니다.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 인원별 시간 현황 -->
<div class="card">
<div class="card-header">
<i class="bi bi-people me-2"></i>인원별 시간 현황
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>이름</th>
<th class="text-center" style="width: 120px">금주 수행</th>
<th class="text-center" style="width: 120px">차주 계획</th>
<th class="text-center" style="width: 150px">차주 여유</th>
<th style="width: 200px">여유율</th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.employeeId">
<td>
<i class="bi bi-person me-1"></i>{{ m.employeeName }}
</td>
<td class="text-center">
<span class="badge bg-primary">{{ formatMemberHours(m.workHours) }}</span>
</td>
<td class="text-center">
<span class="badge bg-info">{{ formatMemberHours(m.planHours) }}</span>
</td>
<td class="text-center">
<span class="badge" :class="getAvailableClass(m.availableHours)">
{{ formatMemberHours(m.availableHours) }}
</span>
</td>
<td>
<div class="progress" style="height: 20px;">
<div
class="progress-bar"
:class="getProgressClass(m.planHours)"
:style="{ width: Math.min(100, (m.planHours / 40) * 100) + '%' }"
>
{{ Math.round((m.planHours / 40) * 100) }}%
</div>
</div>
</td>
</tr>
<tr v-if="members.length === 0">
<td colspan="5" class="text-center py-3 text-muted">
데이터가 없습니다.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="text-center py-5" v-else-if="isLoading">
<div class="spinner-border text-primary"></div>
<p class="mt-2 text-muted">로딩중...</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
const router = useRouter()
const route = useRoute()
const { fetchCurrentUser } = useAuth()
const weekInfo = ref<any>(null)
const projects = ref<any[]>([])
const members = ref<any[]>([])
const isLoading = ref(true)
const isReaggregating = ref(false)
const showRaw = reactive<Record<number, { work: boolean, plan: boolean }>>({})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
await loadData()
})
async function loadData() {
isLoading.value = true
try {
const year = route.params.year
const week = route.params.week
const res = await $fetch<{ weekInfo: any; projects: any[]; members: any[] }>('/api/report/summary/week/detail', {
query: { year, week }
})
weekInfo.value = res.weekInfo
projects.value = res.projects || []
members.value = res.members || []
for (const p of projects.value) {
showRaw[p.projectId] = { work: false, plan: false }
}
} catch (e: any) {
alert(e.data?.message || '데이터를 불러오는데 실패했습니다.')
router.push('/report/summary')
} finally {
isLoading.value = false
}
}
async function doReaggregate() {
if (!confirm('해당 주차의 모든 프로젝트를 다시 취합하시겠습니까?\nAI 요약이 새로 생성됩니다.')) {
return
}
isReaggregating.value = true
try {
const projectIds = projects.value.map(p => p.projectId)
await $fetch('/api/report/summary/aggregate', {
method: 'POST',
body: {
projectIds,
reportYear: weekInfo.value.reportYear,
reportWeek: weekInfo.value.reportWeek
}
})
alert('취합이 완료되었습니다.')
await loadData()
} catch (e: any) {
alert(e.data?.message || '취합에 실패했습니다.')
} finally {
isReaggregating.value = false
}
}
function toggleRaw(projectId: number, type: 'work' | 'plan') {
if (!showRaw[projectId]) {
showRaw[projectId] = { work: false, plan: false }
}
showRaw[projectId][type] = !showRaw[projectId][type]
}
function formatDate(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remain = hours % 8
if (days === 0) return `${remain}h`
if (remain === 0) return `${days}`
return `${days}${remain}h`
}
function formatMemberHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remain = hours % 8
if (days === 0) return `${hours}h`
if (remain === 0) return `${days}`
return `${days}${remain}h`
}
function sumHours(members: any[], key: string): number {
if (!members || members.length === 0) return 0
return members.reduce((sum, m) => sum + (m[key] || 0), 0)
}
function renderMarkdown(text: string): string {
if (!text) return ''
return text
.replace(/^### (.+)$/gm, '<strong>$1</strong><br>')
.replace(/^## (.+)$/gm, '<strong>$1</strong><br>')
.replace(/^# (.+)$/gm, '<strong>$1</strong><br>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '• $1<br>')
.replace(/\n/g, '<br>')
}
function getAvailableClass(hours: number): string {
if (hours >= 16) return 'bg-success'
if (hours >= 8) return 'bg-warning text-dark'
return 'bg-danger'
}
function getProgressClass(planHours: number): string {
const percent = (planHours / 40) * 100
if (percent >= 100) return 'bg-danger'
if (percent >= 80) return 'bg-warning'
return 'bg-success'
}
function exportToExcel() {
alert('Excel 다운로드 기능은 준비 중입니다.')
}
</script>
<style scoped>
.summary-table {
table-layout: fixed;
}
.project-cell {
vertical-align: top;
background-color: #f8f9fa;
padding: 0.75rem;
}
.task-cell {
vertical-align: top;
padding: 0.75rem;
}
.task-item {
padding: 0.25rem 0;
border-bottom: 1px dashed #eee;
}
.task-item:last-child {
border-bottom: none;
}
.task-desc {
white-space: pre-wrap;
word-break: break-word;
}
.ai-badge {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.ai-content {
font-size: 0.9rem;
line-height: 1.6;
color: #333;
}
.mini-table {
font-size: 0.8rem;
}
.mini-table th {
padding: 0.25rem 0.5rem;
font-weight: 600;
}
.mini-table td {
padding: 0.2rem 0.4rem;
}
.mini-table .badge {
font-size: 0.7rem;
}
</style>

View File

@@ -6,7 +6,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4><i class="bi bi-collection me-2"></i>취합 보고서</h4>
<p class="text-muted mb-0">프로젝트별 주간보고 취합 목록</p>
<p class="text-muted mb-0">주차별 취합 보고서 목록</p>
</div>
<button class="btn btn-primary" @click="showAggregateModal = true">
<i class="bi bi-plus-lg me-1"></i> 취합하기
@@ -16,81 +16,68 @@
<!-- 필터 -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">프로젝트</label>
<select class="form-select" v-model="filter.projectId">
<option value="">전체</option>
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
{{ p.projectName }}
</option>
</select>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-2">
<label class="form-label">연도</label>
<select class="form-select" v-model="filter.year">
<select class="form-select" v-model="filter.year" @change="loadWeeklyList">
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-outline-secondary" @click="loadSummaries">
<i class="bi bi-search me-1"></i> 조회
</button>
</div>
</div>
</div>
</div>
<!-- 취합 목록 -->
<!-- 주차별 목록 -->
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 80px">주차</th>
<th style="width: 120px">주차</th>
<th style="width: 180px">기간</th>
<th>프로젝트</th>
<th style="width: 150px">기간</th>
<th style="width: 100px">참여인원</th>
<th style="width: 100px"> 시간</th>
<th style="width: 100px">상태</th>
<th style="width: 150px">취합일시</th>
<th style="width: 80px">상세</th>
</tr>
</thead>
<tbody>
<tr v-for="summary in summaries" :key="summary.summaryId">
<tr v-for="week in weeklyList" :key="week.reportWeek">
<td>
<strong>W{{ String(summary.reportWeek).padStart(2, '0') }}</strong>
</td>
<td>{{ summary.projectName }}</td>
<td>
<small>{{ formatDateRange(summary.weekStartDate, summary.weekEndDate) }}</small>
<strong class="text-primary">{{ week.reportWeek }}주차</strong>
</td>
<td>
<span class="badge bg-primary">{{ summary.memberCount }}</span>
<small>{{ formatDateRange(week.weekStartDate, week.weekEndDate) }}</small>
</td>
<td>
{{ summary.totalWorkHours ? summary.totalWorkHours + 'h' : '-' }}
</td>
<td>
<span :class="getStatusBadgeClass(summary.summaryStatus)">
{{ getStatusText(summary.summaryStatus) }}
<span class="badge bg-secondary me-1" v-for="p in week.projects.slice(0, 3)" :key="p">
{{ p }}
</span>
<span v-if="week.projects.length > 3" class="text-muted small">
+{{ week.projects.length - 3 }}
</span>
</td>
<td>
<small>{{ formatDateTime(summary.aggregatedAt) }}</small>
<span class="badge bg-primary">{{ week.totalMembers }}</span>
</td>
<td>
{{ week.totalWorkHours ? formatHours(week.totalWorkHours) : '-' }}
</td>
<td>
<small>{{ formatDateTime(week.latestAggregatedAt) }}</small>
</td>
<td>
<NuxtLink
:to="`/report/summary/${summary.summaryId}`"
:to="`/report/summary/${filter.year}/${week.reportWeek}`"
class="btn btn-sm btn-outline-primary"
>
<i class="bi bi-eye"></i>
</NuxtLink>
</td>
</tr>
<tr v-if="summaries.length === 0">
<td colspan="8" class="text-center py-5 text-muted">
<tr v-if="weeklyList.length === 0">
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">취합된 보고서가 없습니다.</p>
</td>
@@ -105,7 +92,7 @@
<div class="modal fade" :class="{ show: showAggregateModal }"
:style="{ display: showAggregateModal ? 'block' : 'none' }"
tabindex="-1">
<div class="modal-dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
@@ -114,39 +101,76 @@
<button type="button" class="btn-close" @click="showAggregateModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">프로젝트 <span class="text-danger">*</span></label>
<select class="form-select" v-model="aggregateForm.projectId">
<option value="">선택하세요</option>
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
{{ p.projectName }}
</option>
</select>
</div>
<div class="row">
<div class="col-6">
<div class="row mb-3">
<div class="col-4">
<label class="form-label">연도 <span class="text-danger">*</span></label>
<select class="form-select" v-model="aggregateForm.reportYear">
<select class="form-select" v-model="aggregateForm.reportYear" @change="loadAvailableProjects">
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="col-6">
<div class="col-8">
<label class="form-label">주차 <span class="text-danger">*</span></label>
<select class="form-select" v-model="aggregateForm.reportWeek">
<option v-for="w in 53" :key="w" :value="w">W{{ String(w).padStart(2, '0') }}</option>
<select class="form-select" v-model="aggregateForm.reportWeek" @change="loadAvailableProjects">
<option v-for="w in 53" :key="w" :value="w">
{{ w }}주차 ({{ getWeekDateRange(aggregateForm.reportYear, w) }})
</option>
</select>
</div>
</div>
<div class="alert alert-info mt-3 mb-0">
<!-- 프로젝트 선택 -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">프로젝트 선택 <span class="text-danger">*</span></label>
<div>
<button type="button" class="btn btn-sm btn-outline-primary me-1" @click="selectAllAvailable">전체 선택</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="deselectAllAvailable">전체 해제</button>
</div>
</div>
<div v-if="isLoadingProjects" class="text-center py-3">
<span class="spinner-border spinner-border-sm me-1"></span> 프로젝트 조회 ...
</div>
<div v-else-if="availableProjects.length === 0" class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
해당 주차에 제출된 주간보고가 없습니다.
</div>
<div v-else class="border rounded p-3" style="max-height: 300px; overflow-y: auto;">
<div class="row">
<div class="col-md-6" v-for="proj in availableProjects" :key="proj.projectId">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input"
:id="'agg-proj-' + proj.projectId"
:value="proj.projectId"
v-model="aggregateForm.selectedProjectIds" />
<label class="form-check-label" :for="'agg-proj-' + proj.projectId">
{{ proj.projectName }}
<small class="text-muted">({{ proj.reportCount }})</small>
</label>
</div>
</div>
</div>
</div>
<div class="text-muted small mt-2" v-if="availableProjects.length > 0">
{{ aggregateForm.selectedProjectIds.length }} / {{ availableProjects.length }} 프로젝트 선택됨
</div>
</div>
<div class="alert alert-info mb-0" v-if="aggregateForm.selectedProjectIds.length > 0">
<i class="bi bi-info-circle me-2"></i>
선택한 프로젝트/주차의 제출된 보고서를 취합합니다.
{{ aggregateForm.reportYear }} {{ aggregateForm.reportWeek }}주차,
{{ aggregateForm.selectedProjectIds.length }} 프로젝트의 보고서를 취합합니다.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showAggregateModal = false">
취소
</button>
<button type="button" class="btn btn-primary" @click="doAggregate" :disabled="isAggregating">
<button type="button" class="btn btn-primary" @click="doAggregate"
:disabled="isAggregating || aggregateForm.selectedProjectIds.length === 0">
<span v-if="isAggregating">
<span class="spinner-border spinner-border-sm me-1"></span>취합 ...
</span>
@@ -163,6 +187,8 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
const { fetchCurrentUser } = useAuth()
const { getCurrentWeekInfo } = useWeekCalc()
const router = useRouter()
@@ -172,20 +198,20 @@ const currentWeek = getCurrentWeekInfo()
const years = [currentYear, currentYear - 1, currentYear - 2]
const filter = ref({
projectId: '',
year: currentYear
})
const summaries = ref<any[]>([])
const projects = ref<any[]>([])
const weeklyList = ref<any[]>([])
// 취합 모달
const showAggregateModal = ref(false)
const isAggregating = ref(false)
const isLoadingProjects = ref(false)
const availableProjects = ref<any[]>([])
const aggregateForm = ref({
projectId: '',
reportYear: currentYear,
reportWeek: currentWeek.week > 1 ? currentWeek.week - 1 : 1 // 기본값: 지난주
reportWeek: currentWeek.week > 1 ? currentWeek.week - 1 : 1,
selectedProjectIds: [] as number[]
})
onMounted(async () => {
@@ -195,51 +221,59 @@ onMounted(async () => {
return
}
await loadProjects()
await loadSummaries()
await loadWeeklyList()
})
async function loadProjects() {
async function loadWeeklyList() {
try {
const res = await $fetch<{ projects: any[] }>('/api/project/list')
projects.value = res.projects || []
const res = await $fetch<{ weeks: any[] }>('/api/report/summary/weekly-list', {
query: { year: filter.value.year }
})
weeklyList.value = res.weeks || []
} catch (e) {
console.error('Load projects error:', e)
console.error('Load weekly list error:', e)
}
}
async function loadSummaries() {
async function loadAvailableProjects() {
isLoadingProjects.value = true
try {
const query: Record<string, any> = { year: filter.value.year }
if (filter.value.projectId) query.projectId = filter.value.projectId
const res = await $fetch<{ summaries: any[] }>('/api/report/summary/list', { query })
summaries.value = res.summaries || []
const res = await $fetch<{ projects: any[] }>('/api/report/summary/available-projects', {
query: {
year: aggregateForm.value.reportYear,
week: aggregateForm.value.reportWeek
}
})
availableProjects.value = res.projects || []
aggregateForm.value.selectedProjectIds = availableProjects.value.map(p => p.projectId)
} catch (e) {
console.error('Load summaries error:', e)
console.error('Load available projects error:', e)
availableProjects.value = []
} finally {
isLoadingProjects.value = false
}
}
async function doAggregate() {
if (!aggregateForm.value.projectId) {
if (aggregateForm.value.selectedProjectIds.length === 0) {
alert('프로젝트를 선택해주세요.')
return
}
isAggregating.value = true
try {
const res = await $fetch<{ success: boolean; memberCount: number }>('/api/report/summary/aggregate', {
const res = await $fetch<{ success: boolean; summaryCount: number; totalMembers: number }>('/api/report/summary/aggregate', {
method: 'POST',
body: {
projectId: parseInt(aggregateForm.value.projectId as string),
projectIds: aggregateForm.value.selectedProjectIds,
reportYear: aggregateForm.value.reportYear,
reportWeek: aggregateForm.value.reportWeek
}
})
alert(`취합 완료! (${res.memberCount}의 보고서)`)
alert(`취합 완료! (${res.summaryCount}개 프로젝트, 총 ${res.totalMembers}명)`)
showAggregateModal.value = false
await loadSummaries()
await loadWeeklyList()
} catch (e: any) {
alert(e.data?.message || '취합에 실패했습니다.')
} finally {
@@ -247,23 +281,38 @@ async function doAggregate() {
}
}
function getStatusBadgeClass(status: string) {
const classes: Record<string, string> = {
'AGGREGATED': 'badge bg-info',
'REVIEWED': 'badge bg-success'
}
return classes[status] || 'badge bg-secondary'
function selectAllAvailable() {
aggregateForm.value.selectedProjectIds = availableProjects.value.map(p => p.projectId)
}
function getStatusText(status: string) {
const texts: Record<string, string> = {
'AGGREGATED': '취합완료',
'REVIEWED': '검토완료'
function deselectAllAvailable() {
aggregateForm.value.selectedProjectIds = []
}
watch(showAggregateModal, (val) => {
if (val) {
loadAvailableProjects()
}
return texts[status] || status
})
function getWeekDateRange(year: number, week: number): string {
const jan4 = new Date(year, 0, 4)
const jan4Day = jan4.getDay() || 7
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4Day + 1)
const monday = new Date(week1Monday)
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
return `${fmt(monday)}~${fmt(sunday)}`
}
function formatDateRange(start: string, end: string) {
if (!start || !end) return '-'
const s = new Date(start)
const e = new Date(end)
return `${s.getMonth()+1}/${s.getDate()} ~ ${e.getMonth()+1}/${e.getDate()}`
@@ -272,10 +321,16 @@ function formatDateRange(start: string, end: string) {
function formatDateTime(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
})
return d.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remain = hours % 8
if (days === 0) return `${remain}h`
if (remain === 0) return `${days}`
return `${days}${remain}h`
}
</script>

View File

@@ -18,11 +18,27 @@
</span>
</h4>
<p class="text-muted mb-0">
<span class="me-3">{{ report.authorName }}</span>
{{ report.reportYear }} {{ report.reportWeek }}주차
({{ formatDate(report.weekStartDate) }} ~ {{ formatDate(report.weekEndDate) }})
</p>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 align-items-center">
<!-- 이전/다음 보고서 -->
<div class="btn-group me-2">
<NuxtLink v-if="prevReport" :to="`/report/weekly/${prevReport.reportId}`" class="btn btn-outline-secondary" :title="prevReport.authorName">
<i class="bi bi-chevron-left"></i> 이전
</NuxtLink>
<button v-else class="btn btn-outline-secondary" disabled>
<i class="bi bi-chevron-left"></i> 이전
</button>
<NuxtLink v-if="nextReport" :to="`/report/weekly/${nextReport.reportId}`" class="btn btn-outline-secondary" :title="nextReport.authorName">
다음 <i class="bi bi-chevron-right"></i>
</NuxtLink>
<button v-else class="btn btn-outline-secondary" disabled>
다음 <i class="bi bi-chevron-right"></i>
</button>
</div>
<NuxtLink to="/report/weekly" class="btn btn-outline-secondary">목록</NuxtLink>
<button v-if="canEdit" class="btn btn-primary" @click="isEditing = !isEditing">
{{ isEditing ? '취소' : '수정' }}
@@ -31,44 +47,81 @@
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-1"></span>
제출
</button>
<button v-if="canDelete" class="btn btn-outline-danger" @click="handleDelete" :disabled="isDeleting">
<span v-if="isDeleting" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-trash me-1"></i>삭제
</button>
</div>
</div>
<!-- 보기 모드 -->
<div v-if="!isEditing">
<!-- 프로젝트별 실적 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적</strong>
<span class="badge bg-secondary ms-2">{{ projects.length }}</span>
<!-- 프로젝트별 Task -->
<div v-for="proj in projects" :key="proj.projectId" class="card mb-4">
<div class="card-header bg-light">
<i class="bi bi-folder2 me-2"></i>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</div>
<div class="card-body">
<div v-for="(proj, idx) in projects" :key="proj.detailId"
:class="{ 'border-top pt-3 mt-3': idx > 0 }">
<h6 class="mb-3">
<i class="bi bi-folder2 me-1"></i>
{{ proj.projectName }}
<small class="text-muted">({{ proj.projectCode }})</small>
</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">금주 실적</label>
<div class="bg-light rounded p-3" style="white-space: pre-wrap;">{{ proj.workDescription || '-' }}</div>
<!-- 금주 실적 -->
<div class="col-md-6">
<h6 class="text-primary mb-3">
<i class="bi bi-check2-square me-1"></i>금주 실적
<span class="badge bg-primary ms-1">{{ formatHoursDisplay(getProjectWorkHours(proj)) }}</span>
</h6>
<div v-if="proj.workTasks.length === 0" class="text-muted">-</div>
<div v-else class="list-group list-group-flush">
<div v-for="task in proj.workTasks" :key="task.taskId" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-start">
<div>
<span class="badge me-2" :class="task.isCompleted ? 'bg-success' : 'bg-warning text-dark'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span style="white-space: pre-wrap;">{{ task.description }}</span>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">차주 계획</label>
<div class="bg-light rounded p-3" style="white-space: pre-wrap;">{{ proj.planDescription || '-' }}</div>
<span class="badge bg-light text-dark">{{ formatHours(task.hours) }}</span>
</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<h6 class="text-success mb-3">
<i class="bi bi-calendar-check me-1"></i>차주 계획
<span class="badge bg-success ms-1">{{ formatHoursDisplay(getProjectPlanHours(proj)) }}</span>
</h6>
<div v-if="proj.planTasks.length === 0" class="text-muted">-</div>
<div v-else class="list-group list-group-flush">
<div v-for="task in proj.planTasks" :key="task.taskId" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-start">
<span style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="badge bg-light text-dark">{{ formatHours(task.hours) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 총계 -->
<div class="card mb-4 border-primary">
<div class="card-body py-2">
<div class="row text-center">
<div class="col">
<span class="text-muted">금주 실적 합계</span>
<h5 class="mb-0 text-primary">{{ formatHoursDisplay(totalWorkHours) }}</h5>
</div>
<div class="col">
<span class="text-muted">차주 계획 합계</span>
<h5 class="mb-0 text-success">{{ formatHoursDisplay(totalPlanHours) }}</h5>
</div>
</div>
</div>
</div>
<!-- 공통 사항 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong>
</div>
<div class="card-header"><strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong></div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
@@ -86,36 +139,135 @@
</div>
</div>
</div>
<!-- PMO AI 리뷰 -->
<div class="card mb-4 border-info">
<div class="card-header bg-info bg-opacity-10 d-flex justify-content-between align-items-center">
<strong><i class="bi bi-robot me-2"></i>PMO AI 리뷰</strong>
<button class="btn btn-sm btn-outline-info" @click="requestAiReview" :disabled="isReviewing">
<span v-if="isReviewing" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-arrow-repeat me-1"></i>
{{ report.aiReview ? '리뷰 재요청' : '리뷰 요청' }}
</button>
</div>
<div class="card-body">
<div v-if="report.aiReview" class="ai-review-content" v-html="renderMarkdown(report.aiReview)"></div>
<div v-else class="text-muted text-center py-3">
<i class="bi bi-chat-left-dots me-2"></i>
아직 AI 리뷰가 없습니다. 리뷰 요청 버튼을 클릭하세요.
</div>
<div v-if="report.aiReviewAt" class="text-muted small mt-3 text-end">
<i class="bi bi-clock me-1"></i>리뷰 생성: {{ formatDateTime(report.aiReviewAt) }}
</div>
</div>
</div>
</div>
<!-- 수정 모드 -->
<form v-else @submit.prevent="handleUpdate">
<!-- 프로젝트별 실적 -->
<!-- 주차 정보 수정 -->
<div class="card mb-4">
<div class="card-header"><strong>보고 주차</strong></div>
<div class="card-body">
<div class="row align-items-end">
<div class="col-auto">
<div class="input-group">
<button type="button" class="btn btn-outline-secondary" @click="changeEditWeek(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<span class="input-group-text bg-white" style="min-width: 160px;">
<strong>{{ editForm.reportYear }} {{ editForm.reportWeek }}주차</strong>
</span>
<button type="button" class="btn btn-outline-secondary" @click="changeEditWeek(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="col-auto">
<span class="text-muted">
{{ editForm.weekStartDate }} ~ {{ editForm.weekEndDate }}
</span>
</div>
</div>
</div>
</div>
<!-- 프로젝트별 Task -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적</strong>
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
<i class="bi bi-plus"></i> 프로젝트 추가
</button>
</div>
<div class="card-body">
<div v-for="(proj, idx) in editForm.projects" :key="idx" class="border rounded p-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeProject(idx)">
<i class="bi bi-x"></i> 삭제
<div v-for="(group, gIdx) in editProjectGroups" :key="group.projectId" class="border rounded mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-folder2 me-2"></i>
<strong>{{ group.projectName }}</strong>
<small class="text-muted ms-2">({{ group.projectCode }})</small>
</span>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeEditProjectGroup(gIdx)">
<i class="bi bi-x"></i>
</button>
</div>
<div class="mb-3">
<label class="form-label">금주 실적</label>
<textarea class="form-control" rows="3" v-model="proj.workDescription"></textarea>
<div class="card-body">
<div class="row">
<!-- 금주 실적 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-primary">금주 실적</label>
<button type="button" class="btn btn-sm btn-outline-primary" @click="addEditTask(group.projectId, 'WORK')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in getEditWorkTasks(group.projectId)" :key="'work-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<div class="form-check pt-1">
<input type="checkbox" class="form-check-input" v-model="task.isCompleted"
:id="'edit-work-chk-'+group.projectId+'-'+tIdx" />
<label class="form-check-label small" :for="'edit-work-chk-'+group.projectId+'-'+tIdx"
:class="task.isCompleted ? 'text-success' : 'text-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</label>
</div>
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="작업 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeEditTask(group.projectId, 'WORK', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-success">차주 계획</label>
<button type="button" class="btn btn-sm btn-outline-success" @click="addEditTask(group.projectId, 'PLAN')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in getEditPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="계획 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeEditTask(group.projectId, 'PLAN', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
</div>
<div>
<label class="form-label">차주 계획</label>
<textarea class="form-control" rows="3" v-model="proj.planDescription"></textarea>
</div>
</div>
</div>
@@ -123,21 +275,21 @@
<!-- 공통 사항 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong>
</div>
<div class="card-header"><strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong></div>
<div class="card-body">
<div class="mb-3">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" rows="3" v-model="editForm.issueDescription"></textarea>
</div>
<div class="mb-3">
<div class="col-md-4 mb-3">
<label class="form-label">휴가일정</label>
<textarea class="form-control" rows="2" v-model="editForm.vacationDescription"></textarea>
<textarea class="form-control" rows="3" v-model="editForm.vacationDescription"></textarea>
</div>
<div>
<div class="col-md-4 mb-3">
<label class="form-label">기타사항</label>
<textarea class="form-control" rows="2" v-model="editForm.remarkDescription"></textarea>
<textarea class="form-control" rows="3" v-model="editForm.remarkDescription"></textarea>
</div>
</div>
</div>
</div>
@@ -154,7 +306,7 @@
</div>
<!-- 프로젝트 선택 모달 -->
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }" tabindex="-1">
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -168,7 +320,7 @@
<div v-else class="list-group">
<button type="button" class="list-group-item list-group-item-action"
v-for="p in availableProjects" :key="p.projectId"
@click="addProject(p)">
@click="addEditProjectGroup(p)">
<strong>{{ p.projectName }}</strong>
<small class="text-muted ms-2">({{ p.projectCode }})</small>
</button>
@@ -186,35 +338,73 @@ const { currentUser, fetchCurrentUser } = useAuth()
const router = useRouter()
const route = useRoute()
const reportId = route.params.id as string
const reportId = computed(() => route.params.id as string)
const report = ref<any>(null)
const projects = ref<any[]>([])
const allProjects = ref<any[]>([])
const prevReport = ref<any>(null)
const nextReport = ref<any>(null)
const isLoading = ref(true)
const isEditing = ref(false)
const isSaving = ref(false)
const isSubmitting = ref(false)
const isDeleting = ref(false)
const isReviewing = ref(false)
const showProjectModal = ref(false)
interface EditProjectItem {
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
interface EditTask {
projectId: number
projectCode: string
projectName: string
workDescription: string
planDescription: string
taskType: 'WORK' | 'PLAN'
description: string
hours: number
isCompleted: boolean
}
const editForm = ref({
projects: [] as EditProjectItem[],
reportYear: 0,
reportWeek: 0,
weekStartDate: '',
weekEndDate: '',
tasks: [] as EditTask[],
issueDescription: '',
vacationDescription: '',
remarkDescription: ''
})
const editProjectGroups = computed(() => {
const projectIds = [...new Set(editForm.value.tasks.map(t => t.projectId))]
return projectIds.map(pid => {
const proj = allProjects.value.find(p => p.projectId === pid)
return {
projectId: pid,
projectCode: proj?.projectCode || '',
projectName: proj?.projectName || ''
}
})
})
const availableProjects = computed(() => {
const usedIds = editProjectGroups.value.map(g => g.projectId)
return allProjects.value.filter(p => !usedIds.includes(p.projectId))
})
const totalWorkHours = computed(() => {
return projects.value.reduce((sum, proj) => sum + getProjectWorkHours(proj), 0)
})
const totalPlanHours = computed(() => {
return projects.value.reduce((sum, proj) => sum + getProjectPlanHours(proj), 0)
})
const canEdit = computed(() => {
if (!report.value || !currentUser.value) return false
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus === 'DRAFT'
// 관리자는 항상 수정 가능
if (isAdmin.value) return true
// 본인 보고서이고 AGGREGATED가 아니면 수정 가능
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus !== 'AGGREGATED'
})
const canSubmit = computed(() => {
@@ -222,9 +412,10 @@ const canSubmit = computed(() => {
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus === 'DRAFT'
})
const availableProjects = computed(() => {
const addedIds = editForm.value.projects.map(p => p.projectId)
return allProjects.value.filter(p => !addedIds.includes(p.projectId))
const canDelete = computed(() => {
if (!report.value || !currentUser.value) return false
if (isAdmin.value) return true
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus !== 'AGGREGATED'
})
onMounted(async () => {
@@ -240,9 +431,11 @@ onMounted(async () => {
async function loadReport() {
isLoading.value = true
try {
const res = await $fetch<any>(`/api/report/weekly/${reportId}/detail`)
const res = await $fetch<any>(`/api/report/weekly/${reportId.value}/detail`)
report.value = res.report
projects.value = res.projects
prevReport.value = res.prevReport
nextReport.value = res.nextReport
} catch (e: any) {
alert(e.data?.message || '보고서를 불러올 수 없습니다.')
router.push('/report/weekly')
@@ -251,6 +444,12 @@ async function loadReport() {
}
}
// route param 변경 시 다시 로드
watch(reportId, async () => {
isEditing.value = false
await loadReport()
})
async function loadAllProjects() {
try {
const res = await $fetch<any>('/api/project/list')
@@ -262,14 +461,35 @@ async function loadAllProjects() {
watch(isEditing, (val) => {
if (val) {
// 기존 데이터를 editForm으로 변환
const tasks: EditTask[] = []
for (const proj of projects.value) {
for (const task of proj.workTasks) {
tasks.push({
projectId: proj.projectId,
taskType: 'WORK',
description: task.description,
hours: task.hours,
isCompleted: task.isCompleted !== false
})
}
for (const task of proj.planTasks) {
tasks.push({
projectId: proj.projectId,
taskType: 'PLAN',
description: task.description,
hours: task.hours,
isCompleted: true
})
}
}
editForm.value = {
projects: projects.value.map(p => ({
projectId: p.projectId,
projectCode: p.projectCode,
projectName: p.projectName,
workDescription: p.workDescription || '',
planDescription: p.planDescription || ''
})),
reportYear: report.value.reportYear,
reportWeek: report.value.reportWeek,
weekStartDate: report.value.weekStartDate,
weekEndDate: report.value.weekEndDate,
tasks,
issueDescription: report.value.issueDescription || '',
vacationDescription: report.value.vacationDescription || '',
remarkDescription: report.value.remarkDescription || ''
@@ -277,36 +497,167 @@ watch(isEditing, (val) => {
}
})
function addProject(p: any) {
editForm.value.projects.push({
projectId: p.projectId,
projectCode: p.projectCode,
projectName: p.projectName,
workDescription: '',
planDescription: ''
// 프로젝트별 시간 계산
function getProjectWorkHours(proj: any) {
return proj.workTasks.reduce((sum: number, t: any) => sum + (t.hours || 0), 0)
}
function getProjectPlanHours(proj: any) {
return proj.planTasks.reduce((sum: number, t: any) => sum + (t.hours || 0), 0)
}
// 수정 모드 주차 변경
function changeEditWeek(delta: number) {
let year = editForm.value.reportYear
let week = editForm.value.reportWeek + delta
// 주차 범위 조정
if (week < 1) {
year--
week = getWeeksInYear(year)
} else if (week > getWeeksInYear(year)) {
year++
week = 1
}
editForm.value.reportYear = year
editForm.value.reportWeek = week
// 해당 주차의 월요일~일요일 계산
const { monday, sunday } = getWeekDates(year, week)
editForm.value.weekStartDate = monday
editForm.value.weekEndDate = sunday
}
// 연도의 총 주차 수 계산
function getWeeksInYear(year: number): number {
const dec31 = new Date(year, 11, 31)
const dayOfWeek = dec31.getDay()
// 12월 31일이 목요일 이후면 53주, 아니면 52주
return dayOfWeek >= 4 || dayOfWeek === 0 ? 53 : 52
}
// 연도와 주차로 해당 주의 월요일~일요일 계산
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
// 해당 연도의 첫 번째 목요일이 속한 주가 1주차
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: formatDateStr(monday),
sunday: formatDateStr(sunday)
}
}
function formatDateStr(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}`
}
// 수정 모드 함수들
function getEditWorkTasks(projectId: number) {
return editForm.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'WORK')
}
function getEditPlanTasks(projectId: number) {
return editForm.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'PLAN')
}
function addEditProjectGroup(project: any) {
editForm.value.tasks.push({
projectId: project.projectId,
taskType: 'WORK',
description: '',
hours: 0,
isCompleted: true
})
editForm.value.tasks.push({
projectId: project.projectId,
taskType: 'PLAN',
description: '',
hours: 0,
isCompleted: true
})
showProjectModal.value = false
}
function removeProject(idx: number) {
editForm.value.projects.splice(idx, 1)
function removeEditProjectGroup(gIdx: number) {
const group = editProjectGroups.value[gIdx]
editForm.value.tasks = editForm.value.tasks.filter(t => t.projectId !== group.projectId)
}
function addEditTask(projectId: number, taskType: 'WORK' | 'PLAN') {
editForm.value.tasks.push({ projectId, taskType, description: '', hours: 0, isCompleted: true })
}
function removeEditTask(projectId: number, taskType: 'WORK' | 'PLAN', idx: number) {
const tasks = editForm.value.tasks.filter(t => t.projectId === projectId && t.taskType === taskType)
if (tasks.length <= 1) return
const targetTask = tasks[idx]
const targetIndex = editForm.value.tasks.indexOf(targetTask)
if (targetIndex > -1) {
editForm.value.tasks.splice(targetIndex, 1)
}
}
// 시간 표시 함수
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '-'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}h`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}h`
}
function formatHoursDisplay(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}시간`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}시간`
}
async function handleUpdate() {
if (editForm.value.projects.length === 0) {
alert('최소 1개 이상의 프로젝트가 필요합니다.')
const validTasks = editForm.value.tasks.filter(t => t.description.trim())
if (validTasks.length === 0) {
alert('최소 1개 이상의 Task를 입력해주세요.')
return
}
isSaving.value = true
try {
await $fetch(`/api/report/weekly/${reportId}/update`, {
await $fetch(`/api/report/weekly/${reportId.value}/update`, {
method: 'PUT',
body: {
projects: editForm.value.projects.map(p => ({
projectId: p.projectId,
workDescription: p.workDescription,
planDescription: p.planDescription
reportYear: editForm.value.reportYear,
reportWeek: editForm.value.reportWeek,
weekStartDate: editForm.value.weekStartDate,
weekEndDate: editForm.value.weekEndDate,
tasks: validTasks.map(t => ({
projectId: t.projectId,
taskType: t.taskType,
taskDescription: t.description,
taskHours: t.hours || 0,
isCompleted: t.isCompleted
})),
issueDescription: editForm.value.issueDescription,
vacationDescription: editForm.value.vacationDescription,
@@ -328,7 +679,7 @@ async function handleSubmit() {
isSubmitting.value = true
try {
await $fetch(`/api/report/weekly/${reportId}/submit`, { method: 'POST' })
await $fetch(`/api/report/weekly/${reportId.value}/submit`, { method: 'POST' })
alert('제출되었습니다.')
await loadReport()
} catch (e: any) {
@@ -338,6 +689,58 @@ async function handleSubmit() {
}
}
async function handleDelete() {
const authorName = report.value?.authorName || ''
const weekInfo = `${report.value?.reportYear}${report.value?.reportWeek}주차`
if (!confirm(`정말 삭제하시겠습니까?\n\n작성자: ${authorName}\n주차: ${weekInfo}\n\n삭제된 보고서는 복구할 수 없습니다.`)) return
isDeleting.value = true
try {
await $fetch(`/api/report/weekly/${reportId.value}/delete`, { method: 'DELETE' })
alert('삭제되었습니다.')
router.push('/report/weekly')
} catch (e: any) {
alert(e.data?.message || '삭제에 실패했습니다.')
} finally {
isDeleting.value = false
}
}
async function requestAiReview() {
isReviewing.value = true
try {
const res = await $fetch<{ review: string; reviewedAt: string }>('/api/report/review', {
method: 'POST',
body: { reportId: parseInt(reportId.value) }
})
report.value.aiReview = res.review
report.value.aiReviewAt = res.reviewedAt
} catch (e: any) {
alert(e.data?.message || 'AI 리뷰 요청에 실패했습니다.')
} finally {
isReviewing.value = false
}
}
function renderMarkdown(text: string): string {
if (!text) return ''
return text
.replace(/^### (.+)$/gm, '<h6 class="fw-bold mt-3">$1</h6>')
.replace(/^## (.+)$/gm, '<h5 class="fw-bold mt-3">$1</h5>')
.replace(/^# (.+)$/gm, '<h5 class="fw-bold mt-3">$1</h5>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '• $1<br>')
.replace(/\n/g, '<br>')
}
function formatDateTime(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
function formatDate(dateStr: string) {
if (!dateStr) return ''
return dateStr.split('T')[0]
@@ -366,4 +769,12 @@ function getStatusText(status: string) {
.modal.show {
background-color: rgba(0, 0, 0, 0.5);
}
.ai-review-content {
line-height: 1.8;
font-size: 0.95rem;
}
.ai-review-content h5, .ai-review-content h6 {
color: #0d6efd;
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,294 @@
<template>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-collection me-2"></i>주간보고 취합</h4>
<NuxtLink to="/report/weekly" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i> 목록
</NuxtLink>
</div>
<!-- 조회 조건 -->
<div class="card mb-4">
<div class="card-body">
<div class="row align-items-end">
<div class="col-auto">
<label class="form-label small text-muted">보고 주차</label>
<div class="input-group">
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<span class="input-group-text bg-white" style="min-width: 160px;">
<strong>{{ selectedYear }} {{ selectedWeek }}주차</strong>
</span>
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="col-auto">
<span class="text-muted">{{ weekStartDate }} ~ {{ weekEndDate }}</span>
</div>
<div class="col-auto">
<button class="btn btn-primary" @click="loadAggregate" :disabled="isLoading">
<span v-if="isLoading" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-search me-1"></i> 조회
</button>
</div>
</div>
</div>
</div>
<!-- 프로젝트 선택 -->
<div class="card mb-4" v-if="availableProjects.length > 0">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder-check me-2"></i>프로젝트 선택</strong>
<div>
<button class="btn btn-sm btn-outline-primary me-2" @click="selectAllProjects">전체 선택</button>
<button class="btn btn-sm btn-outline-secondary" @click="deselectAllProjects">전체 해제</button>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 col-sm-6 mb-2" v-for="proj in availableProjects" :key="proj.projectId">
<div class="form-check">
<input type="checkbox" class="form-check-input"
:id="'proj-' + proj.projectId"
:value="proj.projectId"
v-model="selectedProjectIds"
@change="filterProjects" />
<label class="form-check-label" :for="'proj-' + proj.projectId">
{{ proj.projectName }}
</label>
</div>
</div>
</div>
<div class="text-muted small mt-2">
{{ selectedProjectIds.length }} / {{ availableProjects.length }} 프로젝트 선택됨
· {{ reportCount }} 보고서
</div>
</div>
</div>
<!-- 취합 결과 -->
<div v-if="!isLoading && filteredProjects.length > 0">
<div class="card mb-4" v-for="proj in filteredProjects" :key="proj.projectId">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-folder2 me-2"></i>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</span>
<span class="text-muted small">
실적 {{ formatHours(proj.totalWorkHours) }} · 계획 {{ formatHours(proj.totalPlanHours) }}
</span>
</div>
</div>
<div class="card-body">
<div class="row">
<!-- 금주 실적 -->
<div class="col-md-6">
<h6 class="text-primary mb-3"><i class="bi bi-check-circle me-1"></i>금주 실적</h6>
<div v-if="proj.workTasks.length === 0" class="text-muted small">-</div>
<div v-for="(task, idx) in proj.workTasks" :key="'work-'+idx" class="mb-2 pb-2 border-bottom">
<div class="d-flex justify-content-between">
<span>
<span class="badge me-2" :class="task.isCompleted ? 'bg-success' : 'bg-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</span>
<span style="white-space: pre-wrap;">{{ task.description }}</span>
</span>
<span class="text-nowrap ms-2">
<span class="badge bg-secondary">{{ task.authorName }}</span>
<span class="text-muted small ms-1">{{ task.hours }}h</span>
</span>
</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<h6 class="text-success mb-3"><i class="bi bi-calendar-event me-1"></i>차주 계획</h6>
<div v-if="proj.planTasks.length === 0" class="text-muted small">-</div>
<div v-for="(task, idx) in proj.planTasks" :key="'plan-'+idx" class="mb-2 pb-2 border-bottom">
<div class="d-flex justify-content-between">
<span style="white-space: pre-wrap;">{{ task.description }}</span>
<span class="text-nowrap ms-2">
<span class="badge bg-secondary">{{ task.authorName }}</span>
<span class="text-muted small ms-1">{{ task.hours }}h</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 결과 -->
<div v-else-if="!isLoading && isLoaded" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1"></i>
<p class="mt-2">해당 주차에 등록된 주간보고가 없습니다.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const { fetchCurrentUser, isAdmin } = useAuth()
const isLoading = ref(false)
const isLoaded = ref(false)
const reportCount = ref(0)
// 주차 선택
const selectedYear = ref(new Date().getFullYear())
const selectedWeek = ref(1)
const weekStartDate = ref('')
const weekEndDate = ref('')
// 프로젝트 선택
const availableProjects = ref<any[]>([])
const selectedProjectIds = ref<number[]>([])
const allProjects = ref<any[]>([])
// 취합 결과
const filteredProjects = computed(() => {
return allProjects.value.filter(p => selectedProjectIds.value.includes(p.projectId))
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
if (!isAdmin.value) {
alert('관리자만 접근할 수 있습니다.')
router.push('/report/weekly')
return
}
// 현재 주차 계산
initCurrentWeek()
await loadAggregate()
})
function initCurrentWeek() {
const now = new Date()
const jan4 = new Date(now.getFullYear(), 0, 4)
const jan4Day = jan4.getDay() || 7
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4Day + 1)
const diff = now.getTime() - week1Monday.getTime()
const weekNum = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1
selectedYear.value = now.getFullYear()
selectedWeek.value = weekNum > 0 ? weekNum : 1
updateWeekDates()
}
function changeWeek(delta: number) {
let year = selectedYear.value
let week = selectedWeek.value + delta
if (week < 1) {
year--
week = getWeeksInYear(year)
} else if (week > getWeeksInYear(year)) {
year++
week = 1
}
selectedYear.value = year
selectedWeek.value = week
updateWeekDates()
}
function getWeeksInYear(year: number): number {
const dec31 = new Date(year, 11, 31)
const dayOfWeek = dec31.getDay()
return dayOfWeek >= 4 || dayOfWeek === 0 ? 53 : 52
}
function updateWeekDates() {
const { monday, sunday } = getWeekDates(selectedYear.value, selectedWeek.value)
weekStartDate.value = monday
weekEndDate.value = sunday
}
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
const jan4 = new Date(year, 0, 4)
const jan4Day = jan4.getDay() || 7
const week1Monday = new Date(jan4)
week1Monday.setDate(jan4.getDate() - jan4Day + 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}`
}
async function loadAggregate() {
isLoading.value = true
try {
const res = await $fetch<any>('/api/report/weekly/aggregate', {
params: {
year: selectedYear.value,
week: selectedWeek.value
}
})
reportCount.value = res.reportCount
availableProjects.value = res.availableProjects
allProjects.value = res.projects
// 기본으로 모든 프로젝트 선택
selectedProjectIds.value = res.availableProjects.map((p: any) => p.projectId)
isLoaded.value = true
} catch (e: any) {
alert(e.data?.message || '조회에 실패했습니다.')
} finally {
isLoading.value = false
}
}
function filterProjects() {
// 체크박스 변경 시 자동 필터링 (computed로 처리)
}
function selectAllProjects() {
selectedProjectIds.value = availableProjects.value.map(p => p.projectId)
}
function deselectAllProjects() {
selectedProjectIds.value = []
}
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}h`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}h`
}
</script>

View File

@@ -2,62 +2,154 @@
<div>
<AppHeader />
<div class="container py-4">
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">
<i class="bi bi-journal-text me-2"></i> 주간보고
<i class="bi bi-journal-text me-2"></i>주간보고
</h4>
<div class="d-flex gap-2">
<NuxtLink v-if="isAdmin" to="/report/summary" class="btn btn-outline-primary">
<i class="bi bi-collection me-1"></i>취합하기
</NuxtLink>
<NuxtLink to="/report/weekly/write" class="btn btn-primary">
<i class="bi bi-plus me-1"></i>작성하기
</NuxtLink>
</div>
</div>
<!-- 필터 영역 -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<!-- 전체보기 (관리자만) -->
<div class="col-auto" v-if="isAdmin">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="viewAll" v-model="filters.viewAll" @change="loadReports">
<label class="form-check-label" for="viewAll">전체 보기</label>
</div>
</div>
<!-- 작성자 -->
<div class="col-md-2" v-if="isAdmin">
<label class="form-label small text-muted">작성자</label>
<select class="form-select form-select-sm" v-model="filters.authorId" @change="loadReports">
<option value="">전체</option>
<option v-for="emp in employees" :key="emp.employeeId" :value="emp.employeeId">
{{ emp.employeeName }}
</option>
</select>
</div>
<!-- 프로젝트 -->
<div class="col-md-2">
<label class="form-label small text-muted">프로젝트</label>
<select class="form-select form-select-sm" v-model="filters.projectId" @change="loadReports">
<option value="">전체</option>
<option v-for="proj in projects" :key="proj.projectId" :value="proj.projectId">
{{ proj.projectName }}
</option>
</select>
</div>
<!-- 연도 -->
<div class="col-md-1">
<label class="form-label small text-muted">연도</label>
<select class="form-select form-select-sm" v-model="filters.year" @change="loadReports">
<option value="">전체</option>
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<!-- 기간 -->
<div class="col-md-2">
<label class="form-label small text-muted">시작일</label>
<input type="date" class="form-control form-control-sm" v-model="filters.startDate" @change="loadReports">
</div>
<div class="col-md-2">
<label class="form-label small text-muted">종료일</label>
<input type="date" class="form-control form-control-sm" v-model="filters.endDate" @change="loadReports">
</div>
<!-- 상태 -->
<div class="col-md-1">
<label class="form-label small text-muted">상태</label>
<select class="form-select form-select-sm" v-model="filters.status" @change="loadReports">
<option value="">전체</option>
<option value="DRAFT">작성중</option>
<option value="SUBMITTED">제출완료</option>
<option value="AGGREGATED">취합완료</option>
</select>
</div>
<!-- 초기화 -->
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @click="resetFilters">
<i class="bi bi-arrow-counterclockwise me-1"></i>초기화
</button>
</div>
</div>
</div>
</div>
<!-- 목록 -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 150px">주차</th>
<th style="width: 200px">기간</th>
<th style="width: 120px">주차</th>
<th style="width: 180px">기간</th>
<th v-if="isAdmin" style="width: 120px">작성자</th>
<th>프로젝트</th>
<th style="width: 100px">상태</th>
<th style="width: 150px">작성</th>
<th style="width: 90px">상태</th>
<th style="width: 100px">제출</th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="5" class="text-center py-4">
<td :colspan="isAdmin ? 6 : 5" class="text-center py-4">
<span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</td>
</tr>
<tr v-else-if="reports.length === 0">
<td colspan="5" class="text-center py-5 text-muted">
<td :colspan="isAdmin ? 6 : 5" class="text-center py-5 text-muted">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">작성한 주간보고가 없습니다.</p>
<p class="mt-2 mb-0">조회된 주간보고가 없습니다.</p>
</td>
</tr>
<tr v-else v-for="r in reports" :key="r.reportId"
@click="router.push(`/report/weekly/${r.reportId}`)"
style="cursor: pointer;">
<td>
<strong>{{ r.reportYear }} {{ r.reportWeek }}</strong>
<strong>{{ r.reportYear }} {{ r.reportWeek }}</strong>
</td>
<td class="small">
{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}
</td>
<td v-if="isAdmin">
<span class="badge bg-secondary">{{ r.authorName }}</span>
</td>
<td>{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}</td>
<td>
<span class="badge bg-primary">{{ r.projectCount }} 프로젝트</span>
<span class="text-truncate d-inline-block" style="max-width: 400px;" :title="r.projectNames">
{{ r.projectNames || '-' }}
</span>
<span class="badge bg-light text-dark ms-1">{{ r.projectCount }}</span>
</td>
<td>
<span :class="getStatusBadgeClass(r.reportStatus)">
{{ getStatusText(r.reportStatus) }}
</span>
</td>
<td>{{ formatDateTime(r.createdAt) }}</td>
<td class="small">{{ formatDateTime(r.submittedAt || r.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted small" v-if="reports.length > 0">
{{ reports.length }}
</div>
</div>
</div>
</div>
@@ -68,7 +160,23 @@ const { fetchCurrentUser } = useAuth()
const router = useRouter()
const reports = ref<any[]>([])
const employees = ref<any[]>([])
const projects = ref<any[]>([])
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<any>('/api/employee/list')
employees.value = empRes.employees || []
// 프로젝트 목록
const projRes = await $fetch<any>('/api/project/list')
projects.value = projRes.projects || []
} catch (e) {
console.error(e)
}
}
async function loadReports() {
isLoading.value = true
try {
const res = await $fetch<any>('/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<any>(`/api/report/weekly/list?${params.toString()}`)
reports.value = res.reports || []
// 일반 사용자도 프로젝트 필터 사용할 수 있도록
if (!isAdmin.value && projects.value.length === 0) {
const projRes = await $fetch<any>('/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
}
</script>
<style scoped>
.form-label {
margin-bottom: 0.25rem;
}
</style>

View File

@@ -3,47 +3,146 @@
<AppHeader />
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">
<i class="bi bi-journal-plus me-2"></i>주간보고 작성
<h4 class="mb-4">
<i class="bi bi-pencil-square me-2"></i>주간보고 작성
</h4>
<span class="text-muted">{{ weekInfo.weekString }} ({{ weekInfo.startDateStr }} ~ {{ weekInfo.endDateStr }})</span>
</div>
<form @submit.prevent="handleSubmit">
<!-- 프로젝트별 실적 -->
<!-- 주차 정보 -->
<div class="card mb-4">
<div class="card-header"><strong>보고 주차</strong></div>
<div class="card-body">
<div class="row align-items-end">
<div class="col-auto">
<div class="input-group">
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(-1)">
<i class="bi bi-chevron-left"></i>
</button>
<span class="input-group-text bg-white" style="min-width: 160px;">
<strong>{{ form.reportYear }} {{ form.reportWeek }}주차</strong>
</span>
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(1)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="col-auto">
<div class="input-group">
<input type="date" class="form-control" v-model="form.weekStartDate" @change="updateWeekFromDate" />
<span class="input-group-text">~</span>
<input type="date" class="form-control" v-model="form.weekEndDate" readonly />
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-primary btn-sm" @click="setLastWeek">지난주</button>
<button type="button" class="btn btn-outline-secondary btn-sm ms-1" @click="setThisWeek">이번주</button>
</div>
</div>
</div>
</div>
<!-- 프로젝트별 Task -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적</strong>
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
<i class="bi bi-plus"></i> 프로젝트 추가
</button>
</div>
<div class="card-body">
<div v-if="form.projects.length === 0" class="text-center text-muted py-4">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">프로젝트를 추가해주세요.</p>
<div v-if="projectGroups.length === 0" class="text-center text-muted py-4">
프로젝트를 추가해주세요.
</div>
<div v-for="(proj, idx) in form.projects" :key="idx" class="border rounded p-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<strong>{{ proj.projectName }}</strong>
<small class="text-muted ms-2">({{ proj.projectCode }})</small>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeProject(idx)">
<i class="bi bi-x"></i> 삭제
<div v-for="(group, gIdx) in projectGroups" :key="group.projectId" class="border rounded mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-folder2 me-2"></i>
<strong>{{ group.projectName }}</strong>
<small class="text-muted ms-2">({{ group.projectCode }})</small>
</span>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeProjectGroup(gIdx)">
<i class="bi bi-x"></i>
</button>
</div>
<div class="mb-3">
<label class="form-label">금주 실적</label>
<textarea class="form-control" rows="3" v-model="proj.workDescription"
placeholder="이번 주에 수행한 업무를 작성해주세요."></textarea>
<div class="card-body">
<div class="row">
<!-- 금주 실적 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-primary">
<i class="bi bi-check2-square me-1"></i>금주 실적
<span class="badge bg-primary ms-1">{{ formatHoursDisplay(getGroupWorkHours(group)) }}</span>
</label>
<button type="button" class="btn btn-sm btn-outline-primary" @click="addTask(group.projectId, 'WORK')">
<i class="bi bi-plus"></i>
</button>
</div>
<div>
<label class="form-label">차주 계획</label>
<textarea class="form-control" rows="3" v-model="proj.planDescription"
placeholder="다음 주에 수행할 업무를 작성해주세요."></textarea>
<div v-for="(task, tIdx) in getWorkTasks(group.projectId)" :key="'work-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<div class="form-check pt-1">
<input type="checkbox" class="form-check-input" v-model="task.isCompleted"
:id="'work-chk-'+group.projectId+'-'+tIdx" />
<label class="form-check-label small" :for="'work-chk-'+group.projectId+'-'+tIdx"
:class="task.isCompleted ? 'text-success' : 'text-warning'">
{{ task.isCompleted ? '완료' : '진행' }}
</label>
</div>
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="작업 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeTask(group.projectId, 'WORK', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
<!-- 차주 계획 -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0 fw-bold text-success">
<i class="bi bi-calendar-check me-1"></i>차주 계획
<span class="badge bg-success ms-1">{{ formatHoursDisplay(getGroupPlanHours(group)) }}</span>
</label>
<button type="button" class="btn btn-sm btn-outline-success" @click="addTask(group.projectId, 'PLAN')">
<i class="bi bi-plus"></i>
</button>
</div>
<div v-for="(task, tIdx) in getPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start">
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="계획 내용"></textarea>
<div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
<div class="small text-muted text-end">{{ formatHours(task.hours) }}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" @click="removeTask(group.projectId, 'PLAN', tIdx)">
<i class="bi bi-x"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 총계 -->
<div class="card mb-4 border-primary" v-if="form.tasks.length > 0">
<div class="card-body py-2">
<div class="row text-center">
<div class="col">
<span class="text-muted">금주 실적 합계</span>
<h5 class="mb-0 text-primary">{{ formatHoursDisplay(totalWorkHours) }}</h5>
</div>
<div class="col">
<span class="text-muted">차주 계획 합계</span>
<h5 class="mb-0 text-success">{{ formatHoursDisplay(totalPlanHours) }}</h5>
</div>
</div>
</div>
@@ -51,24 +150,21 @@
<!-- 공통 사항 -->
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong>
</div>
<div class="card-header"><strong><i class="bi bi-chat-left-text me-2"></i>공통 사항</strong></div>
<div class="card-body">
<div class="mb-3">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">이슈/리스크</label>
<textarea class="form-control" rows="3" v-model="form.issueDescription"
placeholder="진행 중 발생한 이슈나 리스크를 작성해주세요."></textarea>
<textarea class="form-control" rows="3" v-model="form.issueDescription"></textarea>
</div>
<div class="mb-3">
<div class="col-md-4 mb-3">
<label class="form-label">휴가일정</label>
<textarea class="form-control" rows="2" v-model="form.vacationDescription"
placeholder="예: 1/6(월) 연차, 1/8(수) 오후 반차"></textarea>
<textarea class="form-control" rows="3" v-model="form.vacationDescription"></textarea>
</div>
<div>
<div class="col-md-4 mb-3">
<label class="form-label">기타사항</label>
<textarea class="form-control" rows="2" v-model="form.remarkDescription"
placeholder="기타 전달사항이 있으면 작성해주세요."></textarea>
<textarea class="form-control" rows="3" v-model="form.remarkDescription"></textarea>
</div>
</div>
</div>
</div>
@@ -76,16 +172,16 @@
<!-- 버튼 -->
<div class="d-flex justify-content-end gap-2">
<NuxtLink to="/report/weekly" class="btn btn-secondary">취소</NuxtLink>
<button type="submit" class="btn btn-primary" :disabled="isSubmitting || form.projects.length === 0">
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-1"></span>
임시저장
<button type="submit" class="btn btn-primary" :disabled="isSaving || !canSubmit">
<span v-if="isSaving" class="spinner-border spinner-border-sm me-1"></span>
저장
</button>
</div>
</form>
</div>
<!-- 프로젝트 선택 모달 -->
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }" tabindex="-1">
<div class="modal fade" :class="{ show: showProjectModal }" :style="{ display: showProjectModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -93,16 +189,13 @@
<button type="button" class="btn-close" @click="showProjectModal = false"></button>
</div>
<div class="modal-body">
<div v-if="isLoadingProjects" class="text-center py-4">
<span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</div>
<div v-else-if="availableProjects.length === 0" class="text-center text-muted py-4">
<div v-if="availableProjects.length === 0" class="text-center text-muted py-4">
추가할 있는 프로젝트가 없습니다.
</div>
<div v-else class="list-group">
<button type="button" class="list-group-item list-group-item-action"
v-for="p in availableProjects" :key="p.projectId"
@click="addProject(p)">
@click="addProjectGroup(p)">
<strong>{{ p.projectName }}</strong>
<small class="text-muted ms-2">({{ p.projectCode }})</small>
</button>
@@ -117,103 +210,258 @@
<script setup lang="ts">
const { fetchCurrentUser } = useAuth()
const { getCurrentWeekInfo } = useWeekCalc()
const router = useRouter()
const weekInfo = getCurrentWeekInfo()
interface TaskItem {
projectId: number
taskType: 'WORK' | 'PLAN'
description: string
hours: number
isCompleted: boolean
}
interface ProjectItem {
interface ProjectGroup {
projectId: number
projectCode: string
projectName: string
workDescription: string
planDescription: string
}
const allProjects = ref<any[]>([])
const showProjectModal = ref(false)
const isSaving = ref(false)
const form = ref({
projects: [] as ProjectItem[],
reportYear: new Date().getFullYear(),
reportWeek: 1,
weekStartDate: '',
weekEndDate: '',
tasks: [] as TaskItem[],
issueDescription: '',
vacationDescription: '',
remarkDescription: ''
})
const allProjects = ref<any[]>([])
const isLoadingProjects = ref(false)
const isSubmitting = ref(false)
const showProjectModal = ref(false)
// 아직 추가하지 않은 프로젝트만
const availableProjects = computed(() => {
const addedIds = form.value.projects.map(p => p.projectId)
return allProjects.value.filter(p => !addedIds.includes(p.projectId))
const projectGroups = computed<ProjectGroup[]>(() => {
const projectIds = [...new Set(form.value.tasks.map(t => t.projectId))]
return projectIds.map(pid => {
const proj = allProjects.value.find(p => p.projectId === pid)
return {
projectId: pid,
projectCode: proj?.projectCode || '',
projectName: proj?.projectName || ''
}
})
})
const availableProjects = computed(() => {
const usedIds = projectGroups.value.map(g => g.projectId)
return allProjects.value.filter(p => !usedIds.includes(p.projectId))
})
const totalWorkHours = computed(() =>
form.value.tasks.filter(t => t.taskType === 'WORK').reduce((sum, t) => sum + (t.hours || 0), 0)
)
const totalPlanHours = computed(() =>
form.value.tasks.filter(t => t.taskType === 'PLAN').reduce((sum, t) => sum + (t.hours || 0), 0)
)
const canSubmit = computed(() => form.value.tasks.some(t => t.description.trim()))
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) {
router.push('/login')
return
}
loadProjects()
await loadProjects()
setLastWeek()
})
async function loadProjects() {
isLoadingProjects.value = true
try {
const res = await $fetch<any>('/api/project/list')
allProjects.value = res.projects || []
} catch (e) {
console.error(e)
} finally {
isLoadingProjects.value = false
}
}
function addProject(p: any) {
form.value.projects.push({
projectId: p.projectId,
projectCode: p.projectCode,
projectName: p.projectName,
workDescription: '',
planDescription: ''
// 주차 관련 함수들
function getMonday(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
return d
}
function getSunday(monday: Date): Date {
const d = new Date(monday)
d.setDate(d.getDate() + 6)
return d
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}
function getWeekNumber(date: Date): { year: number; week: number } {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7)
const week1 = new Date(d.getFullYear(), 0, 4)
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
return { year: d.getFullYear(), week: weekNum }
}
function setWeekDates(monday: Date) {
const sunday = getSunday(monday)
const weekInfo = getWeekNumber(monday)
form.value.weekStartDate = formatDate(monday)
form.value.weekEndDate = formatDate(sunday)
form.value.reportYear = weekInfo.year
form.value.reportWeek = weekInfo.week
}
function changeWeek(delta: number) {
const currentMonday = new Date(form.value.weekStartDate)
currentMonday.setDate(currentMonday.getDate() + (delta * 7))
setWeekDates(currentMonday)
}
function setLastWeek() {
const today = new Date()
const lastWeekMonday = getMonday(today)
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
setWeekDates(lastWeekMonday)
}
function setThisWeek() {
const today = new Date()
const thisWeekMonday = getMonday(today)
setWeekDates(thisWeekMonday)
}
function updateWeekFromDate() {
const startDate = new Date(form.value.weekStartDate)
const monday = getMonday(startDate)
setWeekDates(monday)
}
// Task 관련 함수들
function getWorkTasks(projectId: number) {
return form.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'WORK')
}
function getPlanTasks(projectId: number) {
return form.value.tasks.filter(t => t.projectId === projectId && t.taskType === 'PLAN')
}
function getGroupWorkHours(group: ProjectGroup) {
return getWorkTasks(group.projectId).reduce((sum, t) => sum + (t.hours || 0), 0)
}
function getGroupPlanHours(group: ProjectGroup) {
return getPlanTasks(group.projectId).reduce((sum, t) => sum + (t.hours || 0), 0)
}
function addProjectGroup(project: any) {
// 기본 Task 1개씩 추가
form.value.tasks.push({
projectId: project.projectId,
taskType: 'WORK',
description: '',
hours: 0,
isCompleted: true
})
form.value.tasks.push({
projectId: project.projectId,
taskType: 'PLAN',
description: '',
hours: 0,
isCompleted: true
})
showProjectModal.value = false
}
function removeProject(idx: number) {
form.value.projects.splice(idx, 1)
function removeProjectGroup(gIdx: number) {
const group = projectGroups.value[gIdx]
form.value.tasks = form.value.tasks.filter(t => t.projectId !== group.projectId)
}
function addTask(projectId: number, taskType: 'WORK' | 'PLAN') {
form.value.tasks.push({ projectId, taskType, description: '', hours: 0, isCompleted: true })
}
function removeTask(projectId: number, taskType: 'WORK' | 'PLAN', idx: number) {
const tasks = form.value.tasks.filter(t => t.projectId === projectId && t.taskType === taskType)
if (tasks.length <= 1) return // 최소 1개는 유지
const targetTask = tasks[idx]
const targetIndex = form.value.tasks.indexOf(targetTask)
if (targetIndex > -1) {
form.value.tasks.splice(targetIndex, 1)
}
}
// 시간 표시 함수
function formatHours(hours: number): string {
if (!hours || hours <= 0) return '-'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}h`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}h`
}
function formatHoursDisplay(hours: number): string {
if (!hours || hours <= 0) return '0h'
const days = Math.floor(hours / 8)
const remainHours = hours % 8
if (days === 0) return `${remainHours}시간`
if (remainHours === 0) return `${days}`
return `${days}${remainHours}시간`
}
async function handleSubmit() {
if (form.value.projects.length === 0) {
alert('최소 1개 이상의 프로젝트를 추가해주세요.')
const validTasks = form.value.tasks.filter(t => t.description.trim())
if (validTasks.length === 0) {
alert('최소 1개 이상의 Task를 입력해주세요.')
return
}
isSubmitting.value = true
isSaving.value = true
try {
const res = await $fetch<any>('/api/report/weekly/create', {
await $fetch('/api/report/weekly/create', {
method: 'POST',
body: {
reportYear: weekInfo.year,
reportWeek: weekInfo.week,
projects: form.value.projects.map(p => ({
projectId: p.projectId,
workDescription: p.workDescription,
planDescription: p.planDescription
reportYear: form.value.reportYear,
reportWeek: form.value.reportWeek,
weekStartDate: form.value.weekStartDate,
weekEndDate: form.value.weekEndDate,
tasks: validTasks.map(t => ({
projectId: t.projectId,
taskType: t.taskType,
taskDescription: t.description,
taskHours: t.hours || 0,
isCompleted: t.taskType === 'WORK' ? t.isCompleted : undefined
})),
issueDescription: form.value.issueDescription,
vacationDescription: form.value.vacationDescription,
remarkDescription: form.value.remarkDescription
}
})
alert('저장되었습니다.')
router.push(`/report/weekly/${res.reportId}`)
alert('주간보고가 작성되었습니다.')
router.push('/report/weekly')
} catch (e: any) {
alert(e.data?.message || '저장에 실패했습니다.')
} finally {
isSubmitting.value = false
isSaving.value = false
}
}
</script>

31
migrate-completed.mjs Normal file
View File

@@ -0,0 +1,31 @@
import pg from 'pg'
const pool = new pg.Pool({
host: '172.25.0.79',
port: 5433,
database: 'weeklyreport',
user: 'weeklyreport',
password: 'weeklyreport2026'
})
async function migrate() {
const client = await pool.connect()
try {
// 완료여부 컬럼 추가 (WORK 타입에만 사용)
await client.query(`
ALTER TABLE wr_weekly_report_task
ADD COLUMN IF NOT EXISTS is_completed BOOLEAN DEFAULT true
`)
console.log('is_completed 컬럼 추가 완료')
await client.query(`
COMMENT ON COLUMN wr_weekly_report_task.is_completed IS '완료여부 (WORK 타입에만 사용, true=완료, false=진행중)'
`)
console.log('마이그레이션 완료!')
} finally {
client.release()
await pool.end()
}
}
migrate().catch(console.error)

22
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"nuxt": "^3.15.4",
"openai": "^6.15.0",
"pg": "^8.13.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
@@ -7442,6 +7443,27 @@
"node": ">=8"
}
},
"node_modules/openai": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz",
"integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/oxc-minify": {
"version": "0.102.0",
"resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.102.0.tgz",

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"nuxt": "^3.15.4",
"openai": "^6.15.0",
"pg": "^8.13.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"