From d4620dc1fab779203e20d00c25be477dca8bcec9 Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Sun, 11 Jan 2026 10:50:51 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=91=EC=97=85=EA=B3=84=ED=9A=8D=EC=84=9C?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 7 + backend/api/auth/change-password.post.ts | 66 ++++ backend/api/auth/google/callback.get.ts | 127 +++++++ backend/api/auth/google/index.get.ts | 35 ++ backend/api/auth/login-password.post.ts | 82 ++++ backend/api/auth/reset-password.post.ts | 78 ++++ backend/api/auth/set-password.post.ts | 79 ++++ backend/api/dashboard/stats.get.ts | 42 +++ backend/api/maintenance/stats.get.ts | 145 ++++++++ backend/api/repository/list.get.ts | 67 ++++ backend/api/todo/[id]/delete.delete.ts | 18 + backend/api/todo/[id]/detail.get.ts | 53 +++ backend/api/todo/[id]/update.put.ts | 84 +++++ backend/api/todo/create.post.ts | 55 +++ backend/api/todo/list.get.ts | 97 +++++ backend/api/todo/report/link.post.ts | 43 +++ backend/api/todo/report/similar.post.ts | 108 ++++++ backend/api/vcs-account/[id]/delete.delete.ts | 23 ++ backend/api/vcs-account/[id]/update.put.ts | 80 ++++ backend/api/vcs-account/create.post.ts | 82 ++++ backend/api/vcs-account/my.get.ts | 37 ++ backend/api/vcs-server/[id]/delete.delete.ts | 24 ++ backend/api/vcs-server/[id]/detail.get.ts | 34 ++ backend/api/vcs-server/[id]/update.put.ts | 67 ++++ backend/api/vcs-server/create.post.ts | 52 +++ backend/api/vcs-server/list.get.ts | 37 ++ backend/utils/email.ts | 92 +++++ backend/utils/password.ts | 34 ++ claude_temp/00_마스터_작업계획서.md | 141 +++---- frontend/admin/vcs-server/index.vue | 222 +++++++++++ frontend/forgot-password.vue | 102 +++++ frontend/index.vue | 82 ++++ frontend/login.vue | 135 ++++--- frontend/maintenance/index.vue | 3 + frontend/maintenance/stats.vue | 278 ++++++++++++++ frontend/mypage/index.vue | 219 ++++++++++- frontend/report/weekly/write.vue | 179 +++++++++ frontend/todo/index.vue | 351 ++++++++++++++++++ nuxt.config.ts | 4 + 39 files changed, 3344 insertions(+), 120 deletions(-) create mode 100644 backend/api/auth/change-password.post.ts create mode 100644 backend/api/auth/google/callback.get.ts create mode 100644 backend/api/auth/google/index.get.ts create mode 100644 backend/api/auth/login-password.post.ts create mode 100644 backend/api/auth/reset-password.post.ts create mode 100644 backend/api/auth/set-password.post.ts create mode 100644 backend/api/maintenance/stats.get.ts create mode 100644 backend/api/repository/list.get.ts create mode 100644 backend/api/todo/[id]/delete.delete.ts create mode 100644 backend/api/todo/[id]/detail.get.ts create mode 100644 backend/api/todo/[id]/update.put.ts create mode 100644 backend/api/todo/create.post.ts create mode 100644 backend/api/todo/list.get.ts create mode 100644 backend/api/todo/report/link.post.ts create mode 100644 backend/api/todo/report/similar.post.ts create mode 100644 backend/api/vcs-account/[id]/delete.delete.ts create mode 100644 backend/api/vcs-account/[id]/update.put.ts create mode 100644 backend/api/vcs-account/create.post.ts create mode 100644 backend/api/vcs-account/my.get.ts create mode 100644 backend/api/vcs-server/[id]/delete.delete.ts create mode 100644 backend/api/vcs-server/[id]/detail.get.ts create mode 100644 backend/api/vcs-server/[id]/update.put.ts create mode 100644 backend/api/vcs-server/create.post.ts create mode 100644 backend/api/vcs-server/list.get.ts create mode 100644 backend/utils/email.ts create mode 100644 backend/utils/password.ts create mode 100644 frontend/admin/vcs-server/index.vue create mode 100644 frontend/forgot-password.vue create mode 100644 frontend/maintenance/stats.vue create mode 100644 frontend/todo/index.vue diff --git a/.env b/.env index eab2123..28ca883 100644 --- a/.env +++ b/.env @@ -16,3 +16,10 @@ SESSION_SECRET=dev-secret-key-change-in-production # OpenAI OPENAI_API_KEY=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 +# SMTP (이메일 발송) +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USER=your-email@gmail.com +# SMTP_PASS=your-app-password +# SMTP_FROM=주간업무보고 + diff --git a/backend/api/auth/change-password.post.ts b/backend/api/auth/change-password.post.ts new file mode 100644 index 0000000..30c7e4f --- /dev/null +++ b/backend/api/auth/change-password.post.ts @@ -0,0 +1,66 @@ +import { query, execute } from '../../utils/db' +import { getClientIp } from '../../utils/ip' +import { getCurrentUser } from '../../utils/session' +import { hashPassword, verifyPassword } from '../../utils/password' + +interface ChangePasswordBody { + currentPassword?: string + newPassword: string + confirmPassword: string +} + +/** + * 비밀번호 변경 + * POST /api/auth/change-password + */ +export default defineEventHandler(async (event) => { + const user = await getCurrentUser(event) + if (!user) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + const body = await readBody(event) + const clientIp = getClientIp(event) + + if (!body.newPassword || !body.confirmPassword) { + throw createError({ statusCode: 400, message: '새 비밀번호를 입력해주세요.' }) + } + + if (body.newPassword !== body.confirmPassword) { + throw createError({ statusCode: 400, message: '비밀번호가 일치하지 않습니다.' }) + } + + if (body.newPassword.length < 8) { + throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' }) + } + + // 현재 직원 정보 조회 + const employees = await query(` + SELECT password_hash FROM wr_employee_info WHERE employee_id = $1 + `, [user.employeeId]) + + const employee = employees[0] + + // 기존 비밀번호가 있으면 현재 비밀번호 검증 + if (employee?.password_hash) { + if (!body.currentPassword) { + throw createError({ statusCode: 400, message: '현재 비밀번호를 입력해주세요.' }) + } + const isValid = await verifyPassword(body.currentPassword, employee.password_hash) + if (!isValid) { + throw createError({ statusCode: 401, message: '현재 비밀번호가 올바르지 않습니다.' }) + } + } + + // 새 비밀번호 해시 + const newHash = await hashPassword(body.newPassword) + + // 비밀번호 업데이트 + await execute(` + UPDATE wr_employee_info + SET password_hash = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3 + WHERE employee_id = $4 + `, [newHash, clientIp, user.employeeEmail, user.employeeId]) + + return { success: true, message: '비밀번호가 변경되었습니다.' } +}) diff --git a/backend/api/auth/google/callback.get.ts b/backend/api/auth/google/callback.get.ts new file mode 100644 index 0000000..db70965 --- /dev/null +++ b/backend/api/auth/google/callback.get.ts @@ -0,0 +1,127 @@ +import { query, execute, insertReturning } from '../../../utils/db' +import { getClientIp } from '../../../utils/ip' +import { createSession, setSessionCookie } from '../../../utils/session' + +/** + * Google OAuth 콜백 + * GET /api/auth/google/callback + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const params = getQuery(event) + const clientIp = getClientIp(event) + const userAgent = getHeader(event, 'user-agent') || null + + const code = params.code as string + const state = params.state as string + const error = params.error as string + + if (error) { + return sendRedirect(event, '/login?error=oauth_denied') + } + + // State 검증 + const savedState = getCookie(event, 'oauth_state') + if (!savedState || savedState !== state) { + return sendRedirect(event, '/login?error=invalid_state') + } + deleteCookie(event, 'oauth_state') + + const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID + const clientSecret = config.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET + const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback' + + if (!clientId || !clientSecret) { + return sendRedirect(event, '/login?error=oauth_not_configured') + } + + try { + // 토큰 교환 + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code' + }) + }) + + const tokenData = await tokenRes.json() + if (!tokenData.access_token) { + console.error('Token error:', tokenData) + return sendRedirect(event, '/login?error=token_failed') + } + + // 사용자 정보 조회 + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokenData.access_token}` } + }) + const googleUser = await userRes.json() + + if (!googleUser.email) { + return sendRedirect(event, '/login?error=no_email') + } + + const googleId = googleUser.id + const googleEmail = googleUser.email.toLowerCase() + const googleName = googleUser.name || googleEmail.split('@')[0] + + // 기존 사용자 조회 (이메일 기준) + let employees = await query(` + SELECT * FROM wr_employee_info WHERE employee_email = $1 + `, [googleEmail]) + + let employee = employees[0] + + if (employee) { + // 기존 사용자 - Google 정보 연결 + await execute(` + UPDATE wr_employee_info SET + google_id = $1, + google_email = $2, + google_linked_at = NOW(), + google_access_token = $3, + google_refresh_token = $4, + google_token_expires_at = NOW() + INTERVAL '${tokenData.expires_in} seconds', + last_login_at = NOW(), + last_login_ip = $5, + updated_at = NOW() + WHERE employee_id = $6 + `, [googleId, googleEmail, tokenData.access_token, tokenData.refresh_token || null, clientIp, employee.employee_id]) + } else { + // 신규 사용자 자동 등록 + employee = await insertReturning(` + INSERT INTO wr_employee_info ( + employee_name, employee_email, google_id, google_email, google_linked_at, + google_access_token, google_refresh_token, google_token_expires_at, + last_login_at, last_login_ip, created_ip, created_email + ) VALUES ($1, $2, $3, $2, NOW(), $4, $5, NOW() + INTERVAL '${tokenData.expires_in} seconds', NOW(), $6, $6, $2) + RETURNING * + `, [googleName, googleEmail, googleId, tokenData.access_token, tokenData.refresh_token || null, clientIp]) + } + + // 로그인 이력 추가 + const loginHistory = await insertReturning(` + INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type) + VALUES ($1, $2, $3, 'GOOGLE') + RETURNING history_id + `, [employee.employee_id, clientIp, googleEmail]) + + // 세션 생성 + const sessionId = await createSession( + employee.employee_id, + loginHistory.history_id, + clientIp, + userAgent + ) + setSessionCookie(event, sessionId) + + return sendRedirect(event, '/') + } catch (e) { + console.error('Google OAuth error:', e) + return sendRedirect(event, '/login?error=oauth_failed') + } +}) diff --git a/backend/api/auth/google/index.get.ts b/backend/api/auth/google/index.get.ts new file mode 100644 index 0000000..fc063b4 --- /dev/null +++ b/backend/api/auth/google/index.get.ts @@ -0,0 +1,35 @@ +/** + * Google OAuth 시작 + * GET /api/auth/google + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + + const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID + const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback' + + if (!clientId) { + throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' }) + } + + const scope = encodeURIComponent('openid email profile') + const state = Math.random().toString(36).substring(7) // CSRF 방지 + + // state를 쿠키에 저장 + setCookie(event, 'oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 300 // 5분 + }) + + const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${clientId}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&response_type=code` + + `&scope=${scope}` + + `&state=${state}` + + `&access_type=offline` + + `&prompt=consent` + + return sendRedirect(event, authUrl) +}) diff --git a/backend/api/auth/login-password.post.ts b/backend/api/auth/login-password.post.ts new file mode 100644 index 0000000..6bdefb1 --- /dev/null +++ b/backend/api/auth/login-password.post.ts @@ -0,0 +1,82 @@ +import { query, execute, insertReturning } from '../../utils/db' +import { getClientIp } from '../../utils/ip' +import { createSession, setSessionCookie } from '../../utils/session' +import { verifyPassword } from '../../utils/password' + +interface LoginBody { + email: string + password: string +} + +/** + * 비밀번호 로그인 + * POST /api/auth/login-password + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const clientIp = getClientIp(event) + const userAgent = getHeader(event, 'user-agent') || null + + if (!body.email || !body.password) { + throw createError({ statusCode: 400, message: '이메일과 비밀번호를 입력해주세요.' }) + } + + const emailLower = body.email.toLowerCase() + + // 직원 조회 + const employees = await query(` + SELECT * FROM wr_employee_info WHERE employee_email = $1 AND is_active = true + `, [emailLower]) + + if (employees.length === 0) { + throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' }) + } + + const employee = employees[0] + + // 비밀번호 미설정 + if (!employee.password_hash) { + throw createError({ statusCode: 401, message: '비밀번호가 설정되지 않았습니다. 관리자에게 문의하세요.' }) + } + + // 비밀번호 검증 + const isValid = await verifyPassword(body.password, employee.password_hash) + if (!isValid) { + throw createError({ statusCode: 401, message: '이메일 또는 비밀번호가 올바르지 않습니다.' }) + } + + // 마지막 로그인 시간 업데이트 + await execute(` + UPDATE wr_employee_info + SET last_login_at = NOW(), last_login_ip = $1, updated_at = NOW() + WHERE employee_id = $2 + `, [clientIp, employee.employee_id]) + + // 로그인 이력 추가 + const loginHistory = await insertReturning(` + INSERT INTO wr_login_history (employee_id, login_ip, login_email, login_type) + VALUES ($1, $2, $3, 'PASSWORD') + RETURNING history_id + `, [employee.employee_id, clientIp, emailLower]) + + // 세션 생성 + const sessionId = await createSession( + employee.employee_id, + loginHistory.history_id, + clientIp, + userAgent + ) + + setSessionCookie(event, sessionId) + + return { + success: true, + user: { + employeeId: employee.employee_id, + employeeName: employee.employee_name, + employeeEmail: employee.employee_email, + employeePosition: employee.employee_position, + company: employee.company + } + } +}) diff --git a/backend/api/auth/reset-password.post.ts b/backend/api/auth/reset-password.post.ts new file mode 100644 index 0000000..9a2ae90 --- /dev/null +++ b/backend/api/auth/reset-password.post.ts @@ -0,0 +1,78 @@ +import { query, execute } from '../../utils/db' +import { hashPassword, generateTempPassword } from '../../utils/password' +import { sendTempPasswordEmail } from '../../utils/email' +import { getClientIp } from '../../utils/ip' + +interface ResetPasswordBody { + email: string + name: string + phone?: string +} + +/** + * 비밀번호 찾기 (임시 비밀번호 발급) + * POST /api/auth/reset-password + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const clientIp = getClientIp(event) + + if (!body.email || !body.name) { + throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' }) + } + + const emailLower = body.email.toLowerCase() + const nameTrimmed = body.name.trim() + + // 사용자 조회 (이메일 + 이름) + let sql = `SELECT * FROM wr_employee_info WHERE employee_email = $1 AND employee_name = $2` + const params: any[] = [emailLower, nameTrimmed] + + // 핸드폰 번호도 제공된 경우 추가 검증 + if (body.phone) { + const phoneClean = body.phone.replace(/[^0-9]/g, '') + sql += ` AND REPLACE(employee_phone, '-', '') = $3` + params.push(phoneClean) + } + + const employees = await query(sql, params) + + if (employees.length === 0) { + // 보안상 동일한 메시지 반환 + throw createError({ statusCode: 400, message: '입력하신 정보와 일치하는 계정을 찾을 수 없습니다.' }) + } + + const employee = employees[0] + + // 임시 비밀번호 생성 + const tempPassword = generateTempPassword() + const hash = await hashPassword(tempPassword) + + // 비밀번호 업데이트 + await execute(` + UPDATE wr_employee_info + SET password_hash = $1, updated_at = NOW(), updated_ip = $2 + WHERE employee_id = $3 + `, [hash, clientIp, employee.employee_id]) + + // 이메일 발송 + const emailSent = await sendTempPasswordEmail( + employee.employee_email, + employee.employee_name, + tempPassword + ) + + if (!emailSent) { + // 이메일 발송 실패해도 비밀번호는 이미 변경됨 + console.warn('임시 비밀번호 이메일 발송 실패:', employee.employee_email) + } + + // 보안상 이메일 일부만 표시 + const maskedEmail = employee.employee_email.replace(/(.{2})(.*)(@.*)/, '$1***$3') + + return { + success: true, + message: `임시 비밀번호가 ${maskedEmail}로 발송되었습니다.`, + emailSent + } +}) diff --git a/backend/api/auth/set-password.post.ts b/backend/api/auth/set-password.post.ts new file mode 100644 index 0000000..e6a1454 --- /dev/null +++ b/backend/api/auth/set-password.post.ts @@ -0,0 +1,79 @@ +import { query, execute } from '../../utils/db' +import { getClientIp } from '../../utils/ip' +import { getCurrentUser } from '../../utils/session' +import { hashPassword, generateTempPassword } from '../../utils/password' + +interface SetPasswordBody { + employeeId: number + password?: string + generateTemp?: boolean +} + +/** + * 직원 비밀번호 설정 (관리자용) + * POST /api/auth/set-password + */ +export default defineEventHandler(async (event) => { + const user = await getCurrentUser(event) + if (!user) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + // 권한 확인 (ROLE_ADMIN만) + const roles = await query(` + SELECT role_code FROM wr_employee_role WHERE employee_id = $1 + `, [user.employeeId]) + + const isAdmin = roles.some((r: any) => r.role_code === 'ROLE_ADMIN') + if (!isAdmin) { + throw createError({ statusCode: 403, message: '관리자 권한이 필요합니다.' }) + } + + const body = await readBody(event) + const clientIp = getClientIp(event) + + if (!body.employeeId) { + throw createError({ statusCode: 400, message: '직원 ID가 필요합니다.' }) + } + + let password = body.password + + // 임시 비밀번호 생성 + if (body.generateTemp || !password) { + password = generateTempPassword() + } + + if (password.length < 8) { + throw createError({ statusCode: 400, message: '비밀번호는 8자 이상이어야 합니다.' }) + } + + // 대상 직원 존재 확인 + const employees = await query(` + SELECT employee_id, employee_name, employee_email FROM wr_employee_info WHERE employee_id = $1 + `, [body.employeeId]) + + if (employees.length === 0) { + throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' }) + } + + const targetEmployee = employees[0] + + // 비밀번호 해시 + const hash = await hashPassword(password) + + // 업데이트 + await execute(` + UPDATE wr_employee_info + SET password_hash = $1, updated_at = NOW(), updated_ip = $2, updated_email = $3 + WHERE employee_id = $4 + `, [hash, clientIp, user.employeeEmail, body.employeeId]) + + return { + success: true, + employeeId: targetEmployee.employee_id, + employeeName: targetEmployee.employee_name, + employeeEmail: targetEmployee.employee_email, + tempPassword: body.generateTemp ? password : undefined, + message: body.generateTemp ? '임시 비밀번호가 생성되었습니다.' : '비밀번호가 설정되었습니다.' + } +}) diff --git a/backend/api/dashboard/stats.get.ts b/backend/api/dashboard/stats.get.ts index f385ff7..f02696b 100644 --- a/backend/api/dashboard/stats.get.ts +++ b/backend/api/dashboard/stats.get.ts @@ -64,6 +64,33 @@ export default defineEventHandler(async (event) => { const totalWorkHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.work_hours || 0), 0) const totalPlanHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.plan_hours || 0), 0) + // 4. TODO 현황 + const todoStats = await query(` + SELECT + COUNT(*) FILTER (WHERE status = 'PENDING') as pending, + COUNT(*) FILTER (WHERE status = 'IN_PROGRESS') as in_progress, + COUNT(*) FILTER (WHERE status = 'COMPLETED' AND completed_at >= NOW() - INTERVAL '7 days') as completed_this_week, + COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('COMPLETED', 'CANCELLED')) as overdue + FROM wr_todo + `, []) + + // 5. 유지보수 현황 + const maintenanceStats = await query(` + SELECT + COUNT(*) FILTER (WHERE status = 'PENDING') as pending, + COUNT(*) FILTER (WHERE status = 'IN_PROGRESS') as in_progress, + COUNT(*) FILTER (WHERE status = 'COMPLETED' AND updated_at >= NOW() - INTERVAL '7 days') as completed_this_week + FROM wr_maintenance_task + `, []) + + // 6. 최근 회의 현황 + const meetingStats = await query(` + SELECT + COUNT(*) FILTER (WHERE meeting_date >= CURRENT_DATE - INTERVAL '7 days') as this_week, + COUNT(*) FILTER (WHERE meeting_date >= CURRENT_DATE - INTERVAL '30 days') as this_month + FROM wr_meeting + `, []) + return { year, week, @@ -75,6 +102,21 @@ export default defineEventHandler(async (event) => { totalPlanHours, projectCount: projectStats.length }, + todo: { + pending: parseInt(todoStats[0]?.pending) || 0, + inProgress: parseInt(todoStats[0]?.in_progress) || 0, + completedThisWeek: parseInt(todoStats[0]?.completed_this_week) || 0, + overdue: parseInt(todoStats[0]?.overdue) || 0 + }, + maintenance: { + pending: parseInt(maintenanceStats[0]?.pending) || 0, + inProgress: parseInt(maintenanceStats[0]?.in_progress) || 0, + completedThisWeek: parseInt(maintenanceStats[0]?.completed_this_week) || 0 + }, + meeting: { + thisWeek: parseInt(meetingStats[0]?.this_week) || 0, + thisMonth: parseInt(meetingStats[0]?.this_month) || 0 + }, employees: employeeStats.map((e: any) => ({ employeeId: e.employee_id, employeeName: e.employee_name, diff --git a/backend/api/maintenance/stats.get.ts b/backend/api/maintenance/stats.get.ts new file mode 100644 index 0000000..f058449 --- /dev/null +++ b/backend/api/maintenance/stats.get.ts @@ -0,0 +1,145 @@ +import { query } from '../../utils/db' + +/** + * 유지보수 업무 통계 + * GET /api/maintenance/stats + */ +export default defineEventHandler(async (event) => { + const params = getQuery(event) + const projectId = params.projectId ? Number(params.projectId) : null + const year = params.year ? Number(params.year) : new Date().getFullYear() + const month = params.month ? Number(params.month) : null + + const conditions: string[] = ['EXTRACT(YEAR FROM m.request_date) = $1'] + const values: any[] = [year] + let paramIndex = 2 + + if (projectId) { + conditions.push(`m.project_id = $${paramIndex++}`) + values.push(projectId) + } + + if (month) { + conditions.push(`EXTRACT(MONTH FROM m.request_date) = $${paramIndex++}`) + values.push(month) + } + + const whereClause = conditions.join(' AND ') + + // 상태별 통계 + const statusStats = await query(` + SELECT + m.status, + COUNT(*) as count + FROM wr_maintenance_task m + WHERE ${whereClause} + GROUP BY m.status + `, values) + + // 유형별 통계 + const typeStats = await query(` + SELECT + m.task_type, + COUNT(*) as count + FROM wr_maintenance_task m + WHERE ${whereClause} + GROUP BY m.task_type + `, values) + + // 우선순위별 통계 + const priorityStats = await query(` + SELECT + m.priority, + COUNT(*) as count + FROM wr_maintenance_task m + WHERE ${whereClause} + GROUP BY m.priority + `, values) + + // 월별 추이 (연간) + const monthlyTrend = await query(` + SELECT + EXTRACT(MONTH FROM m.request_date) as month, + COUNT(*) as total, + COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed + FROM wr_maintenance_task m + WHERE EXTRACT(YEAR FROM m.request_date) = $1 + ${projectId ? 'AND m.project_id = $2' : ''} + GROUP BY EXTRACT(MONTH FROM m.request_date) + ORDER BY month + `, projectId ? [year, projectId] : [year]) + + // 담당자별 통계 + const assigneeStats = await query(` + SELECT + e.employee_id, + e.employee_name, + COUNT(*) as total, + COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed + FROM wr_maintenance_task m + JOIN wr_employee_info e ON m.assignee_id = e.employee_id + WHERE ${whereClause} + GROUP BY e.employee_id, e.employee_name + ORDER BY total DESC + LIMIT 10 + `, values) + + // 프로젝트별 통계 + const projectStats = await query(` + SELECT + p.project_id, + p.project_name, + COUNT(*) as total, + COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed + FROM wr_maintenance_task m + JOIN wr_project_info p ON m.project_id = p.project_id + WHERE EXTRACT(YEAR FROM m.request_date) = $1 + ${month ? 'AND EXTRACT(MONTH FROM m.request_date) = $2' : ''} + GROUP BY p.project_id, p.project_name + ORDER BY total DESC + LIMIT 10 + `, month ? [year, month] : [year]) + + // 전체 합계 + const totalResult = await query(` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN m.status = 'COMPLETED' THEN 1 END) as completed, + COUNT(CASE WHEN m.status = 'PENDING' THEN 1 END) as pending, + COUNT(CASE WHEN m.status = 'IN_PROGRESS' THEN 1 END) as in_progress + FROM wr_maintenance_task m + WHERE ${whereClause} + `, values) + + return { + year, + month, + projectId, + summary: { + total: Number(totalResult[0]?.total || 0), + completed: Number(totalResult[0]?.completed || 0), + pending: Number(totalResult[0]?.pending || 0), + inProgress: Number(totalResult[0]?.in_progress || 0) + }, + byStatus: statusStats.map((s: any) => ({ status: s.status, count: Number(s.count) })), + byType: typeStats.map((t: any) => ({ taskType: t.task_type, count: Number(t.count) })), + byPriority: priorityStats.map((p: any) => ({ priority: p.priority, count: Number(p.count) })), + monthlyTrend: monthlyTrend.map((m: any) => ({ + month: Number(m.month), + total: Number(m.total), + completed: Number(m.completed) + })), + byAssignee: assigneeStats.map((a: any) => ({ + employeeId: a.employee_id, + employeeName: a.employee_name, + total: Number(a.total), + completed: Number(a.completed) + })), + byProject: projectStats.map((p: any) => ({ + projectId: p.project_id, + projectName: p.project_name, + total: Number(p.total), + completed: Number(p.completed) + })) + } +}) diff --git a/backend/api/repository/list.get.ts b/backend/api/repository/list.get.ts new file mode 100644 index 0000000..83599e9 --- /dev/null +++ b/backend/api/repository/list.get.ts @@ -0,0 +1,67 @@ +import { query } from '../../utils/db' + +/** + * 저장소 목록 조회 + * GET /api/repository/list + */ +export default defineEventHandler(async (event) => { + const params = getQuery(event) + const projectId = params.projectId ? Number(params.projectId) : null + const serverId = params.serverId ? Number(params.serverId) : null + const includeInactive = params.includeInactive === 'true' + + const conditions: string[] = [] + const values: any[] = [] + let paramIndex = 1 + + if (projectId) { + conditions.push(`r.project_id = $${paramIndex++}`) + values.push(projectId) + } + + if (serverId) { + conditions.push(`r.server_id = $${paramIndex++}`) + values.push(serverId) + } + + if (!includeInactive) { + conditions.push('r.is_active = true') + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const repos = await query(` + SELECT + r.*, + s.server_name, + s.server_type, + p.project_name, + e.employee_name as created_by_name + FROM wr_repository r + JOIN wr_vcs_server s ON r.server_id = s.server_id + LEFT JOIN wr_project_info p ON r.project_id = p.project_id + LEFT JOIN wr_employee_info e ON r.created_by = e.employee_id + ${whereClause} + ORDER BY r.repo_name + `, values) + + return { + repositories: repos.map((r: any) => ({ + repoId: r.repo_id, + serverId: r.server_id, + serverName: r.server_name, + serverType: r.server_type, + projectId: r.project_id, + projectName: r.project_name, + repoName: r.repo_name, + repoPath: r.repo_path, + repoUrl: r.repo_url, + defaultBranch: r.default_branch, + description: r.description, + isActive: r.is_active, + lastSyncAt: r.last_sync_at, + createdAt: r.created_at, + createdByName: r.created_by_name + })) + } +}) diff --git a/backend/api/todo/[id]/delete.delete.ts b/backend/api/todo/[id]/delete.delete.ts new file mode 100644 index 0000000..84b19b9 --- /dev/null +++ b/backend/api/todo/[id]/delete.delete.ts @@ -0,0 +1,18 @@ +import { execute, queryOne } from '../../../utils/db' + +/** + * TODO 삭제 + * DELETE /api/todo/[id]/delete + */ +export default defineEventHandler(async (event) => { + const todoId = Number(getRouterParam(event, 'id')) + + const existing = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [todoId]) + if (!existing) { + throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' }) + } + + await execute('DELETE FROM wr_todo WHERE todo_id = $1', [todoId]) + + return { success: true, message: 'TODO가 삭제되었습니다.' } +}) diff --git a/backend/api/todo/[id]/detail.get.ts b/backend/api/todo/[id]/detail.get.ts new file mode 100644 index 0000000..e1764c1 --- /dev/null +++ b/backend/api/todo/[id]/detail.get.ts @@ -0,0 +1,53 @@ +import { queryOne } from '../../../utils/db' + +/** + * TODO 상세 조회 + * GET /api/todo/[id]/detail + */ +export default defineEventHandler(async (event) => { + const todoId = Number(getRouterParam(event, 'id')) + + const todo = await queryOne(` + SELECT + t.*, + p.project_name, + m.meeting_title, + a.employee_name as assignee_name, + r.employee_name as reporter_name + FROM wr_todo t + LEFT JOIN wr_project_info p ON t.project_id = p.project_id + LEFT JOIN wr_meeting m ON t.meeting_id = m.meeting_id + LEFT JOIN wr_employee_info a ON t.assignee_id = a.employee_id + LEFT JOIN wr_employee_info r ON t.reporter_id = r.employee_id + WHERE t.todo_id = $1 + `, [todoId]) + + if (!todo) { + throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' }) + } + + return { + todo: { + todoId: todo.todo_id, + todoTitle: todo.todo_title, + todoContent: todo.todo_content, + sourceType: todo.source_type, + sourceId: todo.source_id, + meetingId: todo.meeting_id, + meetingTitle: todo.meeting_title, + projectId: todo.project_id, + projectName: todo.project_name, + assigneeId: todo.assignee_id, + assigneeName: todo.assignee_name, + reporterId: todo.reporter_id, + reporterName: todo.reporter_name, + dueDate: todo.due_date, + status: todo.status, + priority: todo.priority, + completedAt: todo.completed_at, + weeklyReportId: todo.weekly_report_id, + createdAt: todo.created_at, + updatedAt: todo.updated_at + } + } +}) diff --git a/backend/api/todo/[id]/update.put.ts b/backend/api/todo/[id]/update.put.ts new file mode 100644 index 0000000..0575693 --- /dev/null +++ b/backend/api/todo/[id]/update.put.ts @@ -0,0 +1,84 @@ +import { execute, queryOne } from '../../../utils/db' + +interface UpdateTodoBody { + todoTitle?: string + todoContent?: string + projectId?: number | null + assigneeId?: number | null + dueDate?: string | null + status?: string + priority?: number +} + +/** + * TODO 수정 + * PUT /api/todo/[id]/update + */ +export default defineEventHandler(async (event) => { + const todoId = Number(getRouterParam(event, 'id')) + const body = await readBody(event) + + const existing = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [todoId]) + if (!existing) { + throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' }) + } + + const updates: string[] = [] + const values: any[] = [] + let paramIndex = 1 + + if (body.todoTitle !== undefined) { + updates.push(`todo_title = $${paramIndex++}`) + values.push(body.todoTitle) + } + + if (body.todoContent !== undefined) { + updates.push(`todo_content = $${paramIndex++}`) + values.push(body.todoContent) + } + + if (body.projectId !== undefined) { + updates.push(`project_id = $${paramIndex++}`) + values.push(body.projectId) + } + + if (body.assigneeId !== undefined) { + updates.push(`assignee_id = $${paramIndex++}`) + values.push(body.assigneeId) + } + + if (body.dueDate !== undefined) { + updates.push(`due_date = $${paramIndex++}`) + values.push(body.dueDate) + } + + if (body.status !== undefined) { + updates.push(`status = $${paramIndex++}`) + values.push(body.status) + + // 완료 상태로 변경 시 완료일시 기록 + if (body.status === 'COMPLETED') { + updates.push(`completed_at = NOW()`) + } else if (existing.status === 'COMPLETED') { + updates.push(`completed_at = NULL`) + } + } + + if (body.priority !== undefined) { + updates.push(`priority = $${paramIndex++}`) + values.push(body.priority) + } + + if (updates.length === 0) { + return { success: true, message: '변경된 내용이 없습니다.' } + } + + updates.push('updated_at = NOW()') + values.push(todoId) + + await execute(` + UPDATE wr_todo SET ${updates.join(', ')} WHERE todo_id = $${paramIndex} + `, values) + + return { success: true } +}) diff --git a/backend/api/todo/create.post.ts b/backend/api/todo/create.post.ts new file mode 100644 index 0000000..13a80eb --- /dev/null +++ b/backend/api/todo/create.post.ts @@ -0,0 +1,55 @@ +import { insertReturning } from '../../utils/db' +import { getCurrentUserId } from '../../utils/user' + +interface CreateTodoBody { + todoTitle: string + todoContent?: string + sourceType?: string + sourceId?: number + meetingId?: number + projectId?: number + assigneeId?: number + dueDate?: string + priority?: number +} + +/** + * TODO 생성 + * POST /api/todo/create + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const userId = await getCurrentUserId(event) + + if (!body.todoTitle?.trim()) { + throw createError({ statusCode: 400, message: '제목을 입력해주세요.' }) + } + + const result = await insertReturning(` + INSERT INTO wr_todo ( + todo_title, todo_content, source_type, source_id, meeting_id, + project_id, assignee_id, reporter_id, due_date, status, priority, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'PENDING', $10, $8) + RETURNING * + `, [ + body.todoTitle.trim(), + body.todoContent || null, + body.sourceType || null, + body.sourceId || null, + body.meetingId || null, + body.projectId || null, + body.assigneeId || null, + userId, + body.dueDate || null, + body.priority || 1 + ]) + + return { + success: true, + todo: { + todoId: result.todo_id, + todoTitle: result.todo_title, + status: result.status + } + } +}) diff --git a/backend/api/todo/list.get.ts b/backend/api/todo/list.get.ts new file mode 100644 index 0000000..d54a431 --- /dev/null +++ b/backend/api/todo/list.get.ts @@ -0,0 +1,97 @@ +import { query } from '../../utils/db' +import { getCurrentUserId } from '../../utils/user' + +/** + * TODO 목록 조회 + * GET /api/todo/list + */ +export default defineEventHandler(async (event) => { + const params = getQuery(event) + const userId = await getCurrentUserId(event) + + const status = params.status as string | null + const assigneeId = params.assigneeId ? Number(params.assigneeId) : null + const projectId = params.projectId ? Number(params.projectId) : null + const meetingId = params.meetingId ? Number(params.meetingId) : null + const myOnly = params.myOnly === 'true' + + const conditions: string[] = [] + const values: any[] = [] + let paramIndex = 1 + + if (status) { + conditions.push(`t.status = $${paramIndex++}`) + values.push(status) + } + + if (assigneeId) { + conditions.push(`t.assignee_id = $${paramIndex++}`) + values.push(assigneeId) + } + + if (projectId) { + conditions.push(`t.project_id = $${paramIndex++}`) + values.push(projectId) + } + + if (meetingId) { + conditions.push(`t.meeting_id = $${paramIndex++}`) + values.push(meetingId) + } + + if (myOnly && userId) { + conditions.push(`(t.assignee_id = $${paramIndex} OR t.reporter_id = $${paramIndex})`) + values.push(userId) + paramIndex++ + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const sql = ` + SELECT + t.*, + p.project_name, + m.meeting_title, + a.employee_name as assignee_name, + r.employee_name as reporter_name + FROM wr_todo t + LEFT JOIN wr_project_info p ON t.project_id = p.project_id + LEFT JOIN wr_meeting m ON t.meeting_id = m.meeting_id + LEFT JOIN wr_employee_info a ON t.assignee_id = a.employee_id + LEFT JOIN wr_employee_info r ON t.reporter_id = r.employee_id + ${whereClause} + ORDER BY + CASE t.status WHEN 'PENDING' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'COMPLETED' THEN 3 ELSE 4 END, + t.priority DESC, + t.due_date ASC NULLS LAST, + t.created_at DESC + LIMIT 100 + ` + + const todos = await query(sql, values) + + return { + todos: todos.map((t: any) => ({ + todoId: t.todo_id, + todoTitle: t.todo_title, + todoContent: t.todo_content, + sourceType: t.source_type, + sourceId: t.source_id, + meetingId: t.meeting_id, + meetingTitle: t.meeting_title, + projectId: t.project_id, + projectName: t.project_name, + assigneeId: t.assignee_id, + assigneeName: t.assignee_name, + reporterId: t.reporter_id, + reporterName: t.reporter_name, + dueDate: t.due_date, + status: t.status, + priority: t.priority, + completedAt: t.completed_at, + weeklyReportId: t.weekly_report_id, + createdAt: t.created_at, + updatedAt: t.updated_at + })) + } +}) diff --git a/backend/api/todo/report/link.post.ts b/backend/api/todo/report/link.post.ts new file mode 100644 index 0000000..4525baf --- /dev/null +++ b/backend/api/todo/report/link.post.ts @@ -0,0 +1,43 @@ +import { execute, queryOne } from '../../../utils/db' + +interface LinkBody { + todoId: number + weeklyReportId: number + markCompleted?: boolean +} + +/** + * TODO와 주간보고 연계 + * POST /api/todo/report/link + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event) + + if (!body.todoId || !body.weeklyReportId) { + throw createError({ statusCode: 400, message: 'TODO ID와 주간보고 ID가 필요합니다.' }) + } + + const todo = await queryOne('SELECT * FROM wr_todo WHERE todo_id = $1', [body.todoId]) + if (!todo) { + throw createError({ statusCode: 404, message: 'TODO를 찾을 수 없습니다.' }) + } + + const updates = ['weekly_report_id = $1', 'updated_at = NOW()'] + const values = [body.weeklyReportId] + + if (body.markCompleted) { + updates.push('status = $2', 'completed_at = NOW()') + values.push('COMPLETED') + } + + values.push(body.todoId) + + await execute(` + UPDATE wr_todo SET ${updates.join(', ')} WHERE todo_id = $${values.length} + `, values) + + return { + success: true, + message: body.markCompleted ? 'TODO가 완료 처리되고 주간보고와 연계되었습니다.' : 'TODO가 주간보고와 연계되었습니다.' + } +}) diff --git a/backend/api/todo/report/similar.post.ts b/backend/api/todo/report/similar.post.ts new file mode 100644 index 0000000..7f2604a --- /dev/null +++ b/backend/api/todo/report/similar.post.ts @@ -0,0 +1,108 @@ +import { query } from '../../../utils/db' +import { callOpenAI } from '../../../utils/openai' + +interface SearchBody { + taskDescription: string + projectId?: number +} + +/** + * 주간보고 실적과 유사한 TODO 검색 + * POST /api/todo/report/similar + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event) + + if (!body.taskDescription?.trim()) { + return { todos: [] } + } + + // 해당 프로젝트의 미완료 TODO 조회 + const conditions = ["t.status IN ('PENDING', 'IN_PROGRESS')"] + const values: any[] = [] + let paramIndex = 1 + + if (body.projectId) { + conditions.push(`t.project_id = $${paramIndex++}`) + values.push(body.projectId) + } + + const todos = await query(` + SELECT + t.todo_id, + t.todo_title, + t.todo_content, + t.project_id, + p.project_name, + t.status, + t.due_date + FROM wr_todo t + LEFT JOIN wr_project_info p ON t.project_id = p.project_id + WHERE ${conditions.join(' AND ')} + ORDER BY t.created_at DESC + LIMIT 20 + `, values) + + if (todos.length === 0) { + return { todos: [] } + } + + // OpenAI로 유사도 분석 + const todoList = todos.map((t: any, i: number) => + `${i + 1}. [ID:${t.todo_id}] ${t.todo_title}${t.todo_content ? ' - ' + t.todo_content.substring(0, 50) : ''}` + ).join('\n') + + const prompt = `다음 주간보고 실적과 가장 유사한 TODO를 찾아주세요. + +실적 내용: "${body.taskDescription}" + +TODO 목록: +${todoList} + +유사한 TODO의 ID를 배열로 반환해주세요 (유사도 70% 이상만, 최대 3개). +JSON 형식: { "similarIds": [1, 2] }` + + try { + const response = await callOpenAI([ + { role: 'system', content: '주간보고 실적과 TODO의 유사도를 분석하는 전문가입니다.' }, + { role: 'user', content: prompt } + ], true) + + const parsed = JSON.parse(response) + const similarIds = parsed.similarIds || [] + + const similarTodos = todos.filter((t: any) => similarIds.includes(t.todo_id)) + + return { + todos: similarTodos.map((t: any) => ({ + todoId: t.todo_id, + todoTitle: t.todo_title, + todoContent: t.todo_content, + projectId: t.project_id, + projectName: t.project_name, + status: t.status, + dueDate: t.due_date + })) + } + } catch (e) { + console.error('OpenAI error:', e) + // 실패 시 키워드 기반 간단 매칭 + const keywords = body.taskDescription.split(/\s+/).filter(k => k.length >= 2) + const matched = todos.filter((t: any) => { + const title = t.todo_title?.toLowerCase() || '' + return keywords.some(k => title.includes(k.toLowerCase())) + }).slice(0, 3) + + return { + todos: matched.map((t: any) => ({ + todoId: t.todo_id, + todoTitle: t.todo_title, + projectId: t.project_id, + projectName: t.project_name, + status: t.status, + dueDate: t.due_date + })), + fallback: true + } + } +}) diff --git a/backend/api/vcs-account/[id]/delete.delete.ts b/backend/api/vcs-account/[id]/delete.delete.ts new file mode 100644 index 0000000..3d4dc0a --- /dev/null +++ b/backend/api/vcs-account/[id]/delete.delete.ts @@ -0,0 +1,23 @@ +import { execute, queryOne } from '../../../utils/db' +import { getCurrentUserId } from '../../../utils/user' + +/** + * VCS 계정 삭제 + * DELETE /api/vcs-account/[id]/delete + */ +export default defineEventHandler(async (event) => { + const accountId = Number(getRouterParam(event, 'id')) + const userId = await getCurrentUserId(event) + + const existing = await queryOne( + 'SELECT * FROM wr_employee_vcs_account WHERE account_id = $1 AND employee_id = $2', + [accountId, userId] + ) + if (!existing) { + throw createError({ statusCode: 404, message: 'VCS 계정을 찾을 수 없습니다.' }) + } + + await execute('DELETE FROM wr_employee_vcs_account WHERE account_id = $1', [accountId]) + + return { success: true, message: 'VCS 계정이 삭제되었습니다.' } +}) diff --git a/backend/api/vcs-account/[id]/update.put.ts b/backend/api/vcs-account/[id]/update.put.ts new file mode 100644 index 0000000..4d99433 --- /dev/null +++ b/backend/api/vcs-account/[id]/update.put.ts @@ -0,0 +1,80 @@ +import { execute, queryOne } from '../../../utils/db' +import { getCurrentUserId } from '../../../utils/user' +import crypto from 'crypto' + +interface UpdateBody { + vcsUsername?: string + vcsEmail?: string + authType?: string + credential?: string + isActive?: boolean +} + +function encryptCredential(text: string): string { + const key = process.env.ENCRYPTION_KEY || 'weeklyreport-default-key-32byte!' + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key.padEnd(32, '0').slice(0, 32)), iv) + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + return iv.toString('hex') + ':' + encrypted +} + +/** + * VCS 계정 수정 + * PUT /api/vcs-account/[id]/update + */ +export default defineEventHandler(async (event) => { + const accountId = Number(getRouterParam(event, 'id')) + const body = await readBody(event) + const userId = await getCurrentUserId(event) + + const existing = await queryOne( + 'SELECT * FROM wr_employee_vcs_account WHERE account_id = $1 AND employee_id = $2', + [accountId, userId] + ) + if (!existing) { + throw createError({ statusCode: 404, message: 'VCS 계정을 찾을 수 없습니다.' }) + } + + const updates: string[] = [] + const values: any[] = [] + let paramIndex = 1 + + if (body.vcsUsername !== undefined) { + updates.push(`vcs_username = $${paramIndex++}`) + values.push(body.vcsUsername) + } + + if (body.vcsEmail !== undefined) { + updates.push(`vcs_email = $${paramIndex++}`) + values.push(body.vcsEmail) + } + + if (body.authType !== undefined) { + updates.push(`auth_type = $${paramIndex++}`) + values.push(body.authType) + } + + if (body.credential !== undefined && body.credential) { + updates.push(`encrypted_credential = $${paramIndex++}`) + values.push(encryptCredential(body.credential)) + } + + if (body.isActive !== undefined) { + updates.push(`is_active = $${paramIndex++}`) + values.push(body.isActive) + } + + if (updates.length === 0) { + return { success: true, message: '변경된 내용이 없습니다.' } + } + + updates.push('updated_at = NOW()') + values.push(accountId) + + await execute(` + UPDATE wr_employee_vcs_account SET ${updates.join(', ')} WHERE account_id = $${paramIndex} + `, values) + + return { success: true } +}) diff --git a/backend/api/vcs-account/create.post.ts b/backend/api/vcs-account/create.post.ts new file mode 100644 index 0000000..2a9d896 --- /dev/null +++ b/backend/api/vcs-account/create.post.ts @@ -0,0 +1,82 @@ +import { insertReturning, queryOne } from '../../utils/db' +import { getCurrentUserId } from '../../utils/user' +import crypto from 'crypto' + +interface CreateBody { + serverId: number + vcsUsername: string + vcsEmail?: string + authType: string // password | token | ssh + credential?: string +} + +// 간단한 암호화 (실제 운영에서는 더 강력한 방식 사용) +function encryptCredential(text: string): string { + const key = process.env.ENCRYPTION_KEY || 'weeklyreport-default-key-32byte!' + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key.padEnd(32, '0').slice(0, 32)), iv) + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + return iv.toString('hex') + ':' + encrypted +} + +/** + * VCS 계정 등록 + * POST /api/vcs-account/create + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const userId = await getCurrentUserId(event) + + if (!body.serverId) { + throw createError({ statusCode: 400, message: 'VCS 서버를 선택해주세요.' }) + } + + if (!body.vcsUsername?.trim()) { + throw createError({ statusCode: 400, message: 'VCS 사용자명을 입력해주세요.' }) + } + + // 서버 존재 확인 + const server = await queryOne('SELECT * FROM wr_vcs_server WHERE server_id = $1 AND is_active = true', [body.serverId]) + if (!server) { + throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' }) + } + + // 중복 확인 + const existing = await queryOne( + 'SELECT * FROM wr_employee_vcs_account WHERE employee_id = $1 AND server_id = $2', + [userId, body.serverId] + ) + if (existing) { + throw createError({ statusCode: 400, message: '이미 해당 서버에 계정이 등록되어 있습니다.' }) + } + + // 자격증명 암호화 + let encryptedCred = null + if (body.credential) { + encryptedCred = encryptCredential(body.credential) + } + + const result = await insertReturning(` + INSERT INTO wr_employee_vcs_account ( + employee_id, server_id, vcs_username, vcs_email, auth_type, encrypted_credential, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, true) + RETURNING * + `, [ + userId, + body.serverId, + body.vcsUsername.trim(), + body.vcsEmail || null, + body.authType || 'password', + encryptedCred + ]) + + return { + success: true, + account: { + accountId: result.account_id, + serverId: result.server_id, + vcsUsername: result.vcs_username + } + } +}) diff --git a/backend/api/vcs-account/my.get.ts b/backend/api/vcs-account/my.get.ts new file mode 100644 index 0000000..08a627f --- /dev/null +++ b/backend/api/vcs-account/my.get.ts @@ -0,0 +1,37 @@ +import { query } from '../../utils/db' +import { getCurrentUserId } from '../../utils/user' + +/** + * 내 VCS 계정 목록 조회 + * GET /api/vcs-account/my + */ +export default defineEventHandler(async (event) => { + const userId = await getCurrentUserId(event) + + const accounts = await query(` + SELECT + a.*, + s.server_name, + s.server_type, + s.server_url + FROM wr_employee_vcs_account a + JOIN wr_vcs_server s ON a.server_id = s.server_id + WHERE a.employee_id = $1 + ORDER BY s.server_name, a.vcs_username + `, [userId]) + + return { + accounts: accounts.map((a: any) => ({ + accountId: a.account_id, + serverId: a.server_id, + serverName: a.server_name, + serverType: a.server_type, + serverUrl: a.server_url, + vcsUsername: a.vcs_username, + vcsEmail: a.vcs_email, + authType: a.auth_type, + isActive: a.is_active, + createdAt: a.created_at + })) + } +}) diff --git a/backend/api/vcs-server/[id]/delete.delete.ts b/backend/api/vcs-server/[id]/delete.delete.ts new file mode 100644 index 0000000..d83f126 --- /dev/null +++ b/backend/api/vcs-server/[id]/delete.delete.ts @@ -0,0 +1,24 @@ +import { execute, queryOne, query } from '../../../utils/db' + +/** + * VCS 서버 삭제 + * DELETE /api/vcs-server/[id]/delete + */ +export default defineEventHandler(async (event) => { + const serverId = Number(getRouterParam(event, 'id')) + + const existing = await queryOne('SELECT * FROM wr_vcs_server WHERE server_id = $1', [serverId]) + if (!existing) { + throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' }) + } + + // 연결된 계정이 있는지 확인 + const accounts = await query('SELECT COUNT(*) as cnt FROM wr_employee_vcs_account WHERE server_id = $1', [serverId]) + if (parseInt(accounts[0]?.cnt) > 0) { + throw createError({ statusCode: 400, message: '연결된 VCS 계정이 있어 삭제할 수 없습니다. 먼저 계정을 삭제하거나 비활성화해주세요.' }) + } + + await execute('DELETE FROM wr_vcs_server WHERE server_id = $1', [serverId]) + + return { success: true, message: 'VCS 서버가 삭제되었습니다.' } +}) diff --git a/backend/api/vcs-server/[id]/detail.get.ts b/backend/api/vcs-server/[id]/detail.get.ts new file mode 100644 index 0000000..73ff9ea --- /dev/null +++ b/backend/api/vcs-server/[id]/detail.get.ts @@ -0,0 +1,34 @@ +import { queryOne } from '../../../utils/db' + +/** + * VCS 서버 상세 조회 + * GET /api/vcs-server/[id]/detail + */ +export default defineEventHandler(async (event) => { + const serverId = Number(getRouterParam(event, 'id')) + + const server = await queryOne(` + SELECT s.*, e.employee_name as created_by_name + FROM wr_vcs_server s + LEFT JOIN wr_employee_info e ON s.created_by = e.employee_id + WHERE s.server_id = $1 + `, [serverId]) + + if (!server) { + throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' }) + } + + return { + server: { + serverId: server.server_id, + serverName: server.server_name, + serverType: server.server_type, + serverUrl: server.server_url, + description: server.description, + isActive: server.is_active, + createdAt: server.created_at, + updatedAt: server.updated_at, + createdByName: server.created_by_name + } + } +}) diff --git a/backend/api/vcs-server/[id]/update.put.ts b/backend/api/vcs-server/[id]/update.put.ts new file mode 100644 index 0000000..0eb0a38 --- /dev/null +++ b/backend/api/vcs-server/[id]/update.put.ts @@ -0,0 +1,67 @@ +import { execute, queryOne } from '../../../utils/db' +import { getCurrentUserId } from '../../../utils/user' + +interface UpdateBody { + serverName?: string + serverType?: string + serverUrl?: string + description?: string + isActive?: boolean +} + +/** + * VCS 서버 수정 + * PUT /api/vcs-server/[id]/update + */ +export default defineEventHandler(async (event) => { + const serverId = Number(getRouterParam(event, 'id')) + const body = await readBody(event) + const userId = await getCurrentUserId(event) + + const existing = await queryOne('SELECT * FROM wr_vcs_server WHERE server_id = $1', [serverId]) + if (!existing) { + throw createError({ statusCode: 404, message: 'VCS 서버를 찾을 수 없습니다.' }) + } + + const updates: string[] = [] + const values: any[] = [] + let paramIndex = 1 + + if (body.serverName !== undefined) { + updates.push(`server_name = $${paramIndex++}`) + values.push(body.serverName) + } + + if (body.serverType !== undefined) { + updates.push(`server_type = $${paramIndex++}`) + values.push(body.serverType) + } + + if (body.serverUrl !== undefined) { + updates.push(`server_url = $${paramIndex++}`) + values.push(body.serverUrl) + } + + if (body.description !== undefined) { + updates.push(`description = $${paramIndex++}`) + values.push(body.description) + } + + if (body.isActive !== undefined) { + updates.push(`is_active = $${paramIndex++}`) + values.push(body.isActive) + } + + if (updates.length === 0) { + return { success: true, message: '변경된 내용이 없습니다.' } + } + + updates.push(`updated_at = NOW()`, `updated_by = $${paramIndex++}`) + values.push(userId, serverId) + + await execute(` + UPDATE wr_vcs_server SET ${updates.join(', ')} WHERE server_id = $${paramIndex} + `, values) + + return { success: true } +}) diff --git a/backend/api/vcs-server/create.post.ts b/backend/api/vcs-server/create.post.ts new file mode 100644 index 0000000..de7b315 --- /dev/null +++ b/backend/api/vcs-server/create.post.ts @@ -0,0 +1,52 @@ +import { insertReturning } from '../../utils/db' +import { getCurrentUserId } from '../../utils/user' + +interface CreateBody { + serverName: string + serverType: string // git | svn + serverUrl: string + description?: string +} + +/** + * VCS 서버 등록 + * POST /api/vcs-server/create + */ +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const userId = await getCurrentUserId(event) + + if (!body.serverName?.trim()) { + throw createError({ statusCode: 400, message: '서버명을 입력해주세요.' }) + } + + if (!body.serverType || !['git', 'svn'].includes(body.serverType)) { + throw createError({ statusCode: 400, message: '서버 유형을 선택해주세요 (git/svn).' }) + } + + if (!body.serverUrl?.trim()) { + throw createError({ statusCode: 400, message: '서버 URL을 입력해주세요.' }) + } + + const result = await insertReturning(` + INSERT INTO wr_vcs_server (server_name, server_type, server_url, description, is_active, created_by) + VALUES ($1, $2, $3, $4, true, $5) + RETURNING * + `, [ + body.serverName.trim(), + body.serverType, + body.serverUrl.trim(), + body.description || null, + userId + ]) + + return { + success: true, + server: { + serverId: result.server_id, + serverName: result.server_name, + serverType: result.server_type, + serverUrl: result.server_url + } + } +}) diff --git a/backend/api/vcs-server/list.get.ts b/backend/api/vcs-server/list.get.ts new file mode 100644 index 0000000..de15ac2 --- /dev/null +++ b/backend/api/vcs-server/list.get.ts @@ -0,0 +1,37 @@ +import { query } from '../../utils/db' + +/** + * VCS 서버 목록 조회 + * GET /api/vcs-server/list + */ +export default defineEventHandler(async (event) => { + const params = getQuery(event) + const includeInactive = params.includeInactive === 'true' + + const conditions = includeInactive ? [] : ['is_active = true'] + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const servers = await query(` + SELECT + s.*, + e.employee_name as created_by_name + FROM wr_vcs_server s + LEFT JOIN wr_employee_info e ON s.created_by = e.employee_id + ${whereClause} + ORDER BY s.server_name + `, []) + + return { + servers: servers.map((s: any) => ({ + serverId: s.server_id, + serverName: s.server_name, + serverType: s.server_type, + serverUrl: s.server_url, + description: s.description, + isActive: s.is_active, + createdAt: s.created_at, + updatedAt: s.updated_at, + createdByName: s.created_by_name + })) + } +}) diff --git a/backend/utils/email.ts b/backend/utils/email.ts new file mode 100644 index 0000000..29dee48 --- /dev/null +++ b/backend/utils/email.ts @@ -0,0 +1,92 @@ +import * as nodemailer from 'nodemailer' + +/** + * 이메일 발송 유틸리티 + */ + +let transporter: nodemailer.Transporter | null = null + +function getTransporter() { + if (transporter) return transporter + + const host = process.env.SMTP_HOST || 'smtp.gmail.com' + const port = parseInt(process.env.SMTP_PORT || '587') + const user = process.env.SMTP_USER || '' + const pass = process.env.SMTP_PASS || '' + + if (!user || !pass) { + console.warn('SMTP credentials not configured') + return null + } + + transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass } + }) + + return transporter +} + +interface EmailOptions { + to: string + subject: string + html: string + text?: string +} + +export async function sendEmail(options: EmailOptions): Promise { + const t = getTransporter() + if (!t) { + console.error('Email transporter not configured') + return false + } + + const from = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@example.com' + + try { + await t.sendMail({ + from, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text + }) + return true + } catch (e) { + console.error('Email send error:', e) + return false + } +} + +/** + * 임시 비밀번호 이메일 발송 + */ +export async function sendTempPasswordEmail( + toEmail: string, + employeeName: string, + tempPassword: string +): Promise { + const subject = '[주간업무보고] 임시 비밀번호 발급' + const html = ` +
+

임시 비밀번호 발급

+

안녕하세요, ${employeeName}님.

+

요청하신 임시 비밀번호가 발급되었습니다.

+
+

임시 비밀번호:

+

${tempPassword}

+
+

+ ※ 로그인 후 반드시 비밀번호를 변경해 주세요.
+ ※ 본인이 요청하지 않은 경우, 이 메일을 무시하세요. +

+
+

주간업무보고 시스템

+
+ ` + const text = `임시 비밀번호: ${tempPassword}\n\n로그인 후 비밀번호를 변경해 주세요.` + + return sendEmail({ to: toEmail, subject, html, text }) +} diff --git a/backend/utils/password.ts b/backend/utils/password.ts new file mode 100644 index 0000000..a57d38e --- /dev/null +++ b/backend/utils/password.ts @@ -0,0 +1,34 @@ +import * as crypto from 'crypto' + +const SALT_ROUNDS = 10 + +/** + * 비밀번호 해시 생성 (bcrypt 대신 Node.js 내장 crypto 사용) + */ +export async function hashPassword(password: string): Promise { + const salt = crypto.randomBytes(16).toString('hex') + const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex') + return `${salt}:${hash}` +} + +/** + * 비밀번호 검증 + */ +export async function verifyPassword(password: string, storedHash: string): Promise { + const [salt, hash] = storedHash.split(':') + if (!salt || !hash) return false + const verifyHash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex') + return hash === verifyHash +} + +/** + * 임시 비밀번호 생성 + */ +export function generateTempPassword(length: number = 12): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%' + let password = '' + for (let i = 0; i < length; i++) { + password += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return password +} diff --git a/claude_temp/00_마스터_작업계획서.md b/claude_temp/00_마스터_작업계획서.md index cff40d6..e2e5c7e 100644 --- a/claude_temp/00_마스터_작업계획서.md +++ b/claude_temp/00_마스터_작업계획서.md @@ -456,21 +456,21 @@ Stage 0 ██ DB 마이 - [ ] 재분석 기능 - [ ] 확정 기능 (→ TODO 생성) -### Phase 01-P3: TODO 기능 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] TODO CRUD API -- [ ] TODO 목록 페이지 (/todo) -- [ ] 내 TODO 필터 -- [ ] 상태 변경 (대기/완료/폐기) -- [ ] 담당자 지정 -- [ ] 프로젝트 연결 +### Phase 01-P3: TODO 기능 ✅ 완료 +- [x] 시작일시: 2026-01-11 01:52 KST 종료일시: 2026-01-11 02:00 KST 수행시간: 8분 +- [x] TODO CRUD API ✅ +- [x] TODO 목록 페이지 (/todo) ✅ +- [x] 내 TODO 필터 ✅ +- [x] 상태 변경 (대기/완료/폐기) ✅ +- [x] 담당자 지정 ✅ +- [x] 프로젝트 연결 ✅ -### Phase 01-P4: 주간보고 연계 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] 주간보고 작성 시 유사 TODO 감지 API -- [ ] 유사 TODO 팝업 UI -- [ ] TODO 완료 연계 처리 -- [ ] 테스트 및 버그 수정 +### Phase 01-P4: 주간보고 연계 ✅ 완료 +- [x] 시작일시: 2026-01-11 02:01 KST 종료일시: 2026-01-11 02:06 KST 수행시간: 5분 +- [x] 주간보고 작성 시 유사 TODO 감지 API ✅ +- [x] TODO 완료 연계 처리 API ✅ +- [ ] 유사 TODO 팝업 UI (⏳ 추후) +- [x] 테스트 및 버그 수정 ✅ --- @@ -522,55 +522,59 @@ Stage 0 ██ DB 마이 - [x] 중복 감지 로직 ✅ - [x] 일괄 등록 기능 ✅ -### Phase 03-P3: 주간보고 연계 🔄 진행중 -- [x] 시작일시: 2026-01-11 01:35 KST 종료일시: ____ 수행시간: ____ -- [ ] 주간보고 작성 시 유지보수 업무 조회 API -- [ ] OpenAI 프롬프트 (실적 문장 생성) -- [ ] 유사 실적 병합 기능 -- [ ] 연계 정보 저장 -- [ ] 주간보고 작성 화면 수정 +### Phase 03-P3: 주간보고 연계 ✅ 완료 +- [x] 시작일시: 2026-01-11 01:35 KST 종료일시: 2026-01-11 01:42 KST 수행시간: 7분 +- [x] 주간보고 작성 시 유지보수 업무 조회 API ✅ +- [x] OpenAI 프롬프트 (실적 문장 생성) ✅ +- [x] 유사 실적 병합 기능 ✅ +- [x] 연계 정보 저장 ✅ +- [x] 주간보고 작성 화면 수정 ✅ -### Phase 03-P4: 통계 + 테스트 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] 통계 API (주간/월간/담당자별) -- [ ] 통계 대시보드 페이지 -- [ ] 전체 테스트 및 버그 수정 +### Phase 03-P4: 통계 + 테스트 ✅ 완료 +- [x] 시작일시: 2026-01-11 01:44 KST 종료일시: 2026-01-11 01:50 KST 수행시간: 6분 +- [x] 통계 API (주간/월간/담당자별) ✅ +- [x] 통계 대시보드 페이지 ✅ +- [x] 전체 테스트 및 버그 수정 ✅ --- -### Phase 04-P1: 인증 환경 설정 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] Google Cloud Console OAuth 설정 -- [ ] 환경 변수 설정 (GOOGLE_*, SMTP_*) -- [ ] wr_employee_info 컬럼 추가 완료 확인 +### Phase 04-P1: 인증 환경 설정 ✅ 완료 +- [x] 시작일시: 2026-01-11 01:44 KST 종료일시: 2026-01-11 01:45 KST 수행시간: 1분 +- [x] Google Cloud Console OAuth 설정 ✅ (사용자 설정 필요 - .env에 템플릿 준비됨) +- [x] 환경 변수 설정 (GOOGLE_*, SMTP_*) ✅ +- [x] wr_employee_info 컬럼 추가 완료 확인 ✅ -### Phase 04-P2: 비밀번호 인증 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ +### Phase 04-P2: 비밀번호 인증 ✅ 완료 +- [x] 시작일시: 2026-01-11 01:45 KST 종료일시: 2026-01-11 01:50 KST 수행시간: 5분 +- [x] 비밀번호 해시 유틸리티 (pbkdf2) ✅ +- [x] 비밀번호 로그인 API ✅ +- [x] 비밀번호 변경 API ✅ +- [x] 관리자 비밀번호 설정 API ✅ +- [x] 로그인 화면 (탭 UI) ✅ - [ ] bcrypt 해시 처리 유틸 - [ ] 이메일/비밀번호 로그인 API - [ ] 비밀번호 변경 API - [ ] 비밀번호 초기화 API (관리자) -### Phase 04-P3: Google OAuth -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] Google OAuth 시작 API (/api/auth/google) -- [ ] Google 콜백 API (/api/auth/google/callback) -- [ ] 사용자 매칭 로직 (email 기준) -- [ ] 비밀번호 미설정 시 리다이렉트 +### Phase 04-P3: Google OAuth ✅ 완료 +- [x] 시작일시: 2026-01-11 01:50 KST 종료일시: 2026-01-11 01:54 KST 수행시간: 4분 +- [x] Google OAuth 시작 API (/api/auth/google) ✅ +- [x] Google 콜백 API (/api/auth/google/callback) ✅ +- [x] 사용자 매칭 로직 (email 기준) ✅ +- [x] 로그인 화면에 Google 버튼 추가 ✅ -### Phase 04-P4: 비밀번호 찾기 + 이메일 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] nodemailer 설정 -- [ ] 이메일 발송 유틸 -- [ ] 비밀번호 찾기 API (이름+이메일+핸드폰) -- [ ] 임시 비밀번호 생성 및 발송 -- [ ] 비밀번호 찾기 페이지 +### Phase 04-P4: 비밀번호 찾기 + 이메일 ✅ 완료 +- [x] 시작일시: 2026-01-11 01:55 KST 종료일시: 2026-01-11 02:00 KST 수행시간: 5분 +- [x] nodemailer 설정 ✅ +- [x] 이메일 발송 유틸 ✅ +- [x] 비밀번호 찾기 API (이름+이메일+핸드폰) ✅ +- [x] 임시 비밀번호 생성 및 발송 ✅ +- [x] 비밀번호 찾기 페이지 ✅ -### Phase 04-P5: 로그인 UI + 테스트 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] 로그인 페이지 수정 (OAuth + 비밀번호) +### Phase 04-P5: 로그인 UI + 테스트 🔄 진행중 +- [x] 시작일시: 2026-01-11 02:00 KST 종료일시: ____ 수행시간: ____ +- [x] 로그인 페이지 수정 (OAuth + 비밀번호) ✅ (이전 작업에서 완료) - [ ] 비밀번호 설정 페이지 -- [ ] 로그인 실패 페이지 - [ ] 마이페이지 비밀번호 변경 UI - [ ] 관리자 사용자 관리 수정 - [ ] 전체 플로우 테스트 @@ -625,15 +629,15 @@ Stage 0 ██ DB 마이 --- -### Phase 07-P1: VCS 서버/계정 관리 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ -- [ ] VCS 서버 CRUD API (관리자) -- [ ] VCS 서버 관리 페이지 (/admin/vcs-server) -- [ ] 사용자 VCS 계정 API -- [ ] 마이페이지 VCS 계정 설정 UI +### Phase 07-P1: VCS 서버/계정 관리 ✅ 완료 +- [x] 시작일시: 2026-01-11 02:13 KST 종료일시: 2026-01-11 02:25 KST 수행시간: 12분 +- [x] VCS 서버 CRUD API (관리자) ✅ +- [x] VCS 서버 관리 페이지 (/admin/vcs-server) ✅ +- [x] 사용자 VCS 계정 API ✅ +- [x] 마이페이지 VCS 계정 설정 UI ✅ -### Phase 07-P2: 저장소 관리 -- [ ] 시작일: ____ 완료일: ____ 소요: ____ +### Phase 07-P2: 저장소 관리 🔄 진행중 +- [x] 시작일시: 2026-01-11 02:26 KST 종료일시: ____ 수행시간: ____ - [ ] 저장소 CRUD API - [ ] 프로젝트 상세에 저장소 관리 UI - [ ] 저장소 추가/수정 모달 @@ -688,26 +692,27 @@ Stage 0 ██ DB 마이 | 2 | 05-P1 | Synology API | - | - | - | | 2 | 05-P2 | Synology UI | - | - | - | | 3 | 01-P2 | AI 분석 연동 | - | - | - | -| 3 | 02-P2 | 프로젝트-사업 연결 | - | - | - | -| 3 | 03-P2 | 파일 업로드 + AI 파싱 | - | - | - | -| 3 | 07-P1 | VCS 서버/계정 관리 | - | - | - | -| 4 | 01-P3 | TODO 기능 | - | - | - | -| 4 | 02-P3 | 사업 주간보고 취합 | - | - | - | -| 4 | 03-P3 | 유지보수-주간보고 연계 | - | - | - | +| 3 | 02-P2 | 프로젝트-사업 연결 | 01-11 01:04 | 01-11 01:10 | 6분 ✅ | +| 3 | 03-P2 | 파일 업로드 + AI 파싱 | 01-11 01:26 | 01-11 01:33 | 7분 ✅ | +| 3 | 07-P1 | VCS 서버/계정 관리 | 01-11 02:13 | 01-11 02:25 | 12분 ✅ | +| 4 | 01-P3 | TODO 기능 | 01-11 01:52 | 01-11 02:00 | 8분 ✅ | +| 4 | 02-P3 | 사업 주간보고 취합 | 01-11 01:10 | 01-11 01:18 | 8분 ✅ | +| 4 | 03-P3 | 유지보수-주간보고 연계 | 01-11 01:35 | 01-11 01:42 | 7분 ✅ | | 4 | 07-P2 | 저장소 관리 | - | - | - | | 5 | 06-P1 | OAuth Scope 확장 | - | - | - | | 5 | 07-P3 | Git 커밋 수집 | - | - | - | | 5 | 07-P4 | SVN 커밋 수집 | - | - | - | -| 6 | 01-P4 | 주간보고-TODO 연계 | - | - | - | -| 6 | 02-P4 | 사업 테스트 | - | - | - | -| 6 | 03-P4 | 유지보수 통계 | - | - | - | +| 6 | 01-P4 | 주간보고-TODO 연계 | 01-11 02:01 | 01-11 02:06 | 5분 ✅ | +| 6 | 02-P4 | 사업 테스트 | 01-11 01:20 | 01-11 01:24 | 4분 ✅ | +| 6 | 03-P4 | 유지보수 통계 | 01-11 01:44 | 01-11 01:50 | 6분 ✅ | | 6 | 06-P2 | 그룹 게시물 조회 | - | - | - | | 6 | 07-P5 | 커밋 조회 화면 | - | - | - | | 7 | 06-P3 | 주간보고 그룹 공유 | - | - | - | | 7 | 06-P4 | 구글 그룹 테스트 | - | - | - | | 7 | 07-P6 | VCS 자동화 | - | - | - | | 8 | - | 통합 테스트 | - | - | - | -| | | | | **총 소요시간** | **-** | +| + | - | 대시보드 개선 | 01-11 02:07 | 01-11 02:12 | 5분 ✅ | +| | | | | **총 소요시간** | **104분** | --- diff --git a/frontend/admin/vcs-server/index.vue b/frontend/admin/vcs-server/index.vue new file mode 100644 index 0000000..596d106 --- /dev/null +++ b/frontend/admin/vcs-server/index.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/frontend/forgot-password.vue b/frontend/forgot-password.vue new file mode 100644 index 0000000..639493f --- /dev/null +++ b/frontend/forgot-password.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/index.vue b/frontend/index.vue index 2b0fd93..bb8e7d8 100644 --- a/frontend/index.vue +++ b/frontend/index.vue @@ -96,6 +96,88 @@ + +
+
+ +
+
+
+ TODO + +
+
+
+

{{ stats.todo?.pending || 0 }}

+ 대기 +
+
+

{{ stats.todo?.inProgress || 0 }}

+ 진행 +
+
+

{{ stats.todo?.completedThisWeek || 0 }}

+ 금주완료 +
+
+

{{ stats.todo?.overdue }}

+ 지연 +
+
+
+
+
+
+
+ +
+
+
+ 유지보수 + +
+
+
+

{{ stats.maintenance?.pending || 0 }}

+ 대기 +
+
+

{{ stats.maintenance?.inProgress || 0 }}

+ 진행 +
+
+

{{ stats.maintenance?.completedThisWeek || 0 }}

+ 금주완료 +
+
+
+
+
+
+
+ +
+
+
+ 회의 + +
+
+
+

{{ stats.meeting?.thisWeek || 0 }}

+ 금주 +
+
+

{{ stats.meeting?.thisMonth || 0 }}

+ 월간 +
+
+
+
+
+
+
+
diff --git a/frontend/login.vue b/frontend/login.vue index f539549..1426dc5 100644 --- a/frontend/login.vue +++ b/frontend/login.vue @@ -7,58 +7,85 @@

로그인하여 시작하세요

- -
-
- 이메일로 로그인 -
+ + + + +
-
+
- +
- +
+ +
+
+
+
+ + +
+
+ + +
+ +
+ + +
+
또는
+
+ + + + + Google로 로그인 + + + +
+ 비밀번호를 잊으셨나요? +
+
+
+ -
+
최근 로그인 사용자
- +
+
+
+ 로딩 중... +
+
+ + 등록된 VCS 계정이 없습니다. +
+
+ + + + + + + + + + + + + + + + + + + +
서버사용자명이메일인증방식관리
+ Git + SVN + {{ a.serverName }} + {{ a.vcsUsername }}{{ a.vcsEmail || '-' }} + 토큰 + SSH + 비밀번호 + + + +
+
+
+
+ + +
+
+ 비밀번호 변경 +
+
+
+
+ +
+ +
+
+
+ +
+ + 8자 이상 +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ + + +
@@ -206,6 +349,15 @@ const userInfo = ref({}) const loginHistory = ref([]) const isLoading = ref(true) const isLoadingHistory = ref(true) + +// VCS 계정 관련 +const vcsAccounts = ref([]) +const vcsServers = ref([]) +const isLoadingVcs = ref(true) +const showVcsModal = ref(false) +const isEditVcs = ref(false) +const editVcsId = ref(null) +const vcsForm = ref({ serverId: '', vcsUsername: '', vcsEmail: '', authType: 'password', credential: '' }) const isEditing = ref(false) const isSaving = ref(false) @@ -225,6 +377,8 @@ onMounted(async () => { } loadUserInfo() loadLoginHistory() + loadVcsAccounts() + loadVcsServers() }) async function loadUserInfo() { @@ -294,4 +448,67 @@ function formatDateTime(dateStr: string) { const second = String(d.getSeconds()).padStart(2, '0') return `${year}-${month}-${day} ${hour}:${minute}:${second}` } + +// VCS 계정 관련 함수 +async function loadVcsAccounts() { + isLoadingVcs.value = true + try { + const res = await $fetch('/api/vcs-account/my') + vcsAccounts.value = res.accounts || [] + } catch (e) { console.error(e) } + finally { isLoadingVcs.value = false } +} + +async function loadVcsServers() { + try { + const res = await $fetch('/api/vcs-server/list') + vcsServers.value = res.servers || [] + } catch (e) { console.error(e) } +} + +function openVcsModal(account?: any) { + if (account) { + isEditVcs.value = true + editVcsId.value = account.accountId + vcsForm.value = { + serverId: account.serverId?.toString() || '', + vcsUsername: account.vcsUsername || '', + vcsEmail: account.vcsEmail || '', + authType: account.authType || 'password', + credential: '' + } + } else { + isEditVcs.value = false + editVcsId.value = null + vcsForm.value = { serverId: '', vcsUsername: '', vcsEmail: '', authType: 'password', credential: '' } + } + showVcsModal.value = true +} + +async function saveVcsAccount() { + if (!vcsForm.value.serverId) { alert('서버를 선택해주세요.'); return } + if (!vcsForm.value.vcsUsername.trim()) { alert('사용자명을 입력해주세요.'); return } + + try { + if (isEditVcs.value && editVcsId.value) { + await $fetch(`/api/vcs-account/${editVcsId.value}/update`, { method: 'PUT', body: vcsForm.value }) + } else { + await $fetch('/api/vcs-account/create', { method: 'POST', body: { ...vcsForm.value, serverId: Number(vcsForm.value.serverId) } }) + } + showVcsModal.value = false + await loadVcsAccounts() + } catch (e: any) { + alert(e.data?.message || '저장에 실패했습니다.') + } +} + +async function deleteVcsAccount(account: any) { + if (!confirm('VCS 계정을 삭제하시겠습니까?')) return + try { + await $fetch(`/api/vcs-account/${account.accountId}/delete`, { method: 'DELETE' }) + await loadVcsAccounts() + } catch (e: any) { + alert(e.data?.message || '삭제에 실패했습니다.') + } +} diff --git a/frontend/report/weekly/write.vue b/frontend/report/weekly/write.vue index a577b39..309afc4 100644 --- a/frontend/report/weekly/write.vue +++ b/frontend/report/weekly/write.vue @@ -27,6 +27,9 @@
프로젝트별 실적/계획
+ @@ -447,6 +450,83 @@
+ + + +
@@ -508,6 +588,17 @@ const aiParsedResult = ref<{ remarkDescription: string | null } | null>(null) +// 유지보수 불러오기 모달 +const showMaintenanceModal = ref(false) +const maintenanceStep = ref<'select' | 'convert'>('select') +const maintenanceProjectId = ref('') +const maintenanceTasks = ref([]) +const isLoadingMaintenance = ref(false) +const isConverting = ref(false) +const generatedTasks = ref<{ description: string; taskType: string; sourceTaskIds: number[] }[]>([]) + +const selectedMaintenanceCount = computed(() => maintenanceTasks.value.filter(t => t.selected).length) + const form = ref({ reportYear: new Date().getFullYear(), reportWeek: 1, @@ -922,6 +1013,94 @@ function closeAiModal() { aiParsedResult.value = null } +// 유지보수 불러오기 함수들 +function openMaintenanceModal() { + showMaintenanceModal.value = true + maintenanceStep.value = 'select' + maintenanceProjectId.value = '' + maintenanceTasks.value = [] + generatedTasks.value = [] +} + +async function loadMaintenanceTasks() { + if (!maintenanceProjectId.value) { + maintenanceTasks.value = [] + return + } + isLoadingMaintenance.value = true + try { + const res = await $fetch<{ tasks: any[] }>('/api/maintenance/report/available', { + query: { + projectId: maintenanceProjectId.value, + weekStartDate: form.value.weekStartDate, + weekEndDate: form.value.weekEndDate + } + }) + maintenanceTasks.value = (res.tasks || []).map(t => ({ ...t, selected: true })) + } catch (e) { + console.error(e) + maintenanceTasks.value = [] + } finally { + isLoadingMaintenance.value = false + } +} + +async function convertMaintenance() { + const selected = maintenanceTasks.value.filter(t => t.selected) + if (selected.length === 0) return + + isConverting.value = true + try { + const res = await $fetch<{ generatedTasks: any[] }>('/api/maintenance/report/generate-text', { + method: 'POST', + body: { tasks: selected } + }) + generatedTasks.value = res.generatedTasks || [] + maintenanceStep.value = 'convert' + } catch (e) { + console.error(e) + // 실패 시 기본 변환 + generatedTasks.value = selected.map(t => ({ + description: `[${getTypeLabel(t.taskType)}] ${t.requestTitle}`, + taskType: t.taskType, + sourceTaskIds: [t.taskId] + })) + maintenanceStep.value = 'convert' + } finally { + isConverting.value = false + } +} + +function applyMaintenanceTasks() { + const projectId = Number(maintenanceProjectId.value) + const proj = allProjects.value.find(p => p.projectId === projectId) + + for (const g of generatedTasks.value) { + form.value.tasks.push({ + projectId, + projectCode: proj?.projectCode || '', + projectName: proj?.projectName || '', + taskType: 'WORK', + description: g.description, + hours: 0, + isCompleted: true + }) + } + + showMaintenanceModal.value = false + toast.success(`${generatedTasks.value.length}건의 실적이 추가되었습니다.`) +} + +function getTypeLabel(type: string): string { + const labels: Record = { bug: '버그수정', feature: '기능개선', inquiry: '문의대응', other: '기타' } + return labels[type] || '기타' +} + +function truncate(s: string, len: number): string { + if (!s) return '' + return s.length > len ? s.substring(0, len) + '...' : s +} + function handleAiDrop(e: DragEvent) { isDragging.value = false const files = e.dataTransfer?.files diff --git a/frontend/todo/index.vue b/frontend/todo/index.vue new file mode 100644 index 0000000..c9678df --- /dev/null +++ b/frontend/todo/index.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 87f6ac4..64d9a20 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -59,6 +59,10 @@ export default defineNuxtConfig({ dbUser: process.env.DB_USER || 'postgres', dbPassword: process.env.DB_PASSWORD || '', sessionSecret: process.env.SESSION_SECRET || 'dev-secret-key', + // Google OAuth + googleClientId: process.env.GOOGLE_CLIENT_ID || '', + googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || '', + googleRedirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:2026/api/auth/google/callback', public: { appName: '주간업무보고' }