diff --git a/.env b/.env index 7ad0e06..a32d24b 100644 --- a/.env +++ b/.env @@ -7,7 +7,6 @@ DB_PASSWORD=weeklyreport2026 # App SESSION_SECRET=dev-secret-key-change-in-production -AUTO_START_SCHEDULER=false # TODO: Google OAuth # GOOGLE_CLIENT_ID= diff --git a/backend/api/auth/login-history.get.ts b/backend/api/auth/login-history.get.ts new file mode 100644 index 0000000..51858c0 --- /dev/null +++ b/backend/api/auth/login-history.get.ts @@ -0,0 +1,37 @@ +import { query } from '../../utils/db' + +/** + * 본인 로그인 이력 조회 + * GET /api/auth/login-history + */ +export default defineEventHandler(async (event) => { + const userId = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + const history = await query(` + SELECT + history_id, + login_at, + login_ip, + logout_at, + logout_ip, + last_active_at + FROM wr_login_history + WHERE employee_id = $1 + ORDER BY login_at DESC + LIMIT 50 + `, [userId]) + + return { + history: history.map(h => ({ + historyId: h.history_id, + loginAt: h.login_at, + loginIp: h.login_ip, + logoutAt: h.logout_at, + logoutIp: h.logout_ip, + lastActiveAt: h.last_active_at + })) + } +}) diff --git a/backend/api/auth/login.post.ts b/backend/api/auth/login.post.ts index 88845a2..a0823f0 100644 --- a/backend/api/auth/login.post.ts +++ b/backend/api/auth/login.post.ts @@ -1,4 +1,5 @@ import { query, insertReturning, execute } from '../../utils/db' +import { getClientIp } from '../../utils/ip' interface LoginBody { email: string @@ -11,6 +12,7 @@ interface LoginBody { */ export default defineEventHandler(async (event) => { const body = await readBody(event) + const clientIp = getClientIp(event) if (!body.email || !body.name) { throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' }) @@ -22,31 +24,52 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: '올바른 이메일 형식이 아닙니다.' }) } - // 기존 사원 조회 + const emailLower = body.email.toLowerCase() + const nameTrimmed = body.name.trim() + + // 기존 직원 조회 let employee = await query(` SELECT * FROM wr_employee_info WHERE employee_email = $1 - `, [body.email.toLowerCase()]) + `, [emailLower]) let employeeData = employee[0] - // 없으면 자동 등록 - if (!employeeData) { + if (employeeData) { + // 기존 직원 - 이름이 다르면 업데이트 + if (employeeData.employee_name !== nameTrimmed) { + await execute(` + UPDATE wr_employee_info + SET employee_name = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3 + WHERE employee_id = $4 + `, [nameTrimmed, clientIp, emailLower, employeeData.employee_id]) + employeeData.employee_name = nameTrimmed + } + } else { + // 신규 직원 자동 등록 employeeData = await insertReturning(` - INSERT INTO wr_employee_info (employee_name, employee_email) - VALUES ($1, $2) + INSERT INTO wr_employee_info (employee_name, employee_email, created_ip, created_email, updated_ip, updated_email) + VALUES ($1, $2, $3, $2, $3, $2) RETURNING * - `, [body.name, body.email.toLowerCase()]) + `, [nameTrimmed, emailLower, clientIp]) } // 로그인 이력 추가 - await execute(` - INSERT INTO wr_login_history (employee_id) VALUES ($1) - `, [employeeData.employee_id]) + const loginHistory = await insertReturning(` + INSERT INTO wr_login_history (employee_id, login_ip, login_email) + VALUES ($1, $2, $3) + RETURNING history_id + `, [employeeData.employee_id, clientIp, emailLower]) - // 쿠키에 사용자 정보 저장 (간단한 임시 세션) + // 쿠키에 사용자 정보 저장 setCookie(event, 'user_id', String(employeeData.employee_id), { httpOnly: true, - maxAge: 60 * 60 * 24 * 7, // 7일 + maxAge: 60 * 60 * 24 * 7, + path: '/' + }) + + setCookie(event, 'login_history_id', String(loginHistory.history_id), { + httpOnly: true, + maxAge: 60 * 60 * 24 * 7, path: '/' }) diff --git a/backend/api/auth/logout.post.ts b/backend/api/auth/logout.post.ts index ae71878..6540238 100644 --- a/backend/api/auth/logout.post.ts +++ b/backend/api/auth/logout.post.ts @@ -1,8 +1,26 @@ +import { execute } from '../../utils/db' +import { getClientIp } from '../../utils/ip' + /** * 로그아웃 * POST /api/auth/logout */ export default defineEventHandler(async (event) => { + const historyId = getCookie(event, 'login_history_id') + const clientIp = getClientIp(event) + + // 로그아웃 이력 기록 + if (historyId) { + await execute(` + UPDATE wr_login_history + SET logout_at = NOW(), logout_ip = $1 + WHERE history_id = $2 + `, [clientIp, historyId]) + } + + // 쿠키 삭제 deleteCookie(event, 'user_id') + deleteCookie(event, 'login_history_id') + return { success: true } }) diff --git a/backend/api/auth/me.get.ts b/backend/api/auth/me.get.ts new file mode 100644 index 0000000..f4180f3 --- /dev/null +++ b/backend/api/auth/me.get.ts @@ -0,0 +1,43 @@ +import { queryOne } from '../../utils/db' + +/** + * 로그인된 사용자 정보 조회 + * GET /api/auth/me + */ +export default defineEventHandler(async (event) => { + const userId = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + const employee = await queryOne(` + SELECT + employee_id, + employee_name, + employee_email, + employee_phone, + employee_position, + company, + join_date, + is_active + FROM wr_employee_info + WHERE employee_id = $1 + `, [userId]) + + if (!employee) { + throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' }) + } + + return { + user: { + employeeId: employee.employee_id, + employeeName: employee.employee_name, + employeeEmail: employee.employee_email, + employeePhone: employee.employee_phone, + employeePosition: employee.employee_position, + company: employee.company, + joinDate: employee.join_date, + isActive: employee.is_active + } + } +}) diff --git a/backend/api/employee/[id]/detail.get.ts b/backend/api/employee/[id]/detail.get.ts index 74fc474..8febd90 100644 --- a/backend/api/employee/[id]/detail.get.ts +++ b/backend/api/employee/[id]/detail.get.ts @@ -1,7 +1,7 @@ import { queryOne } from '../../../utils/db' /** - * 사원 상세 조회 + * 직원 상세 조회 * GET /api/employee/[id]/detail */ export default defineEventHandler(async (event) => { @@ -12,19 +12,21 @@ export default defineEventHandler(async (event) => { `, [employeeId]) if (!employee) { - throw createError({ statusCode: 404, message: '사원을 찾을 수 없습니다.' }) + throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' }) } return { - employeeId: employee.employee_id, - employeeNumber: employee.employee_number, - employeeName: employee.employee_name, - employeeEmail: employee.employee_email, - employeePhone: employee.employee_phone, - employeePosition: employee.employee_position, - joinDate: employee.join_date, - isActive: employee.is_active, - createdAt: employee.created_at, - updatedAt: employee.updated_at + employee: { + employeeId: employee.employee_id, + employeeName: employee.employee_name, + employeeEmail: employee.employee_email, + employeePhone: employee.employee_phone, + employeePosition: employee.employee_position, + company: employee.company, + joinDate: employee.join_date, + isActive: employee.is_active, + createdAt: employee.created_at, + updatedAt: employee.updated_at + } } }) diff --git a/backend/api/employee/[id]/update.put.ts b/backend/api/employee/[id]/update.put.ts index 1c23f38..64b90c0 100644 --- a/backend/api/employee/[id]/update.put.ts +++ b/backend/api/employee/[id]/update.put.ts @@ -1,47 +1,55 @@ import { execute, queryOne } from '../../../utils/db' +import { getClientIp } from '../../../utils/ip' +import { getCurrentUserEmail } from '../../../utils/user' interface UpdateEmployeeBody { - employeeNumber?: string employeeName?: string employeePhone?: string employeePosition?: string + company?: string joinDate?: string isActive?: boolean } /** - * 사원 정보 수정 + * 직원 정보 수정 * PUT /api/employee/[id]/update */ export default defineEventHandler(async (event) => { const employeeId = getRouterParam(event, 'id') const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) const existing = await queryOne(` SELECT * FROM wr_employee_info WHERE employee_id = $1 `, [employeeId]) if (!existing) { - throw createError({ statusCode: 404, message: '사원을 찾을 수 없습니다.' }) + throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' }) } await execute(` UPDATE wr_employee_info SET - employee_number = $1, - employee_name = $2, - employee_phone = $3, - employee_position = $4, + employee_name = $1, + employee_phone = $2, + employee_position = $3, + company = $4, join_date = $5, is_active = $6, - updated_at = NOW() - WHERE employee_id = $7 + updated_at = NOW(), + updated_ip = $7, + updated_email = $8 + WHERE employee_id = $9 `, [ - body.employeeNumber ?? existing.employee_number, body.employeeName ?? existing.employee_name, body.employeePhone ?? existing.employee_phone, body.employeePosition ?? existing.employee_position, + body.company ?? existing.company, body.joinDate ?? existing.join_date, body.isActive ?? existing.is_active, + clientIp, + userEmail, employeeId ]) diff --git a/backend/api/employee/create.post.ts b/backend/api/employee/create.post.ts index 64090d3..a0ccf53 100644 --- a/backend/api/employee/create.post.ts +++ b/backend/api/employee/create.post.ts @@ -1,20 +1,24 @@ import { insertReturning, queryOne } from '../../utils/db' +import { getClientIp } from '../../utils/ip' +import { getCurrentUserEmail } from '../../utils/user' interface CreateEmployeeBody { - employeeNumber?: string employeeName: string employeeEmail: string employeePhone?: string employeePosition?: string + company?: string joinDate?: string } /** - * 사원 등록 + * 직원 등록 * POST /api/employee/create */ export default defineEventHandler(async (event) => { const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) if (!body.employeeName || !body.employeeEmail) { throw createError({ statusCode: 400, message: '이름과 이메일은 필수입니다.' }) @@ -31,17 +35,20 @@ export default defineEventHandler(async (event) => { const employee = await insertReturning(` INSERT INTO wr_employee_info ( - employee_number, employee_name, employee_email, - employee_phone, employee_position, join_date - ) VALUES ($1, $2, $3, $4, $5, $6) + employee_name, employee_email, employee_phone, + employee_position, company, join_date, + created_ip, created_email, updated_ip, updated_email + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8) RETURNING * `, [ - body.employeeNumber || null, body.employeeName, body.employeeEmail.toLowerCase(), body.employeePhone || null, body.employeePosition || null, - body.joinDate || null + body.company || '(주)터보소프트', + body.joinDate || null, + clientIp, + userEmail ]) return { diff --git a/backend/api/employee/list.get.ts b/backend/api/employee/list.get.ts index f6698b6..7885b27 100644 --- a/backend/api/employee/list.get.ts +++ b/backend/api/employee/list.get.ts @@ -1,7 +1,7 @@ import { query } from '../../utils/db' /** - * 사원 목록 조회 + * 직원 목록 조회 * GET /api/employee/list */ export default defineEventHandler(async (event) => { @@ -16,15 +16,17 @@ export default defineEventHandler(async (event) => { const employees = await query(sql) - return employees.map((e: any) => ({ - employeeId: e.employee_id, - employeeNumber: e.employee_number, - employeeName: e.employee_name, - employeeEmail: e.employee_email, - employeePhone: e.employee_phone, - employeePosition: e.employee_position, - joinDate: e.join_date, - isActive: e.is_active, - createdAt: e.created_at - })) + return { + employees: employees.map((e: any) => ({ + employeeId: e.employee_id, + employeeName: e.employee_name, + employeeEmail: e.employee_email, + employeePhone: e.employee_phone, + employeePosition: e.employee_position, + company: e.company, + joinDate: e.join_date, + isActive: e.is_active, + createdAt: e.created_at + })) + } }) diff --git a/backend/api/project/[id]/detail.get.ts b/backend/api/project/[id]/detail.get.ts index dc631b3..0641fd6 100644 --- a/backend/api/project/[id]/detail.get.ts +++ b/backend/api/project/[id]/detail.get.ts @@ -27,6 +27,7 @@ export default defineEventHandler(async (event) => { projectId: project.project_id, projectCode: project.project_code, projectName: project.project_name, + projectType: project.project_type || 'SI', clientName: project.client_name, projectDescription: project.project_description, startDate: project.start_date, diff --git a/backend/api/project/[id]/manager-assign.post.ts b/backend/api/project/[id]/manager-assign.post.ts index edc44e2..98a3188 100644 --- a/backend/api/project/[id]/manager-assign.post.ts +++ b/backend/api/project/[id]/manager-assign.post.ts @@ -1,5 +1,7 @@ import { execute, queryOne, insertReturning } from '../../../utils/db' import { formatDate } from '../../../utils/week-calc' +import { getClientIp } from '../../../utils/ip' +import { getCurrentUserEmail } from '../../../utils/user' interface AssignManagerBody { employeeId: number @@ -15,6 +17,8 @@ interface AssignManagerBody { export default defineEventHandler(async (event) => { const projectId = getRouterParam(event, 'id') const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) if (!body.employeeId || !body.roleType) { throw createError({ statusCode: 400, message: '담당자와 역할을 선택해주세요.' }) @@ -31,17 +35,21 @@ export default defineEventHandler(async (event) => { await execute(` UPDATE wr_project_manager_history SET end_date = $1, - change_reason = COALESCE(change_reason || ' / ', '') || '신규 담당자 지정으로 종료' - WHERE project_id = $2 AND role_type = $3 AND end_date IS NULL - `, [startDate, projectId, body.roleType]) + change_reason = COALESCE(change_reason || ' / ', '') || '신규 담당자 지정으로 종료', + updated_at = NOW(), + updated_ip = $2, + updated_email = $3 + WHERE project_id = $4 AND role_type = $5 AND end_date IS NULL + `, [startDate, clientIp, userEmail, projectId, body.roleType]) // 신규 담당자 등록 const history = await insertReturning(` INSERT INTO wr_project_manager_history ( - project_id, employee_id, role_type, start_date, change_reason - ) VALUES ($1, $2, $3, $4, $5) + project_id, employee_id, role_type, start_date, change_reason, + created_ip, created_email, updated_ip, updated_email + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $6, $7) RETURNING * - `, [projectId, body.employeeId, body.roleType, startDate, body.changeReason || null]) + `, [projectId, body.employeeId, body.roleType, startDate, body.changeReason || null, clientIp, userEmail]) return { success: true, diff --git a/backend/api/project/[id]/update.put.ts b/backend/api/project/[id]/update.put.ts index aff160c..86222cd 100644 --- a/backend/api/project/[id]/update.put.ts +++ b/backend/api/project/[id]/update.put.ts @@ -1,8 +1,10 @@ import { execute, queryOne } from '../../../utils/db' +import { getClientIp } from '../../../utils/ip' +import { getCurrentUserEmail } from '../../../utils/user' interface UpdateProjectBody { - projectCode?: string projectName?: string + projectType?: string clientName?: string projectDescription?: string startDate?: string @@ -18,6 +20,8 @@ interface UpdateProjectBody { export default defineEventHandler(async (event) => { const projectId = getRouterParam(event, 'id') const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) const existing = await queryOne(` SELECT * FROM wr_project_info WHERE project_id = $1 @@ -27,27 +31,36 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, message: '프로젝트를 찾을 수 없습니다.' }) } + // 프로젝트 유형 검증 + if (body.projectType && !['SI', 'SM'].includes(body.projectType)) { + throw createError({ statusCode: 400, message: '프로젝트 유형은 SI 또는 SM이어야 합니다.' }) + } + await execute(` UPDATE wr_project_info SET - project_code = $1, - project_name = $2, + project_name = $1, + project_type = $2, client_name = $3, project_description = $4, start_date = $5, end_date = $6, contract_amount = $7, project_status = $8, - updated_at = NOW() - WHERE project_id = $9 + updated_at = NOW(), + updated_ip = $9, + updated_email = $10 + WHERE project_id = $11 `, [ - body.projectCode ?? existing.project_code, body.projectName ?? existing.project_name, + body.projectType ?? existing.project_type ?? 'SI', body.clientName ?? existing.client_name, body.projectDescription ?? existing.project_description, body.startDate ?? existing.start_date, body.endDate ?? existing.end_date, body.contractAmount ?? existing.contract_amount, body.projectStatus ?? existing.project_status, + clientIp, + userEmail, projectId ]) diff --git a/backend/api/project/create.post.ts b/backend/api/project/create.post.ts index c915f1e..4fe2452 100644 --- a/backend/api/project/create.post.ts +++ b/backend/api/project/create.post.ts @@ -1,8 +1,10 @@ -import { insertReturning } from '../../utils/db' +import { query, insertReturning } from '../../utils/db' +import { getClientIp } from '../../utils/ip' +import { getCurrentUserEmail } from '../../utils/user' interface CreateProjectBody { - projectCode?: string projectName: string + projectType?: string // SI / SM clientName?: string projectDescription?: string startDate?: string @@ -10,38 +12,80 @@ interface CreateProjectBody { contractAmount?: number } +/** + * 프로젝트 코드 자동 생성 (년도-일련번호) + */ +async function generateProjectCode(): Promise { + const year = new Date().getFullYear() + const prefix = `${year}-` + + // 해당 연도의 마지막 코드 조회 + const result = await query<{ project_code: string }>(` + SELECT project_code FROM wr_project_info + WHERE project_code LIKE $1 + ORDER BY project_code DESC + LIMIT 1 + `, [`${prefix}%`]) + + let nextNum = 1 + if (result.length > 0 && result[0].project_code) { + const lastCode = result[0].project_code + const lastNum = parseInt(lastCode.split('-')[1]) || 0 + nextNum = lastNum + 1 + } + + return `${prefix}${String(nextNum).padStart(3, '0')}` +} + /** * 프로젝트 등록 * POST /api/project/create */ export default defineEventHandler(async (event) => { const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) if (!body.projectName) { throw createError({ statusCode: 400, message: '프로젝트명을 입력해주세요.' }) } + // 프로젝트 유형 검증 + const projectType = body.projectType || 'SI' + if (!['SI', 'SM'].includes(projectType)) { + throw createError({ statusCode: 400, message: '프로젝트 유형은 SI 또는 SM이어야 합니다.' }) + } + + // 프로젝트 코드 자동 생성 + const projectCode = await generateProjectCode() + const project = await insertReturning(` INSERT INTO wr_project_info ( - project_code, project_name, client_name, project_description, - start_date, end_date, contract_amount - ) VALUES ($1, $2, $3, $4, $5, $6, $7) + project_code, project_name, project_type, client_name, project_description, + start_date, end_date, contract_amount, + created_ip, created_email, updated_ip, updated_email + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $9, $10) RETURNING * `, [ - body.projectCode || null, + projectCode, body.projectName, + projectType, body.clientName || null, body.projectDescription || null, body.startDate || null, body.endDate || null, - body.contractAmount || null + body.contractAmount || null, + clientIp, + userEmail ]) return { success: true, project: { projectId: project.project_id, - projectName: project.project_name + projectCode: project.project_code, + projectName: project.project_name, + projectType: project.project_type } } }) diff --git a/backend/api/project/list.get.ts b/backend/api/project/list.get.ts index cc2bbc8..aedaa2b 100644 --- a/backend/api/project/list.get.ts +++ b/backend/api/project/list.get.ts @@ -38,6 +38,7 @@ export default defineEventHandler(async (event) => { projectId: p.project_id, projectCode: p.project_code, projectName: p.project_name, + projectType: p.project_type || 'SI', clientName: p.client_name, projectDescription: p.project_description, startDate: p.start_date, diff --git a/backend/api/report/summary/[id]/detail.get.ts b/backend/api/report/summary/[id]/detail.get.ts index 515b153..09cbb27 100644 --- a/backend/api/report/summary/[id]/detail.get.ts +++ b/backend/api/report/summary/[id]/detail.get.ts @@ -21,12 +21,24 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, message: '취합 보고서를 찾을 수 없습니다.' }) } - // 개별 보고서 목록 + // 개별 보고서 목록 (새 구조: 마스터 + 프로젝트별 실적 조인) const reports = await query(` - SELECT r.*, e.employee_name as author_name, e.employee_position - FROM wr_weekly_report_detail r + SELECT + r.report_id, + r.author_id, + e.employee_name as author_name, + e.employee_position, + r.issue_description, + r.vacation_description, + r.remark_description, + r.report_status, + r.submitted_at, + rp.work_description, + rp.plan_description + FROM wr_weekly_report r + JOIN wr_weekly_report_project rp ON r.report_id = rp.report_id JOIN wr_employee_info e ON r.author_id = e.employee_id - WHERE r.project_id = $1 AND r.report_year = $2 AND r.report_week = $3 + WHERE rp.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]) @@ -41,7 +53,6 @@ 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, @@ -56,8 +67,8 @@ export default defineEventHandler(async (event) => { workDescription: r.work_description, planDescription: r.plan_description, issueDescription: r.issue_description, + vacationDescription: r.vacation_description, remarkDescription: r.remark_description, - workHours: r.work_hours, reportStatus: r.report_status, submittedAt: r.submitted_at })) diff --git a/backend/api/report/summary/aggregate.post.ts b/backend/api/report/summary/aggregate.post.ts new file mode 100644 index 0000000..0355622 --- /dev/null +++ b/backend/api/report/summary/aggregate.post.ts @@ -0,0 +1,108 @@ +import { query, queryOne, insertReturning, execute } from '../../../utils/db' +import { getClientIp } from '../../../utils/ip' +import { getCurrentUserEmail } from '../../../utils/user' + +interface AggregateBody { + projectId: number + reportYear: number + reportWeek: number +} + +/** + * 수동 취합 실행 + * POST /api/report/summary/aggregate + */ +export default defineEventHandler(async (event) => { + const userId = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) + + if (!body.projectId || !body.reportYear || !body.reportWeek) { + throw createError({ statusCode: 400, message: '프로젝트, 연도, 주차를 선택해주세요.' }) + } + + // 해당 프로젝트/주차의 제출된 보고서 조회 (새 구조) + const reports = await query(` + SELECT + r.report_id, + r.author_id, + r.week_start_date, + r.week_end_date, + rp.detail_id + FROM wr_weekly_report r + JOIN wr_weekly_report_project rp ON r.report_id = rp.report_id + WHERE rp.project_id = $1 + AND r.report_year = $2 + AND r.report_week = $3 + AND r.report_status IN ('SUBMITTED', 'AGGREGATED') + ORDER BY r.report_id + `, [body.projectId, body.reportYear, body.reportWeek]) + + if (reports.length === 0) { + throw createError({ statusCode: 400, message: '취합할 보고서가 없습니다.' }) + } + + const reportIds = [...new Set(reports.map(r => r.report_id))] + const weekStartDate = reports[0].week_start_date + const weekEndDate = reports[0].week_end_date + + // 기존 취합 보고서 확인 + const existing = await queryOne(` + SELECT summary_id FROM wr_aggregated_report_summary + WHERE project_id = $1 AND report_year = $2 AND report_week = $3 + `, [body.projectId, body.reportYear, body.reportWeek]) + + let summaryId: number + + if (existing) { + // 기존 취합 업데이트 + await execute(` + UPDATE wr_aggregated_report_summary + SET report_ids = $1, + member_count = $2, + aggregated_at = NOW(), + updated_at = NOW(), + updated_ip = $3, + updated_email = $4 + WHERE summary_id = $5 + `, [reportIds, reportIds.length, clientIp, userEmail, existing.summary_id]) + summaryId = existing.summary_id + } else { + // 새 취합 생성 + const newSummary = await insertReturning(` + INSERT INTO wr_aggregated_report_summary ( + project_id, report_year, report_week, week_start_date, week_end_date, + report_ids, member_count, + created_ip, created_email, updated_ip, updated_email + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $8, $9) + RETURNING summary_id + `, [ + body.projectId, body.reportYear, body.reportWeek, + weekStartDate, weekEndDate, + reportIds, reportIds.length, + clientIp, userEmail + ]) + summaryId = newSummary.summary_id + } + + // 개별 보고서 상태 업데이트 + await execute(` + UPDATE wr_weekly_report + SET report_status = 'AGGREGATED', + updated_at = NOW(), + updated_ip = $1, + updated_email = $2 + WHERE report_id = ANY($3) + `, [clientIp, userEmail, reportIds]) + + return { + success: true, + summaryId, + memberCount: reportIds.length + } +}) diff --git a/backend/api/report/weekly/[id]/detail.get.ts b/backend/api/report/weekly/[id]/detail.get.ts index 594208c..a3c199b 100644 --- a/backend/api/report/weekly/[id]/detail.get.ts +++ b/backend/api/report/weekly/[id]/detail.get.ts @@ -1,4 +1,4 @@ -import { queryOne } from '../../../../utils/db' +import { query, queryOne } from '../../../../utils/db' /** * 주간보고 상세 조회 @@ -12,10 +12,13 @@ export default defineEventHandler(async (event) => { const reportId = getRouterParam(event, 'id') + // 마스터 조회 const report = await queryOne(` - SELECT r.*, p.project_name, p.project_code, e.employee_name as author_name - FROM wr_weekly_report_detail r - JOIN wr_project_info p ON r.project_id = p.project_id + SELECT + r.*, + e.employee_name as author_name, + e.employee_email as author_email + FROM wr_weekly_report r JOIN wr_employee_info e ON r.author_id = e.employee_id WHERE r.report_id = $1 `, [reportId]) @@ -24,25 +27,46 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' }) } + // 프로젝트별 실적 조회 + const projects = await query(` + SELECT + rp.detail_id, + rp.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 + `, [reportId]) + return { - reportId: report.report_id, - projectId: report.project_id, - projectName: report.project_name, - projectCode: report.project_code, - authorId: report.author_id, - authorName: report.author_name, - reportYear: report.report_year, - reportWeek: report.report_week, - weekStartDate: report.week_start_date, - weekEndDate: report.week_end_date, - workDescription: report.work_description, - planDescription: report.plan_description, - issueDescription: report.issue_description, - remarkDescription: report.remark_description, - workHours: report.work_hours, - reportStatus: report.report_status, - submittedAt: report.submitted_at, - createdAt: report.created_at, - updatedAt: report.updated_at + report: { + reportId: report.report_id, + authorId: report.author_id, + authorName: report.author_name, + authorEmail: report.author_email, + reportYear: report.report_year, + reportWeek: report.report_week, + weekStartDate: report.week_start_date, + weekEndDate: 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 + }, + 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 + })) } }) diff --git a/backend/api/report/weekly/[id]/submit.post.ts b/backend/api/report/weekly/[id]/submit.post.ts index cc894c5..b8d7fe8 100644 --- a/backend/api/report/weekly/[id]/submit.post.ts +++ b/backend/api/report/weekly/[id]/submit.post.ts @@ -1,4 +1,6 @@ import { execute, queryOne } from '../../../../utils/db' +import { getClientIp } from '../../../../utils/ip' +import { getCurrentUserEmail } from '../../../../utils/user' /** * 주간보고 제출 @@ -11,10 +13,12 @@ export default defineEventHandler(async (event) => { } const reportId = getRouterParam(event, 'id') + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) // 보고서 조회 및 권한 확인 const report = await queryOne(` - SELECT * FROM wr_weekly_report_detail WHERE report_id = $1 + SELECT * FROM wr_weekly_report WHERE report_id = $1 `, [reportId]) if (!report) { @@ -25,13 +29,19 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 403, message: '본인의 보고서만 제출할 수 있습니다.' }) } + if (report.report_status !== 'DRAFT') { + throw createError({ statusCode: 400, message: '이미 제출된 보고서입니다.' }) + } + await execute(` - UPDATE wr_weekly_report_detail SET + UPDATE wr_weekly_report SET report_status = 'SUBMITTED', submitted_at = NOW(), - updated_at = NOW() - WHERE report_id = $1 - `, [reportId]) + updated_at = NOW(), + updated_ip = $1, + updated_email = $2 + WHERE report_id = $3 + `, [clientIp, userEmail, reportId]) return { success: true } }) diff --git a/backend/api/report/weekly/[id]/update.put.ts b/backend/api/report/weekly/[id]/update.put.ts index bcaafaa..ded597c 100644 --- a/backend/api/report/weekly/[id]/update.put.ts +++ b/backend/api/report/weekly/[id]/update.put.ts @@ -1,11 +1,18 @@ -import { execute, queryOne } from '../../../../utils/db' +import { execute, query, queryOne } from '../../../../utils/db' +import { getClientIp } from '../../../../utils/ip' +import { getCurrentUserEmail } from '../../../../utils/user' -interface UpdateReportBody { +interface ProjectItem { + projectId: number workDescription?: string planDescription?: string +} + +interface UpdateReportBody { + projects?: ProjectItem[] issueDescription?: string + vacationDescription?: string remarkDescription?: string - workHours?: number } /** @@ -20,10 +27,12 @@ export default defineEventHandler(async (event) => { const reportId = getRouterParam(event, 'id') const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) // 보고서 조회 및 권한 확인 const report = await queryOne(` - SELECT * FROM wr_weekly_report_detail WHERE report_id = $1 + SELECT * FROM wr_weekly_report WHERE report_id = $1 `, [reportId]) if (!report) { @@ -34,23 +43,50 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 403, message: '본인의 보고서만 수정할 수 있습니다.' }) } + if (report.report_status === 'SUBMITTED' || report.report_status === 'AGGREGATED') { + throw createError({ statusCode: 400, message: '제출된 보고서는 수정할 수 없습니다.' }) + } + + // 마스터 업데이트 await execute(` - UPDATE wr_weekly_report_detail SET - work_description = $1, - plan_description = $2, - issue_description = $3, - remark_description = $4, - work_hours = $5, - updated_at = NOW() + UPDATE wr_weekly_report SET + issue_description = $1, + vacation_description = $2, + remark_description = $3, + updated_at = NOW(), + updated_ip = $4, + updated_email = $5 WHERE report_id = $6 `, [ - body.workDescription ?? report.work_description, - body.planDescription ?? report.plan_description, body.issueDescription ?? report.issue_description, + body.vacationDescription ?? report.vacation_description, body.remarkDescription ?? report.remark_description, - body.workHours ?? report.work_hours, + clientIp, + userEmail, reportId ]) + // 프로젝트별 실적 업데이트 + if (body.projects && body.projects.length > 0) { + // 기존 삭제 후 재등록 + await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId]) + + for (const proj of body.projects) { + await execute(` + INSERT INTO wr_weekly_report_project ( + report_id, project_id, work_description, plan_description, + created_ip, created_email, updated_ip, updated_email + ) VALUES ($1, $2, $3, $4, $5, $6, $5, $6) + `, [ + reportId, + proj.projectId, + proj.workDescription || null, + proj.planDescription || null, + clientIp, + userEmail + ]) + } + } + return { success: true } }) diff --git a/backend/api/report/weekly/create.post.ts b/backend/api/report/weekly/create.post.ts index 2a81e79..7ba0f0c 100644 --- a/backend/api/report/weekly/create.post.ts +++ b/backend/api/report/weekly/create.post.ts @@ -1,15 +1,21 @@ -import { insertReturning, queryOne } from '../../../utils/db' +import { query, insertReturning, execute } from '../../../utils/db' import { getWeekInfo } from '../../../utils/week-calc' +import { getClientIp } from '../../../utils/ip' +import { getCurrentUserEmail } from '../../../utils/user' -interface CreateReportBody { +interface ProjectItem { projectId: number - reportYear?: number - reportWeek?: number workDescription?: string planDescription?: string +} + +interface CreateReportBody { + reportYear?: number + reportWeek?: number + projects: ProjectItem[] issueDescription?: string + vacationDescription?: string remarkDescription?: string - workHours?: number } /** @@ -23,9 +29,11 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event) + const clientIp = getClientIp(event) + const userEmail = await getCurrentUserEmail(event) - if (!body.projectId) { - throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' }) + if (!body.projects || body.projects.length === 0) { + throw createError({ statusCode: 400, message: '최소 1개 이상의 프로젝트를 추가해주세요.' }) } // 주차 정보 (기본값: 이번 주) @@ -34,40 +42,57 @@ export default defineEventHandler(async (event) => { const week = body.reportWeek || weekInfo.week // 중복 체크 - const existing = await queryOne(` - SELECT report_id FROM wr_weekly_report_detail - WHERE project_id = $1 AND author_id = $2 AND report_year = $3 AND report_week = $4 - `, [body.projectId, parseInt(userId), year, week]) + const existing = await query(` + SELECT report_id FROM wr_weekly_report + WHERE author_id = $1 AND report_year = $2 AND report_week = $3 + `, [parseInt(userId), year, week]) - if (existing) { + if (existing.length > 0) { throw createError({ statusCode: 409, message: '이미 해당 주차 보고서가 존재합니다.' }) } // 주차 날짜 계산 const dates = getWeekInfo(new Date(year, 0, 4 + (week - 1) * 7)) + // 마스터 생성 const report = await insertReturning(` - INSERT INTO wr_weekly_report_detail ( - project_id, author_id, report_year, report_week, + INSERT INTO wr_weekly_report ( + author_id, report_year, report_week, week_start_date, week_end_date, - work_description, plan_description, issue_description, remark_description, - work_hours, report_status - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'DRAFT') + 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 * `, [ - body.projectId, parseInt(userId), year, week, dates.startDateStr, dates.endDateStr, - body.workDescription || null, - body.planDescription || null, body.issueDescription || null, + body.vacationDescription || null, body.remarkDescription || null, - body.workHours || null + clientIp, + userEmail ]) + // 프로젝트별 실적 저장 + for (const proj of body.projects) { + await execute(` + INSERT INTO wr_weekly_report_project ( + report_id, project_id, work_description, plan_description, + created_ip, created_email, updated_ip, updated_email + ) VALUES ($1, $2, $3, $4, $5, $6, $5, $6) + `, [ + report.report_id, + proj.projectId, + proj.workDescription || null, + proj.planDescription || null, + clientIp, + userEmail + ]) + } + return { success: true, reportId: report.report_id diff --git a/backend/api/report/weekly/list.get.ts b/backend/api/report/weekly/list.get.ts index 40d67e7..17bbba7 100644 --- a/backend/api/report/weekly/list.get.ts +++ b/backend/api/report/weekly/list.get.ts @@ -1,7 +1,7 @@ import { query } from '../../../utils/db' /** - * 내 주간보고 목록 + * 주간보고 목록 조회 * GET /api/report/weekly/list */ export default defineEventHandler(async (event) => { @@ -11,50 +11,45 @@ export default defineEventHandler(async (event) => { } const queryParams = getQuery(event) - const year = queryParams.year ? parseInt(queryParams.year as string) : null - const projectId = queryParams.projectId ? parseInt(queryParams.projectId as string) : null + const limit = parseInt(queryParams.limit as string) || 20 - let sql = ` - SELECT r.*, p.project_name, p.project_code - FROM wr_weekly_report_detail r - JOIN wr_project_info p ON r.project_id = p.project_id + const reports = await query(` + SELECT + r.report_id, + r.author_id, + e.employee_name as author_name, + r.report_year, + r.report_week, + r.week_start_date, + r.week_end_date, + r.issue_description, + r.vacation_description, + r.report_status, + r.submitted_at, + r.created_at, + (SELECT COUNT(*) FROM wr_weekly_report_project WHERE report_id = r.report_id) as project_count + FROM wr_weekly_report r + JOIN wr_employee_info e ON r.author_id = e.employee_id WHERE r.author_id = $1 - ` - const params: any[] = [parseInt(userId)] - let paramIndex = 2 - - if (year) { - sql += ` AND r.report_year = $${paramIndex++}` - params.push(year) - } - if (projectId) { - sql += ` AND r.project_id = $${paramIndex++}` - params.push(projectId) - } - - sql += ' ORDER BY r.report_year DESC, r.report_week DESC' - - const reports = await query(sql, params) + ORDER BY r.report_year DESC, r.report_week DESC + LIMIT $2 + `, [userId, limit]) return { reports: reports.map((r: any) => ({ reportId: r.report_id, - projectId: r.project_id, - projectName: r.project_name, - projectCode: r.project_code, + authorId: r.author_id, + authorName: r.author_name, reportYear: r.report_year, reportWeek: r.report_week, weekStartDate: r.week_start_date, weekEndDate: r.week_end_date, - workDescription: r.work_description, - planDescription: r.plan_description, issueDescription: r.issue_description, - remarkDescription: r.remark_description, - workHours: r.work_hours, + vacationDescription: r.vacation_description, reportStatus: r.report_status, submittedAt: r.submitted_at, createdAt: r.created_at, - updatedAt: r.updated_at + projectCount: parseInt(r.project_count) })) } }) diff --git a/backend/api/scheduler/status.get.ts b/backend/api/scheduler/status.get.ts deleted file mode 100644 index c43273e..0000000 --- a/backend/api/scheduler/status.get.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getSchedulerStatus } from '../../utils/report-scheduler' - -/** - * 스케줄러 상태 조회 - * GET /api/scheduler/status - */ -export default defineEventHandler(async () => { - return getSchedulerStatus() -}) diff --git a/backend/api/scheduler/trigger-aggregate.post.ts b/backend/api/scheduler/trigger-aggregate.post.ts deleted file mode 100644 index 1d9ba9a..0000000 --- a/backend/api/scheduler/trigger-aggregate.post.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { aggregateWeeklyReports } from '../../utils/report-scheduler' - -interface TriggerBody { - year?: number - week?: number -} - -/** - * 수동 취합 트리거 - * POST /api/scheduler/trigger-aggregate - */ -export default defineEventHandler(async (event) => { - const body = await readBody(event) - - try { - const result = await aggregateWeeklyReports(body.year, body.week) - return { - success: true, - ...result - } - } catch (error: any) { - throw createError({ - statusCode: 500, - message: `취합 실패: ${error.message}` - }) - } -}) diff --git a/backend/utils/db.ts b/backend/utils/db.ts index 8dbe7c7..1076d70 100644 --- a/backend/utils/db.ts +++ b/backend/utils/db.ts @@ -9,14 +9,12 @@ let pool: pg.Pool | null = null */ export function getPool(): pg.Pool { if (!pool) { - const config = useRuntimeConfig() - const poolConfig = { - host: config.dbHost, - port: parseInt(config.dbPort as string), - database: config.dbName, - user: config.dbUser, - password: config.dbPassword, + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME || 'weeklyreport', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || '', max: 10, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, diff --git a/backend/utils/ip.ts b/backend/utils/ip.ts new file mode 100644 index 0000000..92ef394 --- /dev/null +++ b/backend/utils/ip.ts @@ -0,0 +1,33 @@ +import type { H3Event } from 'h3' + +/** + * 클라이언트 IP 주소 가져오기 + */ +export function getClientIp(event: H3Event): string { + // 프록시/로드밸런서 뒤에 있을 경우 + const xForwardedFor = getHeader(event, 'x-forwarded-for') + if (xForwardedFor) { + return xForwardedFor.split(',')[0].trim() + } + + const xRealIp = getHeader(event, 'x-real-ip') + if (xRealIp) { + return xRealIp + } + + // 직접 연결 + const remoteAddress = event.node.req.socket?.remoteAddress + if (remoteAddress) { + // IPv6 localhost를 IPv4로 변환 + if (remoteAddress === '::1' || remoteAddress === '::ffff:127.0.0.1') { + return '127.0.0.1' + } + // IPv6 매핑된 IPv4 주소 처리 + if (remoteAddress.startsWith('::ffff:')) { + return remoteAddress.substring(7) + } + return remoteAddress + } + + return 'unknown' +} diff --git a/backend/utils/report-scheduler.ts b/backend/utils/report-scheduler.ts deleted file mode 100644 index 879ea16..0000000 --- a/backend/utils/report-scheduler.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { query, execute, insertReturning } from './db' -import { getLastWeekInfo, formatDate } from './week-calc' - -let isRunning = false - -/** - * 주간보고 취합 실행 - */ -export async function aggregateWeeklyReports(targetYear?: number, targetWeek?: number) { - const weekInfo = targetYear && targetWeek - ? { year: targetYear, week: targetWeek } - : getLastWeekInfo() - - console.log(`[Aggregator] 취합 시작: ${weekInfo.year}-W${weekInfo.week}`) - - // 해당 주차에 제출된 보고서가 있는 프로젝트 조회 - const projects = await query(` - SELECT DISTINCT project_id - FROM wr_weekly_report_detail - WHERE report_year = $1 AND report_week = $2 AND report_status = 'SUBMITTED' - `, [weekInfo.year, weekInfo.week]) - - let aggregatedCount = 0 - - for (const { project_id } of projects) { - // 해당 프로젝트의 제출된 보고서들 - const reports = await query(` - SELECT report_id, work_hours - FROM wr_weekly_report_detail - WHERE project_id = $1 AND report_year = $2 AND report_week = $3 - AND report_status = 'SUBMITTED' - `, [project_id, weekInfo.year, weekInfo.week]) - - const reportIds = reports.map((r: any) => r.report_id) - const totalHours = reports.reduce((sum: number, r: any) => sum + (parseFloat(r.work_hours) || 0), 0) - - // 주차 날짜 계산 - const jan4 = new Date(weekInfo.year, 0, 4) - const firstMonday = new Date(jan4) - firstMonday.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7)) - const targetMonday = new Date(firstMonday) - targetMonday.setDate(firstMonday.getDate() + (weekInfo.week - 1) * 7) - const targetSunday = new Date(targetMonday) - targetSunday.setDate(targetMonday.getDate() + 6) - - // UPSERT 취합 보고서 - await execute(` - INSERT INTO wr_aggregated_report_summary ( - project_id, report_year, report_week, - week_start_date, week_end_date, - report_ids, member_count, total_work_hours - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (project_id, report_year, report_week) - DO UPDATE SET - report_ids = $6, - member_count = $7, - total_work_hours = $8, - aggregated_at = NOW(), - updated_at = NOW() - `, [ - project_id, - weekInfo.year, - weekInfo.week, - formatDate(targetMonday), - formatDate(targetSunday), - reportIds, - reportIds.length, - totalHours || null - ]) - - // 개별 보고서 상태 변경 - await execute(` - UPDATE wr_weekly_report_detail SET - report_status = 'AGGREGATED', - updated_at = NOW() - WHERE report_id = ANY($1) - `, [reportIds]) - - aggregatedCount++ - console.log(`[Aggregator] 프로젝트 ${project_id}: ${reportIds.length}건 취합`) - } - - console.log(`[Aggregator] 취합 완료: ${aggregatedCount}개 프로젝트`) - - return { - year: weekInfo.year, - week: weekInfo.week, - projectCount: aggregatedCount - } -} - -/** - * 스케줄러 상태 - */ -export function getSchedulerStatus() { - return { - isRunning - } -} diff --git a/backend/utils/user.ts b/backend/utils/user.ts new file mode 100644 index 0000000..9a09c36 --- /dev/null +++ b/backend/utils/user.ts @@ -0,0 +1,16 @@ +import type { H3Event } from 'h3' +import { queryOne } from './db' + +/** + * 현재 로그인한 사용자의 이메일 조회 + */ +export async function getCurrentUserEmail(event: H3Event): Promise { + const userId = getCookie(event, 'user_id') + if (!userId) return null + + const user = await queryOne<{ employee_email: string }>(` + SELECT employee_email FROM wr_employee_info WHERE employee_id = $1 + `, [parseInt(userId)]) + + return user?.employee_email || null +} diff --git a/frontend/components/layout/AppHeader.vue b/frontend/components/layout/AppHeader.vue index 32bd1a9..3a8f379 100644 --- a/frontend/components/layout/AppHeader.vue +++ b/frontend/components/layout/AppHeader.vue @@ -34,17 +34,17 @@
- + {{ currentUser.employeeName }} - + @@ -74,4 +74,13 @@ async function handleLogout() { .nav-link.active { font-weight: 500; } +.user-link { + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + transition: background-color 0.2s; +} +.user-link:hover { + background-color: rgba(255, 255, 255, 0.1); +} diff --git a/frontend/employee/[id].vue b/frontend/employee/[id].vue index 0803b57..bd6698d 100644 --- a/frontend/employee/[id].vue +++ b/frontend/employee/[id].vue @@ -14,7 +14,7 @@
- 사원 정보 + 직원 정보
{{ employee.isActive ? '재직' : '퇴직' }} @@ -31,19 +31,33 @@
- - + +
@@ -114,7 +128,7 @@ const isSubmitting = ref(false) const form = ref({ employeeName: '', employeeEmail: '', - employeeNumber: '', + company: '(주)터보소프트', employeePosition: '', employeePhone: '', joinDate: '', @@ -141,14 +155,14 @@ async function loadEmployee() { form.value = { employeeName: e.employeeName || '', employeeEmail: e.employeeEmail || '', - employeeNumber: e.employeeNumber || '', + company: e.company || '(주)터보소프트', employeePosition: e.employeePosition || '', employeePhone: e.employeePhone || '', joinDate: e.joinDate ? e.joinDate.split('T')[0] : '', isActive: e.isActive } } catch (e: any) { - alert('사원 정보를 불러오는데 실패했습니다.') + alert('직원 정보를 불러오는데 실패했습니다.') router.push('/employee') } finally { isLoading.value = false diff --git a/frontend/employee/index.vue b/frontend/employee/index.vue index aedf954..06c8506 100644 --- a/frontend/employee/index.vue +++ b/frontend/employee/index.vue @@ -5,11 +5,11 @@
-

사원 관리

-

직원 정보 관리

+

직원 관리

+

총 {{ employees.length }}명

@@ -23,13 +23,14 @@ class="form-control" v-model="searchKeyword" placeholder="이름 또는 이메일 검색" - @keyup.enter="loadEmployees" />
-
- +
+
@@ -41,23 +42,29 @@ - - + + - - + + + + + + - + @@ -86,7 +93,7 @@
사번 이름 이메일직급소속사직급 상태 상세
{{ emp.employeeNumber || '-' }}
+ 로딩 중... +
{{ emp.employeeName }} {{ emp.employeeEmail }}{{ emp.company || '-' }}{{ emp.employeeEmail }} {{ emp.employeePosition || '-' }} - - {{ emp.isActive ? '재직' : '퇴직' }} + + {{ emp.isActive !== false ? '재직' : '퇴직' }} @@ -69,10 +76,10 @@
-

사원 정보가 없습니다.

+

직원 정보가 없습니다.

- + + @@ -62,6 +70,11 @@ + - @@ -106,14 +119,17 @@ @@ -155,14 +180,16 @@ const router = useRouter() const projects = ref([]) const searchKeyword = ref('') +const filterType = ref('') const filterStatus = ref('') const showCreateModal = ref(false) +const isCreating = ref(false) const newProject = ref({ - projectCode: '', projectName: '', + projectType: 'SI', clientName: '', - contractAmount: null, + contractAmount: null as number | null, startDate: '', endDate: '', projectDescription: '' @@ -179,6 +206,10 @@ const filteredProjects = computed(() => { ) } + if (filterType.value) { + list = list.filter(p => p.projectType === filterType.value) + } + if (filterStatus.value) { list = list.filter(p => p.projectStatus === filterStatus.value) } @@ -211,27 +242,38 @@ async function createProject() { return } + isCreating.value = true try { await $fetch('/api/project/create', { method: 'POST', body: newProject.value }) showCreateModal.value = false - newProject.value = { - projectCode: '', - projectName: '', - clientName: '', - contractAmount: null, - startDate: '', - endDate: '', - projectDescription: '' - } + resetNewProject() await loadProjects() } catch (e: any) { alert(e.data?.message || e.message || '등록에 실패했습니다.') + } finally { + isCreating.value = false } } +function resetNewProject() { + newProject.value = { + projectName: '', + projectType: 'SI', + clientName: '', + contractAmount: null, + startDate: '', + endDate: '', + projectDescription: '' + } +} + +function getTypeBadgeClass(type: string) { + return type === 'SM' ? 'badge bg-info' : 'badge bg-primary' +} + function getStatusBadgeClass(status: string) { const classes: Record = { 'ACTIVE': 'badge bg-success', diff --git a/frontend/report/summary/index.vue b/frontend/report/summary/index.vue index 0f158b7..876d454 100644 --- a/frontend/report/summary/index.vue +++ b/frontend/report/summary/index.vue @@ -3,9 +3,14 @@
-
-

취합 보고서

-

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

+
+
+

취합 보고서

+

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

+
+
@@ -95,14 +100,75 @@
+ + + + + + diff --git a/frontend/report/weekly/[id].vue b/frontend/report/weekly/[id].vue index e3de123..aea1dfa 100644 --- a/frontend/report/weekly/[id].vue +++ b/frontend/report/weekly/[id].vue @@ -2,123 +2,230 @@
-
-
- - 목록으로 - +
+
+ +

로딩 중...

- -
-
-
- - {{ report.projectName }} - {{ report.reportYear }}-W{{ String(report.reportWeek).padStart(2, '0') }} -
- - {{ getStatusText(report.reportStatus) }} - + +
+
+
+

+ 주간보고 + + {{ getStatusText(report.reportStatus) }} + +

+

+ {{ report.reportYear }}년 {{ report.reportWeek }}주차 + ({{ formatDate(report.weekStartDate) }} ~ {{ formatDate(report.weekEndDate) }}) +

+
+
+ 목록 + + +
-
- -
-
- -

{{ report.authorName }}

+ +
+ +
+
+ 프로젝트별 실적 + {{ projects.length }}개
-
- -

{{ formatDate(report.weekStartDate) }} ~ {{ formatDate(report.weekEndDate) }}

-
-
- -

{{ report.workHours ? report.workHours + '시간' : '-' }}

+
+
+
+ + {{ proj.projectName }} + ({{ proj.projectCode }}) +
+
+
+ +
{{ proj.workDescription || '-' }}
+
+
+ +
{{ proj.planDescription || '-' }}
+
+
+
- - -
- -
-
{{ report.workDescription || '-' }}
+ + +
+
+ 공통 사항 +
+
+
+
+ +
{{ report.issueDescription || '-' }}
+
+
+ +
{{ report.vacationDescription || '-' }}
+
+
+ +
{{ report.remarkDescription || '-' }}
+
+
- - -
- -
-
{{ report.planDescription || '-' }}
+
+ + +
+ +
+
+ 프로젝트별 실적 + +
+
+
+
+
+ {{ proj.projectName }} + ({{ proj.projectCode }}) +
+ +
+
+ + +
+
+ + +
+
- - -
- -
-
{{ report.issueDescription }}
+ + +
+
+ 공통 사항 +
+
+
+ + +
+
+ + +
+
+ + +
- - -
- -
-
{{ report.remarkDescription }}
-
-
- - -
- - 수정 - - - +
+ +
+
+ + + +
+ + diff --git a/frontend/report/weekly/index.vue b/frontend/report/weekly/index.vue index 24fd1a9..3fec8bc 100644 --- a/frontend/report/weekly/index.vue +++ b/frontend/report/weekly/index.vue @@ -2,109 +2,61 @@
-
+
-
-

주간보고

-

내가 작성한 주간보고 목록

-
+

+ 내 주간보고 +

- 새 보고서 작성 + 작성하기
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
- - +
-
-
프로젝트 코드코드 프로젝트명유형 발주처 기간 상태 {{ project.projectName }} + + {{ project.projectType || 'SI' }} + + {{ project.clientName || '-' }} @@ -85,7 +98,7 @@
+

프로젝트가 없습니다.

- - - - - - - - - - - - - - - - - - - - - - - -
주차프로젝트기간상태작성일작업
- W{{ String(report.reportWeek).padStart(2, '0') }} - {{ report.projectName }} - {{ formatDateRange(report.weekStartDate, report.weekEndDate) }} - - - {{ getStatusText(report.reportStatus) }} - - - {{ formatDateTime(report.createdAt) }} - - - - - -
- -

보고서가 없습니다.

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

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

+
+ {{ r.reportYear }}년 {{ r.reportWeek }}주차 + {{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }} + {{ r.projectCount }}개 프로젝트 + + + {{ getStatusText(r.reportStatus) }} + + {{ formatDateTime(r.createdAt) }}
+
@@ -115,17 +67,8 @@ const { fetchCurrentUser } = useAuth() const router = useRouter() -const currentYear = new Date().getFullYear() -const years = [currentYear, currentYear - 1, currentYear - 2] - -const filter = ref({ - projectId: '', - year: currentYear, - status: '' -}) - const reports = ref([]) -const projects = ref([]) +const isLoading = ref(true) onMounted(async () => { const user = await fetchCurrentUser() @@ -133,42 +76,30 @@ onMounted(async () => { router.push('/login') return } - - await loadProjects() - await loadReports() + loadReports() }) -async function loadProjects() { - try { - const res = await $fetch<{ projects: any[] }>('/api/project/my-projects') - projects.value = res.projects || [] - } catch (e) { - console.error('Load projects error:', e) - } -} - async function loadReports() { + isLoading.value = true try { - const query: Record = { year: filter.value.year } - if (filter.value.projectId) query.projectId = filter.value.projectId - if (filter.value.status) query.status = filter.value.status - - const res = await $fetch<{ reports: any[] }>('/api/report/weekly/list', { query }) + const res = await $fetch('/api/report/weekly/list') reports.value = res.reports || [] } catch (e) { - console.error('Load reports error:', e) + console.error(e) + } finally { + isLoading.value = false } } -async function submitReport(reportId: number) { - if (!confirm('보고서를 제출하시겠습니까?\n제출 후에는 수정이 제한됩니다.')) return - - try { - await $fetch(`/api/report/weekly/${reportId}/submit`, { method: 'POST' }) - await loadReports() - } catch (e: any) { - alert(e.message || '제출에 실패했습니다.') - } +function formatDate(dateStr: string) { + if (!dateStr) return '' + return dateStr.split('T')[0] +} + +function formatDateTime(dateStr: string) { + if (!dateStr) return '' + const d = new Date(dateStr) + return d.toLocaleDateString('ko-KR') } function getStatusBadgeClass(status: string) { @@ -188,18 +119,4 @@ function getStatusText(status: string) { } return texts[status] || status } - -function formatDateRange(start: string, end: string) { - const s = new Date(start) - const e = new Date(end) - return `${s.getMonth()+1}/${s.getDate()} ~ ${e.getMonth()+1}/${e.getDate()}` -} - -function formatDateTime(dateStr: string) { - const d = new Date(dateStr) - return d.toLocaleString('ko-KR', { - month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit' - }) -} diff --git a/frontend/report/weekly/write.vue b/frontend/report/weekly/write.vue index 68ddd6f..7a84157 100644 --- a/frontend/report/weekly/write.vue +++ b/frontend/report/weekly/write.vue @@ -2,189 +2,150 @@
-
-
- - 목록으로 - +
+
+

+ 주간보고 작성 +

+ {{ weekInfo.weekString }} ({{ weekInfo.startDateStr }} ~ {{ weekInfo.endDateStr }})
- -
-
-
-
-
- - {{ isEdit ? '주간보고 수정' : '주간보고 작성' }} -
+ +
+ +
+
+ 프로젝트별 실적 + +
+
+
+ +

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

-
- - -
- - + +
+
+
+ {{ proj.projectName }} + ({{ proj.projectCode }})
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- -
- - 시간 -
-
- - -
- - - - 취소 - -
- + +
+
+ + +
+
+ + +
- - -
-
-
-
작성 안내
-
    -
  • 금주 실적은 필수 항목입니다.
  • -
  • 같은 프로젝트, 같은 주차에 하나의 보고서만 작성 가능합니다.
  • -
  • 제출 후에는 수정이 제한됩니다.
  • -
  • 취합은 매주 자동으로 진행됩니다.
  • -
+ + +
+
+ 공통 사항 +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ 취소 + +
+ +
+ + + +
+ + diff --git a/nuxt.config.ts b/nuxt.config.ts index ea18205..87f6ac4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -3,6 +3,11 @@ export default defineNuxtConfig({ compatibilityDate: '2024-11-01', devtools: { enabled: true }, + // 개발 서버 포트 + devServer: { + port: 2026 + }, + // 서버 설정 nitro: { experimental: {