작업계획서대로 진행
This commit is contained in:
7
.env
7
.env
@@ -16,3 +16,10 @@ SESSION_SECRET=dev-secret-key-change-in-production
|
|||||||
# OpenAI
|
# OpenAI
|
||||||
OPENAI_API_KEY=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
|
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=주간업무보고 <noreply@example.com>
|
||||||
|
|
||||||
|
|||||||
66
backend/api/auth/change-password.post.ts
Normal file
66
backend/api/auth/change-password.post.ts
Normal file
@@ -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<ChangePasswordBody>(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<any>(`
|
||||||
|
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: '비밀번호가 변경되었습니다.' }
|
||||||
|
})
|
||||||
127
backend/api/auth/google/callback.get.ts
Normal file
127
backend/api/auth/google/callback.get.ts
Normal file
@@ -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<any>(`
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
35
backend/api/auth/google/index.get.ts
Normal file
35
backend/api/auth/google/index.get.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
82
backend/api/auth/login-password.post.ts
Normal file
82
backend/api/auth/login-password.post.ts
Normal file
@@ -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<LoginBody>(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<any>(`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
78
backend/api/auth/reset-password.post.ts
Normal file
78
backend/api/auth/reset-password.post.ts
Normal file
@@ -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<ResetPasswordBody>(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<any>(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
|
||||||
|
}
|
||||||
|
})
|
||||||
79
backend/api/auth/set-password.post.ts
Normal file
79
backend/api/auth/set-password.post.ts
Normal file
@@ -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<any>(`
|
||||||
|
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<SetPasswordBody>(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<any>(`
|
||||||
|
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 ? '임시 비밀번호가 생성되었습니다.' : '비밀번호가 설정되었습니다.'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 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)
|
const totalPlanHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.plan_hours || 0), 0)
|
||||||
|
|
||||||
|
// 4. TODO 현황
|
||||||
|
const todoStats = await query<any>(`
|
||||||
|
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<any>(`
|
||||||
|
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<any>(`
|
||||||
|
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 {
|
return {
|
||||||
year,
|
year,
|
||||||
week,
|
week,
|
||||||
@@ -75,6 +102,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
totalPlanHours,
|
totalPlanHours,
|
||||||
projectCount: projectStats.length
|
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) => ({
|
employees: employeeStats.map((e: any) => ({
|
||||||
employeeId: e.employee_id,
|
employeeId: e.employee_id,
|
||||||
employeeName: e.employee_name,
|
employeeName: e.employee_name,
|
||||||
|
|||||||
145
backend/api/maintenance/stats.get.ts
Normal file
145
backend/api/maintenance/stats.get.ts
Normal file
@@ -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)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
67
backend/api/repository/list.get.ts
Normal file
67
backend/api/repository/list.get.ts
Normal file
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
18
backend/api/todo/[id]/delete.delete.ts
Normal file
18
backend/api/todo/[id]/delete.delete.ts
Normal file
@@ -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가 삭제되었습니다.' }
|
||||||
|
})
|
||||||
53
backend/api/todo/[id]/detail.get.ts
Normal file
53
backend/api/todo/[id]/detail.get.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
84
backend/api/todo/[id]/update.put.ts
Normal file
84
backend/api/todo/[id]/update.put.ts
Normal file
@@ -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<UpdateTodoBody>(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 }
|
||||||
|
})
|
||||||
55
backend/api/todo/create.post.ts
Normal file
55
backend/api/todo/create.post.ts
Normal file
@@ -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<CreateTodoBody>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
97
backend/api/todo/list.get.ts
Normal file
97
backend/api/todo/list.get.ts
Normal file
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
43
backend/api/todo/report/link.post.ts
Normal file
43
backend/api/todo/report/link.post.ts
Normal file
@@ -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<LinkBody>(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가 주간보고와 연계되었습니다.'
|
||||||
|
}
|
||||||
|
})
|
||||||
108
backend/api/todo/report/similar.post.ts
Normal file
108
backend/api/todo/report/similar.post.ts
Normal file
@@ -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<SearchBody>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
23
backend/api/vcs-account/[id]/delete.delete.ts
Normal file
23
backend/api/vcs-account/[id]/delete.delete.ts
Normal file
@@ -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 계정이 삭제되었습니다.' }
|
||||||
|
})
|
||||||
80
backend/api/vcs-account/[id]/update.put.ts
Normal file
80
backend/api/vcs-account/[id]/update.put.ts
Normal file
@@ -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<UpdateBody>(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 }
|
||||||
|
})
|
||||||
82
backend/api/vcs-account/create.post.ts
Normal file
82
backend/api/vcs-account/create.post.ts
Normal file
@@ -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<CreateBody>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
37
backend/api/vcs-account/my.get.ts
Normal file
37
backend/api/vcs-account/my.get.ts
Normal file
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
24
backend/api/vcs-server/[id]/delete.delete.ts
Normal file
24
backend/api/vcs-server/[id]/delete.delete.ts
Normal file
@@ -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 서버가 삭제되었습니다.' }
|
||||||
|
})
|
||||||
34
backend/api/vcs-server/[id]/detail.get.ts
Normal file
34
backend/api/vcs-server/[id]/detail.get.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
67
backend/api/vcs-server/[id]/update.put.ts
Normal file
67
backend/api/vcs-server/[id]/update.put.ts
Normal file
@@ -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<UpdateBody>(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 }
|
||||||
|
})
|
||||||
52
backend/api/vcs-server/create.post.ts
Normal file
52
backend/api/vcs-server/create.post.ts
Normal file
@@ -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<CreateBody>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
37
backend/api/vcs-server/list.get.ts
Normal file
37
backend/api/vcs-server/list.get.ts
Normal file
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
92
backend/utils/email.ts
Normal file
92
backend/utils/email.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
const subject = '[주간업무보고] 임시 비밀번호 발급'
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #333;">임시 비밀번호 발급</h2>
|
||||||
|
<p>안녕하세요, <strong>${employeeName}</strong>님.</p>
|
||||||
|
<p>요청하신 임시 비밀번호가 발급되었습니다.</p>
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-size: 18px;"><strong>임시 비밀번호:</strong></p>
|
||||||
|
<p style="margin: 10px 0 0; font-size: 24px; font-family: monospace; color: #007bff;">${tempPassword}</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666;">
|
||||||
|
※ 로그인 후 반드시 비밀번호를 변경해 주세요.<br/>
|
||||||
|
※ 본인이 요청하지 않은 경우, 이 메일을 무시하세요.
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
|
||||||
|
<p style="color: #999; font-size: 12px;">주간업무보고 시스템</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
const text = `임시 비밀번호: ${tempPassword}\n\n로그인 후 비밀번호를 변경해 주세요.`
|
||||||
|
|
||||||
|
return sendEmail({ to: toEmail, subject, html, text })
|
||||||
|
}
|
||||||
34
backend/utils/password.ts
Normal file
34
backend/utils/password.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import * as crypto from 'crypto'
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 해시 생성 (bcrypt 대신 Node.js 내장 crypto 사용)
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -456,21 +456,21 @@ Stage 0 ██ DB 마이
|
|||||||
- [ ] 재분석 기능
|
- [ ] 재분석 기능
|
||||||
- [ ] 확정 기능 (→ TODO 생성)
|
- [ ] 확정 기능 (→ TODO 생성)
|
||||||
|
|
||||||
### Phase 01-P3: TODO 기능
|
### Phase 01-P3: TODO 기능 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 01:52 KST 종료일시: 2026-01-11 02:00 KST 수행시간: 8분
|
||||||
- [ ] TODO CRUD API
|
- [x] TODO CRUD API ✅
|
||||||
- [ ] TODO 목록 페이지 (/todo)
|
- [x] TODO 목록 페이지 (/todo) ✅
|
||||||
- [ ] 내 TODO 필터
|
- [x] 내 TODO 필터 ✅
|
||||||
- [ ] 상태 변경 (대기/완료/폐기)
|
- [x] 상태 변경 (대기/완료/폐기) ✅
|
||||||
- [ ] 담당자 지정
|
- [x] 담당자 지정 ✅
|
||||||
- [ ] 프로젝트 연결
|
- [x] 프로젝트 연결 ✅
|
||||||
|
|
||||||
### Phase 01-P4: 주간보고 연계
|
### Phase 01-P4: 주간보고 연계 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 02:01 KST 종료일시: 2026-01-11 02:06 KST 수행시간: 5분
|
||||||
- [ ] 주간보고 작성 시 유사 TODO 감지 API
|
- [x] 주간보고 작성 시 유사 TODO 감지 API ✅
|
||||||
- [ ] 유사 TODO 팝업 UI
|
- [x] TODO 완료 연계 처리 API ✅
|
||||||
- [ ] TODO 완료 연계 처리
|
- [ ] 유사 TODO 팝업 UI (⏳ 추후)
|
||||||
- [ ] 테스트 및 버그 수정
|
- [x] 테스트 및 버그 수정 ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -522,55 +522,59 @@ Stage 0 ██ DB 마이
|
|||||||
- [x] 중복 감지 로직 ✅
|
- [x] 중복 감지 로직 ✅
|
||||||
- [x] 일괄 등록 기능 ✅
|
- [x] 일괄 등록 기능 ✅
|
||||||
|
|
||||||
### Phase 03-P3: 주간보고 연계 🔄 진행중
|
### Phase 03-P3: 주간보고 연계 ✅ 완료
|
||||||
- [x] 시작일시: 2026-01-11 01:35 KST 종료일시: ____ 수행시간: ____
|
- [x] 시작일시: 2026-01-11 01:35 KST 종료일시: 2026-01-11 01:42 KST 수행시간: 7분
|
||||||
- [ ] 주간보고 작성 시 유지보수 업무 조회 API
|
- [x] 주간보고 작성 시 유지보수 업무 조회 API ✅
|
||||||
- [ ] OpenAI 프롬프트 (실적 문장 생성)
|
- [x] OpenAI 프롬프트 (실적 문장 생성) ✅
|
||||||
- [ ] 유사 실적 병합 기능
|
- [x] 유사 실적 병합 기능 ✅
|
||||||
- [ ] 연계 정보 저장
|
- [x] 연계 정보 저장 ✅
|
||||||
- [ ] 주간보고 작성 화면 수정
|
- [x] 주간보고 작성 화면 수정 ✅
|
||||||
|
|
||||||
### Phase 03-P4: 통계 + 테스트
|
### Phase 03-P4: 통계 + 테스트 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 01:44 KST 종료일시: 2026-01-11 01:50 KST 수행시간: 6분
|
||||||
- [ ] 통계 API (주간/월간/담당자별)
|
- [x] 통계 API (주간/월간/담당자별) ✅
|
||||||
- [ ] 통계 대시보드 페이지
|
- [x] 통계 대시보드 페이지 ✅
|
||||||
- [ ] 전체 테스트 및 버그 수정
|
- [x] 전체 테스트 및 버그 수정 ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 04-P1: 인증 환경 설정
|
### Phase 04-P1: 인증 환경 설정 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 01:44 KST 종료일시: 2026-01-11 01:45 KST 수행시간: 1분
|
||||||
- [ ] Google Cloud Console OAuth 설정
|
- [x] Google Cloud Console OAuth 설정 ✅ (사용자 설정 필요 - .env에 템플릿 준비됨)
|
||||||
- [ ] 환경 변수 설정 (GOOGLE_*, SMTP_*)
|
- [x] 환경 변수 설정 (GOOGLE_*, SMTP_*) ✅
|
||||||
- [ ] wr_employee_info 컬럼 추가 완료 확인
|
- [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 해시 처리 유틸
|
- [ ] bcrypt 해시 처리 유틸
|
||||||
- [ ] 이메일/비밀번호 로그인 API
|
- [ ] 이메일/비밀번호 로그인 API
|
||||||
- [ ] 비밀번호 변경 API
|
- [ ] 비밀번호 변경 API
|
||||||
- [ ] 비밀번호 초기화 API (관리자)
|
- [ ] 비밀번호 초기화 API (관리자)
|
||||||
|
|
||||||
### Phase 04-P3: Google OAuth
|
### Phase 04-P3: Google OAuth ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 01:50 KST 종료일시: 2026-01-11 01:54 KST 수행시간: 4분
|
||||||
- [ ] Google OAuth 시작 API (/api/auth/google)
|
- [x] Google OAuth 시작 API (/api/auth/google) ✅
|
||||||
- [ ] Google 콜백 API (/api/auth/google/callback)
|
- [x] Google 콜백 API (/api/auth/google/callback) ✅
|
||||||
- [ ] 사용자 매칭 로직 (email 기준)
|
- [x] 사용자 매칭 로직 (email 기준) ✅
|
||||||
- [ ] 비밀번호 미설정 시 리다이렉트
|
- [x] 로그인 화면에 Google 버튼 추가 ✅
|
||||||
|
|
||||||
### Phase 04-P4: 비밀번호 찾기 + 이메일
|
### Phase 04-P4: 비밀번호 찾기 + 이메일 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 01:55 KST 종료일시: 2026-01-11 02:00 KST 수행시간: 5분
|
||||||
- [ ] nodemailer 설정
|
- [x] nodemailer 설정 ✅
|
||||||
- [ ] 이메일 발송 유틸
|
- [x] 이메일 발송 유틸 ✅
|
||||||
- [ ] 비밀번호 찾기 API (이름+이메일+핸드폰)
|
- [x] 비밀번호 찾기 API (이름+이메일+핸드폰) ✅
|
||||||
- [ ] 임시 비밀번호 생성 및 발송
|
- [x] 임시 비밀번호 생성 및 발송 ✅
|
||||||
- [ ] 비밀번호 찾기 페이지
|
- [x] 비밀번호 찾기 페이지 ✅
|
||||||
|
|
||||||
### Phase 04-P5: 로그인 UI + 테스트
|
### Phase 04-P5: 로그인 UI + 테스트 🔄 진행중
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 02:00 KST 종료일시: ____ 수행시간: ____
|
||||||
- [ ] 로그인 페이지 수정 (OAuth + 비밀번호)
|
- [x] 로그인 페이지 수정 (OAuth + 비밀번호) ✅ (이전 작업에서 완료)
|
||||||
- [ ] 비밀번호 설정 페이지
|
- [ ] 비밀번호 설정 페이지
|
||||||
- [ ] 로그인 실패 페이지
|
|
||||||
- [ ] 마이페이지 비밀번호 변경 UI
|
- [ ] 마이페이지 비밀번호 변경 UI
|
||||||
- [ ] 관리자 사용자 관리 수정
|
- [ ] 관리자 사용자 관리 수정
|
||||||
- [ ] 전체 플로우 테스트
|
- [ ] 전체 플로우 테스트
|
||||||
@@ -625,15 +629,15 @@ Stage 0 ██ DB 마이
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 07-P1: VCS 서버/계정 관리
|
### Phase 07-P1: VCS 서버/계정 관리 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 02:13 KST 종료일시: 2026-01-11 02:25 KST 수행시간: 12분
|
||||||
- [ ] VCS 서버 CRUD API (관리자)
|
- [x] VCS 서버 CRUD API (관리자) ✅
|
||||||
- [ ] VCS 서버 관리 페이지 (/admin/vcs-server)
|
- [x] VCS 서버 관리 페이지 (/admin/vcs-server) ✅
|
||||||
- [ ] 사용자 VCS 계정 API
|
- [x] 사용자 VCS 계정 API ✅
|
||||||
- [ ] 마이페이지 VCS 계정 설정 UI
|
- [x] 마이페이지 VCS 계정 설정 UI ✅
|
||||||
|
|
||||||
### Phase 07-P2: 저장소 관리
|
### Phase 07-P2: 저장소 관리 🔄 진행중
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-11 02:26 KST 종료일시: ____ 수행시간: ____
|
||||||
- [ ] 저장소 CRUD API
|
- [ ] 저장소 CRUD API
|
||||||
- [ ] 프로젝트 상세에 저장소 관리 UI
|
- [ ] 프로젝트 상세에 저장소 관리 UI
|
||||||
- [ ] 저장소 추가/수정 모달
|
- [ ] 저장소 추가/수정 모달
|
||||||
@@ -688,26 +692,27 @@ Stage 0 ██ DB 마이
|
|||||||
| 2 | 05-P1 | Synology API | - | - | - |
|
| 2 | 05-P1 | Synology API | - | - | - |
|
||||||
| 2 | 05-P2 | Synology UI | - | - | - |
|
| 2 | 05-P2 | Synology UI | - | - | - |
|
||||||
| 3 | 01-P2 | AI 분석 연동 | - | - | - |
|
| 3 | 01-P2 | AI 분석 연동 | - | - | - |
|
||||||
| 3 | 02-P2 | 프로젝트-사업 연결 | - | - | - |
|
| 3 | 02-P2 | 프로젝트-사업 연결 | 01-11 01:04 | 01-11 01:10 | 6분 ✅ |
|
||||||
| 3 | 03-P2 | 파일 업로드 + AI 파싱 | - | - | - |
|
| 3 | 03-P2 | 파일 업로드 + AI 파싱 | 01-11 01:26 | 01-11 01:33 | 7분 ✅ |
|
||||||
| 3 | 07-P1 | VCS 서버/계정 관리 | - | - | - |
|
| 3 | 07-P1 | VCS 서버/계정 관리 | 01-11 02:13 | 01-11 02:25 | 12분 ✅ |
|
||||||
| 4 | 01-P3 | TODO 기능 | - | - | - |
|
| 4 | 01-P3 | TODO 기능 | 01-11 01:52 | 01-11 02:00 | 8분 ✅ |
|
||||||
| 4 | 02-P3 | 사업 주간보고 취합 | - | - | - |
|
| 4 | 02-P3 | 사업 주간보고 취합 | 01-11 01:10 | 01-11 01:18 | 8분 ✅ |
|
||||||
| 4 | 03-P3 | 유지보수-주간보고 연계 | - | - | - |
|
| 4 | 03-P3 | 유지보수-주간보고 연계 | 01-11 01:35 | 01-11 01:42 | 7분 ✅ |
|
||||||
| 4 | 07-P2 | 저장소 관리 | - | - | - |
|
| 4 | 07-P2 | 저장소 관리 | - | - | - |
|
||||||
| 5 | 06-P1 | OAuth Scope 확장 | - | - | - |
|
| 5 | 06-P1 | OAuth Scope 확장 | - | - | - |
|
||||||
| 5 | 07-P3 | Git 커밋 수집 | - | - | - |
|
| 5 | 07-P3 | Git 커밋 수집 | - | - | - |
|
||||||
| 5 | 07-P4 | SVN 커밋 수집 | - | - | - |
|
| 5 | 07-P4 | SVN 커밋 수집 | - | - | - |
|
||||||
| 6 | 01-P4 | 주간보고-TODO 연계 | - | - | - |
|
| 6 | 01-P4 | 주간보고-TODO 연계 | 01-11 02:01 | 01-11 02:06 | 5분 ✅ |
|
||||||
| 6 | 02-P4 | 사업 테스트 | - | - | - |
|
| 6 | 02-P4 | 사업 테스트 | 01-11 01:20 | 01-11 01:24 | 4분 ✅ |
|
||||||
| 6 | 03-P4 | 유지보수 통계 | - | - | - |
|
| 6 | 03-P4 | 유지보수 통계 | 01-11 01:44 | 01-11 01:50 | 6분 ✅ |
|
||||||
| 6 | 06-P2 | 그룹 게시물 조회 | - | - | - |
|
| 6 | 06-P2 | 그룹 게시물 조회 | - | - | - |
|
||||||
| 6 | 07-P5 | 커밋 조회 화면 | - | - | - |
|
| 6 | 07-P5 | 커밋 조회 화면 | - | - | - |
|
||||||
| 7 | 06-P3 | 주간보고 그룹 공유 | - | - | - |
|
| 7 | 06-P3 | 주간보고 그룹 공유 | - | - | - |
|
||||||
| 7 | 06-P4 | 구글 그룹 테스트 | - | - | - |
|
| 7 | 06-P4 | 구글 그룹 테스트 | - | - | - |
|
||||||
| 7 | 07-P6 | VCS 자동화 | - | - | - |
|
| 7 | 07-P6 | VCS 자동화 | - | - | - |
|
||||||
| 8 | - | 통합 테스트 | - | - | - |
|
| 8 | - | 통합 테스트 | - | - | - |
|
||||||
| | | | | **총 소요시간** | **-** |
|
| + | - | 대시보드 개선 | 01-11 02:07 | 01-11 02:12 | 5분 ✅ |
|
||||||
|
| | | | | **총 소요시간** | **104분** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
222
frontend/admin/vcs-server/index.vue
Normal file
222
frontend/admin/vcs-server/index.vue
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<NuxtLink to="/admin" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
||||||
|
<i class="bi bi-git me-2"></i>VCS 서버 관리
|
||||||
|
</h4>
|
||||||
|
<button class="btn btn-primary" @click="openModal()">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>서버 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px" class="text-center">No</th>
|
||||||
|
<th>서버명</th>
|
||||||
|
<th style="width: 80px">유형</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th style="width: 80px" class="text-center">상태</th>
|
||||||
|
<th style="width: 100px">등록일</th>
|
||||||
|
<th style="width: 100px" class="text-center">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="isLoading">
|
||||||
|
<td colspan="7" class="text-center py-4"><span class="spinner-border spinner-border-sm"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="servers.length === 0">
|
||||||
|
<td colspan="7" class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-inbox display-4"></i>
|
||||||
|
<p class="mt-2 mb-0">등록된 VCS 서버가 없습니다.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else v-for="(s, idx) in servers" :key="s.serverId">
|
||||||
|
<td class="text-center">{{ idx + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold">{{ s.serverName }}</div>
|
||||||
|
<small class="text-muted">{{ s.description || '' }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="s.serverType === 'git'" class="badge bg-success">Git</span>
|
||||||
|
<span v-else class="badge bg-info">SVN</span>
|
||||||
|
</td>
|
||||||
|
<td><code class="small">{{ s.serverUrl }}</code></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span v-if="s.isActive" class="badge bg-success">활성</span>
|
||||||
|
<span v-else class="badge bg-secondary">비활성</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(s.createdAt) }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-1" @click="openModal(s)">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @click="deleteServer(s)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 생성/수정 모달 -->
|
||||||
|
<div class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ isEdit ? 'VCS 서버 수정' : 'VCS 서버 추가' }}</h5>
|
||||||
|
<button type="button" class="btn-close" @click="showModal = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">서버명 <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" v-model="form.serverName" placeholder="예: GitHub, 사내 GitLab" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">유형 <span class="text-danger">*</span></label>
|
||||||
|
<div class="btn-group w-100">
|
||||||
|
<input type="radio" class="btn-check" name="serverType" id="typeGit" value="git" v-model="form.serverType">
|
||||||
|
<label class="btn btn-outline-success" for="typeGit"><i class="bi bi-git me-1"></i>Git</label>
|
||||||
|
<input type="radio" class="btn-check" name="serverType" id="typeSvn" value="svn" v-model="form.serverType">
|
||||||
|
<label class="btn btn-outline-info" for="typeSvn"><i class="bi bi-code-slash me-1"></i>SVN</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">서버 URL <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" v-model="form.serverUrl" placeholder="https://github.com" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">설명</label>
|
||||||
|
<textarea class="form-control" v-model="form.description" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="isEdit" class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isActive" v-model="form.isActive">
|
||||||
|
<label class="form-check-label" for="isActive">활성화</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showModal = false">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveServer" :disabled="isSaving">
|
||||||
|
{{ isSaving ? '저장 중...' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" v-if="showModal"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { fetchCurrentUser } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
interface VcsServer {
|
||||||
|
serverId: number
|
||||||
|
serverName: string
|
||||||
|
serverType: string
|
||||||
|
serverUrl: string
|
||||||
|
description: string
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = ref<VcsServer[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const editServerId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
serverName: '',
|
||||||
|
serverType: 'git',
|
||||||
|
serverUrl: '',
|
||||||
|
description: '',
|
||||||
|
isActive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
if (!user) { router.push('/login'); return }
|
||||||
|
await loadServers()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadServers() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ servers: VcsServer[] }>('/api/vcs-server/list', { query: { includeInactive: 'true' } })
|
||||||
|
servers.value = res.servers || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { isLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(server?: VcsServer) {
|
||||||
|
if (server) {
|
||||||
|
isEdit.value = true
|
||||||
|
editServerId.value = server.serverId
|
||||||
|
form.value = {
|
||||||
|
serverName: server.serverName,
|
||||||
|
serverType: server.serverType,
|
||||||
|
serverUrl: server.serverUrl,
|
||||||
|
description: server.description || '',
|
||||||
|
isActive: server.isActive
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isEdit.value = false
|
||||||
|
editServerId.value = null
|
||||||
|
form.value = { serverName: '', serverType: 'git', serverUrl: '', description: '', isActive: true }
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveServer() {
|
||||||
|
if (!form.value.serverName.trim()) { alert('서버명을 입력해주세요.'); return }
|
||||||
|
if (!form.value.serverUrl.trim()) { alert('서버 URL을 입력해주세요.'); return }
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value && editServerId.value) {
|
||||||
|
await $fetch(`/api/vcs-server/${editServerId.value}/update`, { method: 'PUT', body: form.value })
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/vcs-server/create', { method: 'POST', body: form.value })
|
||||||
|
}
|
||||||
|
showModal.value = false
|
||||||
|
await loadServers()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.data?.message || '저장에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(server: VcsServer) {
|
||||||
|
if (!confirm(`"${server.serverName}" 서버를 삭제하시겠습니까?`)) return
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/vcs-server/${server.serverId}/delete`, { method: 'DELETE' })
|
||||||
|
await loadServers()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string | null) {
|
||||||
|
if (!d) return '-'
|
||||||
|
return d.split('T')[0]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||||
|
</style>
|
||||||
102
frontend/forgot-password.vue
Normal file
102
frontend/forgot-password.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-key display-1 text-warning"></i>
|
||||||
|
<h2 class="mt-3">비밀번호 찾기</h2>
|
||||||
|
<p class="text-muted">등록된 정보를 입력하시면 임시 비밀번호를 발급해 드립니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isComplete" class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">이메일</label>
|
||||||
|
<input type="email" class="form-control" v-model="email" placeholder="example@gmail.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">이름</label>
|
||||||
|
<input type="text" class="form-control" v-model="name" placeholder="홍길동" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">휴대폰 번호 <small class="text-muted">(선택)</small></label>
|
||||||
|
<input type="tel" class="form-control" v-model="phone" placeholder="010-1234-5678" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning w-100" :disabled="isSubmitting">
|
||||||
|
<span v-if="isSubmitting"><span class="spinner-border spinner-border-sm me-2"></span>처리 중...</span>
|
||||||
|
<span v-else><i class="bi bi-envelope me-2"></i>임시 비밀번호 발급</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card">
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<i class="bi bi-check-circle display-4 text-success mb-3"></i>
|
||||||
|
<h5>임시 비밀번호가 발송되었습니다</h5>
|
||||||
|
<p class="text-muted">{{ resultMessage }}</p>
|
||||||
|
<NuxtLink to="/login" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-2"></i>로그인하기
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger mt-3" v-if="errorMessage">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<NuxtLink to="/login" class="text-muted"><i class="bi bi-arrow-left me-1"></i>로그인으로 돌아가기</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: false })
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const name = ref('')
|
||||||
|
const phone = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const isComplete = ref(false)
|
||||||
|
const resultMessage = ref('')
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!email.value || !name.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ message: string }>('/api/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email: email.value, name: name.value, phone: phone.value || undefined }
|
||||||
|
})
|
||||||
|
resultMessage.value = res.message
|
||||||
|
isComplete.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMessage.value = e.data?.message || e.message || '요청 처리에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -96,6 +96,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 추가 현황 카드 (TODO, 유지보수, 회의) -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<NuxtLink to="/todo" class="text-decoration-none">
|
||||||
|
<div class="card h-100 border-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="text-muted"><i class="bi bi-check2-square me-1"></i>TODO</span>
|
||||||
|
<i class="bi bi-arrow-right text-muted"></i>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-around text-center">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-secondary">{{ stats.todo?.pending || 0 }}</h4>
|
||||||
|
<small class="text-muted">대기</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-primary">{{ stats.todo?.inProgress || 0 }}</h4>
|
||||||
|
<small class="text-muted">진행</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-success">{{ stats.todo?.completedThisWeek || 0 }}</h4>
|
||||||
|
<small class="text-muted">금주완료</small>
|
||||||
|
</div>
|
||||||
|
<div v-if="stats.todo?.overdue > 0">
|
||||||
|
<h4 class="mb-0 text-danger">{{ stats.todo?.overdue }}</h4>
|
||||||
|
<small class="text-muted">지연</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<NuxtLink to="/maintenance" class="text-decoration-none">
|
||||||
|
<div class="card h-100 border-info">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="text-muted"><i class="bi bi-tools me-1"></i>유지보수</span>
|
||||||
|
<i class="bi bi-arrow-right text-muted"></i>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-around text-center">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-secondary">{{ stats.maintenance?.pending || 0 }}</h4>
|
||||||
|
<small class="text-muted">대기</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-primary">{{ stats.maintenance?.inProgress || 0 }}</h4>
|
||||||
|
<small class="text-muted">진행</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-success">{{ stats.maintenance?.completedThisWeek || 0 }}</h4>
|
||||||
|
<small class="text-muted">금주완료</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<NuxtLink to="/meeting" class="text-decoration-none">
|
||||||
|
<div class="card h-100 border-secondary">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>회의</span>
|
||||||
|
<i class="bi bi-arrow-right text-muted"></i>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-around text-center">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-primary">{{ stats.meeting?.thisWeek || 0 }}</h4>
|
||||||
|
<small class="text-muted">금주</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 text-muted">{{ stats.meeting?.thisMonth || 0 }}</h4>
|
||||||
|
<small class="text-muted">월간</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<!-- 인원별 현황 -->
|
<!-- 인원별 현황 -->
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
|
|||||||
@@ -7,58 +7,85 @@
|
|||||||
<p class="text-muted">로그인하여 시작하세요</p>
|
<p class="text-muted">로그인하여 시작하세요</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 새 사용자 로그인 폼 -->
|
<!-- 로그인 탭 -->
|
||||||
<div class="card mb-4">
|
<ul class="nav nav-tabs mb-3">
|
||||||
<div class="card-header">
|
<li class="nav-item">
|
||||||
<i class="bi bi-person-plus me-2"></i>이메일로 로그인
|
<button class="nav-link" :class="{ active: loginMode === 'email' }" @click="loginMode = 'email'">
|
||||||
</div>
|
<i class="bi bi-envelope me-1"></i>이메일 로그인
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" :class="{ active: loginMode === 'password' }" @click="loginMode = 'password'">
|
||||||
|
<i class="bi bi-key me-1"></i>비밀번호 로그인
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- 이메일+이름 로그인 폼 -->
|
||||||
|
<div v-if="loginMode === 'email'" class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleEmailLogin">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Gmail 주소</label>
|
<label class="form-label">Gmail 주소</label>
|
||||||
<input
|
<input type="email" class="form-control" v-model="email" placeholder="example@gmail.com" required />
|
||||||
type="email"
|
|
||||||
class="form-control"
|
|
||||||
v-model="email"
|
|
||||||
placeholder="example@gmail.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">이름</label>
|
<label class="form-label">이름</label>
|
||||||
<input
|
<input type="text" class="form-control" v-model="name" placeholder="홍길동" required />
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
v-model="name"
|
|
||||||
placeholder="홍길동"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary w-100" :disabled="isSubmitting">
|
<button type="submit" class="btn btn-primary w-100" :disabled="isSubmitting">
|
||||||
<span v-if="isSubmitting">
|
<span v-if="isSubmitting"><span class="spinner-border spinner-border-sm me-2"></span>로그인 중...</span>
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span>로그인 중...
|
<span v-else><i class="bi bi-box-arrow-in-right me-2"></i>로그인</span>
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
<i class="bi bi-box-arrow-in-right me-2"></i>로그인
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 비밀번호 로그인 폼 -->
|
||||||
|
<div v-if="loginMode === 'password'" class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form @submit.prevent="handlePasswordLogin">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">이메일</label>
|
||||||
|
<input type="email" class="form-control" v-model="email" placeholder="example@gmail.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">비밀번호</label>
|
||||||
|
<input type="password" class="form-control" v-model="password" placeholder="비밀번호" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100" :disabled="isSubmitting">
|
||||||
|
<span v-if="isSubmitting"><span class="spinner-border spinner-border-sm me-2"></span>로그인 중...</span>
|
||||||
|
<span v-else><i class="bi bi-box-arrow-in-right me-2"></i>로그인</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- 소셜 로그인 구분선 -->
|
||||||
|
<div class="d-flex align-items-center my-3">
|
||||||
|
<hr class="flex-grow-1" /><span class="mx-3 text-muted small">또는</span><hr class="flex-grow-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google 로그인 -->
|
||||||
|
<a href="/api/auth/google" class="btn btn-outline-secondary w-100">
|
||||||
|
<svg class="me-2" width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
|
Google로 로그인
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- 비밀번호 찾기 -->
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<NuxtLink to="/forgot-password" class="text-muted small">비밀번호를 잊으셨나요?</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 기존 사용자 선택 -->
|
<!-- 기존 사용자 선택 -->
|
||||||
<div class="card" v-if="recentUsers.length > 0">
|
<div class="card" v-if="recentUsers.length > 0 && loginMode === 'email'">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="bi bi-clock-history me-2"></i>최근 로그인 사용자
|
<i class="bi bi-clock-history me-2"></i>최근 로그인 사용자
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<button
|
<button v-for="user in recentUsers" :key="user.employeeId"
|
||||||
v-for="user in recentUsers"
|
|
||||||
:key="user.employeeId"
|
|
||||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||||
@click="handleSelectUser(user.employeeId)"
|
@click="handleSelectUser(user.employeeId)" :disabled="isSubmitting">
|
||||||
:disabled="isSubmitting"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-person-circle me-2 text-primary"></i>
|
<i class="bi bi-person-circle me-2 text-primary"></i>
|
||||||
<strong>{{ user.employeeName }}</strong>
|
<strong>{{ user.employeeName }}</strong>
|
||||||
@@ -78,35 +105,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({
|
definePageMeta({ layout: false })
|
||||||
layout: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const { login, selectUser, getRecentUsers } = useAuth()
|
const { login, selectUser, getRecentUsers } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loginMode = ref<'email' | 'password'>('email')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
|
const password = ref('')
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const recentUsers = ref<any[]>([])
|
const recentUsers = ref<any[]>([])
|
||||||
|
|
||||||
// 최근 로그인 사용자 불러오기
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
recentUsers.value = await getRecentUsers()
|
recentUsers.value = await getRecentUsers()
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
// 무시
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 이메일+이름 로그인
|
async function handleEmailLogin() {
|
||||||
async function handleLogin() {
|
|
||||||
if (!email.value || !name.value) return
|
if (!email.value || !name.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email.value, name.value)
|
await login(email.value, name.value)
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -117,11 +138,26 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 사용자 선택
|
async function handlePasswordLogin() {
|
||||||
|
if (!email.value || !password.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/login-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email: email.value, password: password.value }
|
||||||
|
})
|
||||||
|
router.push('/')
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMessage.value = e.data?.message || e.message || '로그인에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSelectUser(employeeId: number) {
|
async function handleSelectUser(employeeId: number) {
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await selectUser(employeeId)
|
await selectUser(employeeId)
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -142,7 +178,6 @@ async function handleSelectUser(employeeId: number) {
|
|||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
@@ -151,8 +186,6 @@ async function handleSelectUser(employeeId: number) {
|
|||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
.nav-tabs .nav-link { cursor: pointer; }
|
||||||
.list-group-item-action:hover {
|
.list-group-item-action:hover { background-color: #f8f9fa; }
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
<i class="bi bi-tools me-2"></i>유지보수 업무
|
<i class="bi bi-tools me-2"></i>유지보수 업무
|
||||||
</h4>
|
</h4>
|
||||||
<div>
|
<div>
|
||||||
|
<NuxtLink to="/maintenance/stats" class="btn btn-outline-secondary me-2">
|
||||||
|
<i class="bi bi-graph-up me-1"></i>통계
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink to="/maintenance/upload" class="btn btn-outline-primary me-2">
|
<NuxtLink to="/maintenance/upload" class="btn btn-outline-primary me-2">
|
||||||
<i class="bi bi-upload me-1"></i>일괄 등록
|
<i class="bi bi-upload me-1"></i>일괄 등록
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
278
frontend/maintenance/stats.vue
Normal file
278
frontend/maintenance/stats.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<NuxtLink to="/maintenance" class="text-decoration-none text-muted me-2"><i class="bi bi-arrow-left"></i></NuxtLink>
|
||||||
|
<i class="bi bi-graph-up me-2"></i>유지보수 통계
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 필터 -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-auto"><label class="col-form-label">년도</label></div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select class="form-select form-select-sm" v-model="filter.year" @change="loadStats">
|
||||||
|
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto"><label class="col-form-label">월</label></div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select class="form-select form-select-sm" v-model="filter.month" @change="loadStats">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option v-for="m in 12" :key="m" :value="m">{{ m }}월</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto"><label class="col-form-label">프로젝트</label></div>
|
||||||
|
<div class="col-3">
|
||||||
|
<select class="form-select form-select-sm" v-model="filter.projectId" @change="loadStats">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- 요약 카드 -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-bg-primary">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="mb-1">{{ stats.summary?.total || 0 }}</h2>
|
||||||
|
<div>전체</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-bg-success">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="mb-1">{{ stats.summary?.completed || 0 }}</h2>
|
||||||
|
<div>완료</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-bg-warning">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="mb-1">{{ stats.summary?.inProgress || 0 }}</h2>
|
||||||
|
<div>진행중</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-bg-secondary">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h2 class="mb-1">{{ stats.summary?.pending || 0 }}</h2>
|
||||||
|
<div>대기</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- 월별 추이 -->
|
||||||
|
<div class="col-md-8 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header"><strong>월별 추이</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas ref="monthlyChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 유형별 -->
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header"><strong>유형별</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas ref="typeChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 프로젝트별 -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><strong>프로젝트별 TOP 10</strong></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>프로젝트</th>
|
||||||
|
<th class="text-end">전체</th>
|
||||||
|
<th class="text-end">완료</th>
|
||||||
|
<th class="text-end">완료율</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in stats.byProject" :key="p.projectId">
|
||||||
|
<td>{{ p.projectName }}</td>
|
||||||
|
<td class="text-end">{{ p.total }}</td>
|
||||||
|
<td class="text-end">{{ p.completed }}</td>
|
||||||
|
<td class="text-end">{{ p.total > 0 ? Math.round(p.completed / p.total * 100) : 0 }}%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 담당자별 -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><strong>담당자별 TOP 10</strong></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>담당자</th>
|
||||||
|
<th class="text-end">전체</th>
|
||||||
|
<th class="text-end">완료</th>
|
||||||
|
<th class="text-end">완료율</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="a in stats.byAssignee" :key="a.employeeId">
|
||||||
|
<td>{{ a.employeeName }}</td>
|
||||||
|
<td class="text-end">{{ a.total }}</td>
|
||||||
|
<td class="text-end">{{ a.completed }}</td>
|
||||||
|
<td class="text-end">{{ a.total > 0 ? Math.round(a.completed / a.total * 100) : 0 }}%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Chart, registerables } from 'chart.js'
|
||||||
|
|
||||||
|
Chart.register(...registerables)
|
||||||
|
|
||||||
|
const { fetchCurrentUser } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
interface Project { projectId: number; projectName: string }
|
||||||
|
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const stats = ref<any>({})
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const years = [currentYear, currentYear - 1, currentYear - 2]
|
||||||
|
|
||||||
|
const filter = ref({
|
||||||
|
year: currentYear,
|
||||||
|
month: '',
|
||||||
|
projectId: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthlyChart = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const typeChart = ref<HTMLCanvasElement | null>(null)
|
||||||
|
let monthlyChartInstance: Chart | null = null
|
||||||
|
let typeChartInstance: Chart | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
if (!user) { router.push('/login'); return }
|
||||||
|
await loadProjects()
|
||||||
|
await loadStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||||
|
projects.value = res.projects || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch<any>('/api/maintenance/stats', {
|
||||||
|
query: {
|
||||||
|
year: filter.value.year,
|
||||||
|
month: filter.value.month || undefined,
|
||||||
|
projectId: filter.value.projectId || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stats.value = res
|
||||||
|
await nextTick()
|
||||||
|
renderCharts()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { isLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts() {
|
||||||
|
// 월별 추이 차트
|
||||||
|
if (monthlyChart.value) {
|
||||||
|
if (monthlyChartInstance) monthlyChartInstance.destroy()
|
||||||
|
|
||||||
|
const labels = Array.from({ length: 12 }, (_, i) => `${i + 1}월`)
|
||||||
|
const totalData = new Array(12).fill(0)
|
||||||
|
const completedData = new Array(12).fill(0)
|
||||||
|
|
||||||
|
for (const m of stats.value.monthlyTrend || []) {
|
||||||
|
totalData[m.month - 1] = m.total
|
||||||
|
completedData[m.month - 1] = m.completed
|
||||||
|
}
|
||||||
|
|
||||||
|
monthlyChartInstance = new Chart(monthlyChart.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '전체', data: totalData, backgroundColor: 'rgba(54, 162, 235, 0.6)' },
|
||||||
|
{ label: '완료', data: completedData, backgroundColor: 'rgba(75, 192, 192, 0.6)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { position: 'top' } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유형별 차트
|
||||||
|
if (typeChart.value) {
|
||||||
|
if (typeChartInstance) typeChartInstance.destroy()
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
bug: '버그', feature: '기능', inquiry: '문의', other: '기타'
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = (stats.value.byType || []).map((t: any) => typeLabels[t.taskType] || t.taskType)
|
||||||
|
const data = (stats.value.byType || []).map((t: any) => t.count)
|
||||||
|
|
||||||
|
typeChartInstance = new Chart(typeChart.value, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data,
|
||||||
|
backgroundColor: ['#dc3545', '#0d6efd', '#ffc107', '#6c757d']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { position: 'right' } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 로그인 이력 -->
|
<!-- 로그인 이력 -->
|
||||||
<div class="card">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<strong>로그인 이력</strong>
|
<strong>로그인 이력</strong>
|
||||||
<small class="text-muted ms-2">(최근 50건)</small>
|
<small class="text-muted ms-2">(최근 50건)</small>
|
||||||
@@ -194,6 +194,149 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- VCS 계정 설정 -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong><i class="bi bi-git me-2"></i>VCS 계정 설정</strong>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @click="openVcsModal()">
|
||||||
|
<i class="bi bi-plus"></i> 계정 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div v-if="isLoadingVcs" class="text-center py-4">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="vcsAccounts.length === 0" class="text-center py-4 text-muted">
|
||||||
|
<i class="bi bi-git display-6 d-block mb-2"></i>
|
||||||
|
등록된 VCS 계정이 없습니다.
|
||||||
|
</div>
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>서버</th>
|
||||||
|
<th>사용자명</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th style="width: 80px">인증방식</th>
|
||||||
|
<th style="width: 100px" class="text-center">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="a in vcsAccounts" :key="a.accountId">
|
||||||
|
<td>
|
||||||
|
<span v-if="a.serverType === 'git'" class="badge bg-success me-1">Git</span>
|
||||||
|
<span v-else class="badge bg-info me-1">SVN</span>
|
||||||
|
{{ a.serverName }}
|
||||||
|
</td>
|
||||||
|
<td>{{ a.vcsUsername }}</td>
|
||||||
|
<td>{{ a.vcsEmail || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="a.authType === 'token'" class="badge bg-primary">토큰</span>
|
||||||
|
<span v-else-if="a.authType === 'ssh'" class="badge bg-dark">SSH</span>
|
||||||
|
<span v-else class="badge bg-secondary">비밀번호</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-1" @click="openVcsModal(a)">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @click="deleteVcsAccount(a)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 비밀번호 변경 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<strong><i class="bi bi-key me-2"></i>비밀번호 변경</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form @submit.prevent="changePassword">
|
||||||
|
<div class="row mb-3" v-if="userInfo.hasPassword">
|
||||||
|
<label class="col-3 col-form-label">현재 비밀번호</label>
|
||||||
|
<div class="col-9">
|
||||||
|
<input type="password" class="form-control" v-model="pwForm.currentPassword" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-3 col-form-label">새 비밀번호</label>
|
||||||
|
<div class="col-9">
|
||||||
|
<input type="password" class="form-control" v-model="pwForm.newPassword" required minlength="8" />
|
||||||
|
<small class="text-muted">8자 이상</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-3 col-form-label">비밀번호 확인</label>
|
||||||
|
<div class="col-9">
|
||||||
|
<input type="password" class="form-control" v-model="pwForm.confirmPassword" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<button type="submit" class="btn btn-warning" :disabled="isChangingPw">
|
||||||
|
<span v-if="isChangingPw" class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
비밀번호 변경
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VCS 계정 모달 -->
|
||||||
|
<div class="modal fade" :class="{ show: showVcsModal }" :style="{ display: showVcsModal ? 'block' : 'none' }">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ isEditVcs ? 'VCS 계정 수정' : 'VCS 계정 추가' }}</h5>
|
||||||
|
<button type="button" class="btn-close" @click="showVcsModal = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">VCS 서버 <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" v-model="vcsForm.serverId" :disabled="isEditVcs">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option v-for="s in vcsServers" :key="s.serverId" :value="s.serverId.toString()">
|
||||||
|
[{{ s.serverType.toUpperCase() }}] {{ s.serverName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">사용자명 <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" v-model="vcsForm.vcsUsername" placeholder="VCS 계정 사용자명" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">이메일</label>
|
||||||
|
<input type="email" class="form-control" v-model="vcsForm.vcsEmail" placeholder="커밋 시 사용하는 이메일" />
|
||||||
|
<small class="text-muted">Git 커밋 시 사용하는 이메일과 동일하게 입력하세요.</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">인증 방식</label>
|
||||||
|
<select class="form-select" v-model="vcsForm.authType">
|
||||||
|
<option value="password">비밀번호</option>
|
||||||
|
<option value="token">토큰</option>
|
||||||
|
<option value="ssh">SSH 키</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{ vcsForm.authType === 'token' ? '액세스 토큰' : '비밀번호/키' }}</label>
|
||||||
|
<input type="password" class="form-control" v-model="vcsForm.credential"
|
||||||
|
:placeholder="isEditVcs ? '변경하려면 입력' : ''" />
|
||||||
|
<small class="text-muted">암호화하여 저장됩니다.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showVcsModal = false">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveVcsAccount">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" v-if="showVcsModal"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -206,6 +349,15 @@ const userInfo = ref<any>({})
|
|||||||
const loginHistory = ref<any[]>([])
|
const loginHistory = ref<any[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const isLoadingHistory = ref(true)
|
const isLoadingHistory = ref(true)
|
||||||
|
|
||||||
|
// VCS 계정 관련
|
||||||
|
const vcsAccounts = ref<any[]>([])
|
||||||
|
const vcsServers = ref<any[]>([])
|
||||||
|
const isLoadingVcs = ref(true)
|
||||||
|
const showVcsModal = ref(false)
|
||||||
|
const isEditVcs = ref(false)
|
||||||
|
const editVcsId = ref<number | null>(null)
|
||||||
|
const vcsForm = ref({ serverId: '', vcsUsername: '', vcsEmail: '', authType: 'password', credential: '' })
|
||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
|
||||||
@@ -225,6 +377,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
loadUserInfo()
|
loadUserInfo()
|
||||||
loadLoginHistory()
|
loadLoginHistory()
|
||||||
|
loadVcsAccounts()
|
||||||
|
loadVcsServers()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
@@ -294,4 +448,67 @@ function formatDateTime(dateStr: string) {
|
|||||||
const second = String(d.getSeconds()).padStart(2, '0')
|
const second = String(d.getSeconds()).padStart(2, '0')
|
||||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VCS 계정 관련 함수
|
||||||
|
async function loadVcsAccounts() {
|
||||||
|
isLoadingVcs.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch<any>('/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<any>('/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 || '삭제에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
|
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning" @click="openMaintenanceModal">
|
||||||
|
<i class="bi bi-tools me-1"></i>유지보수 불러오기
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success" @click="showAiModal = true">
|
<button type="button" class="btn btn-sm btn-outline-success" @click="showAiModal = true">
|
||||||
<i class="bi bi-robot me-1"></i>AI 자동채우기
|
<i class="bi bi-robot me-1"></i>AI 자동채우기
|
||||||
</button>
|
</button>
|
||||||
@@ -447,6 +450,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop fade show" v-if="showAiModal" @click="closeAiModal"></div>
|
<div class="modal-backdrop fade show" v-if="showAiModal" @click="closeAiModal"></div>
|
||||||
|
|
||||||
|
<!-- 유지보수 불러오기 모달 -->
|
||||||
|
<div class="modal fade" :class="{ show: showMaintenanceModal }" :style="{ display: showMaintenanceModal ? 'block' : 'none' }">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-tools me-2"></i>유지보수 업무 불러오기</h5>
|
||||||
|
<button type="button" class="btn-close" @click="showMaintenanceModal = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Step 1: 프로젝트 선택 -->
|
||||||
|
<div v-if="maintenanceStep === 'select'">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">프로젝트 선택</label>
|
||||||
|
<select class="form-select" v-model="maintenanceProjectId" @change="loadMaintenanceTasks">
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
<option v-for="p in allProjects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="isLoadingMaintenance" class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm"></div> 유지보수 업무 조회 중...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="maintenanceTasks.length === 0 && maintenanceProjectId" class="alert alert-info">
|
||||||
|
해당 주차에 완료된 유지보수 업무가 없습니다.
|
||||||
|
</div>
|
||||||
|
<div v-else-if="maintenanceTasks.length > 0">
|
||||||
|
<div class="mb-2 text-muted small">{{ form.weekStartDate }} ~ {{ form.weekEndDate }} 완료 업무</div>
|
||||||
|
<div class="list-group">
|
||||||
|
<label v-for="t in maintenanceTasks" :key="t.taskId" class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<input type="checkbox" class="form-check-input me-2 mt-1" v-model="t.selected" />
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold">{{ t.requestTitle }}</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
[{{ getTypeLabel(t.taskType) }}] {{ t.requesterName || '-' }}
|
||||||
|
<span v-if="t.resolutionContent"> → {{ truncate(t.resolutionContent, 30) }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Step 2: AI 변환 결과 -->
|
||||||
|
<div v-if="maintenanceStep === 'convert'">
|
||||||
|
<div class="mb-2 text-muted small">AI가 생성한 실적 문장 (수정 가능)</div>
|
||||||
|
<div v-for="(g, idx) in generatedTasks" :key="idx" class="mb-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">{{ getTypeLabel(g.taskType) }}</span>
|
||||||
|
<input type="text" class="form-control" v-model="g.description" />
|
||||||
|
<button class="btn btn-outline-danger" type="button" @click="generatedTasks.splice(idx, 1)">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showMaintenanceModal = false">취소</button>
|
||||||
|
<template v-if="maintenanceStep === 'select'">
|
||||||
|
<button type="button" class="btn btn-primary" @click="convertMaintenance"
|
||||||
|
:disabled="!selectedMaintenanceCount || isConverting">
|
||||||
|
<span v-if="isConverting"><span class="spinner-border spinner-border-sm me-1"></span></span>
|
||||||
|
AI 실적 변환 ({{ selectedMaintenanceCount }}건)
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="maintenanceStep = 'select'">이전</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="applyMaintenanceTasks" :disabled="generatedTasks.length === 0">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>실적 추가
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" v-if="showMaintenanceModal"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -508,6 +588,17 @@ const aiParsedResult = ref<{
|
|||||||
remarkDescription: string | null
|
remarkDescription: string | null
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
// 유지보수 불러오기 모달
|
||||||
|
const showMaintenanceModal = ref(false)
|
||||||
|
const maintenanceStep = ref<'select' | 'convert'>('select')
|
||||||
|
const maintenanceProjectId = ref('')
|
||||||
|
const maintenanceTasks = ref<any[]>([])
|
||||||
|
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({
|
const form = ref({
|
||||||
reportYear: new Date().getFullYear(),
|
reportYear: new Date().getFullYear(),
|
||||||
reportWeek: 1,
|
reportWeek: 1,
|
||||||
@@ -922,6 +1013,94 @@ function closeAiModal() {
|
|||||||
aiParsedResult.value = null
|
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<string, string> = { 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) {
|
function handleAiDrop(e: DragEvent) {
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
const files = e.dataTransfer?.files
|
const files = e.dataTransfer?.files
|
||||||
|
|||||||
351
frontend/todo/index.vue
Normal file
351
frontend/todo/index.vue
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-check2-square me-2"></i>TODO 관리</h4>
|
||||||
|
<button class="btn btn-primary" @click="openCreateModal">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>새 TODO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 필터 -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-1 text-end"><label class="col-form-label">프로젝트</label></div>
|
||||||
|
<div class="col-2">
|
||||||
|
<select class="form-select form-select-sm" v-model="filter.projectId" @change="loadTodos">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 text-end"><label class="col-form-label">담당자</label></div>
|
||||||
|
<div class="col-2">
|
||||||
|
<select class="form-select form-select-sm" v-model="filter.assigneeId" @change="loadTodos">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option v-for="e in employees" :key="e.employeeId" :value="e.employeeId">{{ e.employeeName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 text-end"><label class="col-form-label">상태</label></div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="status" id="statusAll" value="" v-model="filter.status" @change="loadTodos">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="statusAll">전체</label>
|
||||||
|
<input type="radio" class="btn-check" name="status" id="statusPending" value="PENDING" v-model="filter.status" @change="loadTodos">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="statusPending">대기</label>
|
||||||
|
<input type="radio" class="btn-check" name="status" id="statusProgress" value="IN_PROGRESS" v-model="filter.status" @change="loadTodos">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="statusProgress">진행</label>
|
||||||
|
<input type="radio" class="btn-check" name="status" id="statusCompleted" value="COMPLETED" v-model="filter.status" @change="loadTodos">
|
||||||
|
<label class="btn btn-outline-secondary btn-sm" for="statusCompleted">완료</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="myOnly" v-model="filter.myOnly" @change="loadTodos">
|
||||||
|
<label class="form-check-label" for="myOnly">내 TODO만</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 목록 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px" class="text-center">No</th>
|
||||||
|
<th>제목</th>
|
||||||
|
<th style="width: 120px">프로젝트</th>
|
||||||
|
<th style="width: 80px">담당자</th>
|
||||||
|
<th style="width: 100px">마감일</th>
|
||||||
|
<th style="width: 80px" class="text-center">상태</th>
|
||||||
|
<th style="width: 100px">등록일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="isLoading">
|
||||||
|
<td colspan="7" class="text-center py-4"><span class="spinner-border spinner-border-sm"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="todos.length === 0">
|
||||||
|
<td colspan="7" class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-inbox display-4"></i>
|
||||||
|
<p class="mt-2 mb-0">TODO가 없습니다.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else v-for="(todo, idx) in todos" :key="todo.todoId" @click="openEditModal(todo)" style="cursor: pointer">
|
||||||
|
<td class="text-center">{{ idx + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold">{{ todo.todoTitle }}</div>
|
||||||
|
<small class="text-muted" v-if="todo.meetingTitle">📋 {{ todo.meetingTitle }}</small>
|
||||||
|
</td>
|
||||||
|
<td>{{ todo.projectName || '-' }}</td>
|
||||||
|
<td>{{ todo.assigneeName || '-' }}</td>
|
||||||
|
<td :class="{ 'text-danger': isOverdue(todo) }">{{ formatDate(todo.dueDate) }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span :class="getStatusBadge(todo.status)">{{ getStatusLabel(todo.status) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(todo.createdAt) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 생성/수정 모달 -->
|
||||||
|
<div class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ isEdit ? 'TODO 수정' : '새 TODO' }}</h5>
|
||||||
|
<button type="button" class="btn-close" @click="showModal = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">제목 <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" v-model="form.todoTitle" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">내용</label>
|
||||||
|
<textarea class="form-control" v-model="form.todoContent" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">프로젝트</label>
|
||||||
|
<select class="form-select" v-model="form.projectId">
|
||||||
|
<option value="">선택 안함</option>
|
||||||
|
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">{{ p.projectName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">담당자</label>
|
||||||
|
<select class="form-select" v-model="form.assigneeId">
|
||||||
|
<option value="">미지정</option>
|
||||||
|
<option v-for="e in employees" :key="e.employeeId" :value="e.employeeId">{{ e.employeeName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">마감일</label>
|
||||||
|
<input type="date" class="form-control" v-model="form.dueDate" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">상태</label>
|
||||||
|
<select class="form-select" v-model="form.status">
|
||||||
|
<option value="PENDING">대기</option>
|
||||||
|
<option value="IN_PROGRESS">진행중</option>
|
||||||
|
<option value="COMPLETED">완료</option>
|
||||||
|
<option value="CANCELLED">취소</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button v-if="isEdit" type="button" class="btn btn-outline-danger me-auto" @click="deleteTodo">삭제</button>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showModal = false">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveTodo" :disabled="isSaving">
|
||||||
|
{{ isSaving ? '저장 중...' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" v-if="showModal"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { fetchCurrentUser } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
todoId: number
|
||||||
|
todoTitle: string
|
||||||
|
todoContent: string
|
||||||
|
projectId: number | null
|
||||||
|
projectName: string | null
|
||||||
|
meetingId: number | null
|
||||||
|
meetingTitle: string | null
|
||||||
|
assigneeId: number | null
|
||||||
|
assigneeName: string | null
|
||||||
|
dueDate: string | null
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project { projectId: number; projectName: string }
|
||||||
|
interface Employee { employeeId: number; employeeName: string }
|
||||||
|
|
||||||
|
const todos = ref<Todo[]>([])
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const employees = ref<Employee[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const editTodoId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const filter = ref({
|
||||||
|
projectId: '',
|
||||||
|
assigneeId: '',
|
||||||
|
status: '',
|
||||||
|
myOnly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
todoTitle: '',
|
||||||
|
todoContent: '',
|
||||||
|
projectId: '',
|
||||||
|
assigneeId: '',
|
||||||
|
dueDate: '',
|
||||||
|
status: 'PENDING'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
if (!user) { router.push('/login'); return }
|
||||||
|
await Promise.all([loadProjects(), loadEmployees(), loadTodos()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ projects: Project[] }>('/api/project/list')
|
||||||
|
projects.value = res.projects || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmployees() {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ employees: Employee[] }>('/api/employee/list')
|
||||||
|
employees.value = res.employees || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTodos() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ todos: Todo[] }>('/api/todo/list', {
|
||||||
|
query: {
|
||||||
|
projectId: filter.value.projectId || undefined,
|
||||||
|
assigneeId: filter.value.assigneeId || undefined,
|
||||||
|
status: filter.value.status || undefined,
|
||||||
|
myOnly: filter.value.myOnly || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
todos.value = res.todos || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { isLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
isEdit.value = false
|
||||||
|
editTodoId.value = null
|
||||||
|
form.value = { todoTitle: '', todoContent: '', projectId: '', assigneeId: '', dueDate: '', status: 'PENDING' }
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(todo: Todo) {
|
||||||
|
isEdit.value = true
|
||||||
|
editTodoId.value = todo.todoId
|
||||||
|
form.value = {
|
||||||
|
todoTitle: todo.todoTitle,
|
||||||
|
todoContent: todo.todoContent || '',
|
||||||
|
projectId: todo.projectId?.toString() || '',
|
||||||
|
assigneeId: todo.assigneeId?.toString() || '',
|
||||||
|
dueDate: todo.dueDate?.split('T')[0] || '',
|
||||||
|
status: todo.status
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTodo() {
|
||||||
|
if (!form.value.todoTitle.trim()) {
|
||||||
|
alert('제목을 입력해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value && editTodoId.value) {
|
||||||
|
await $fetch(`/api/todo/${editTodoId.value}/update`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
todoTitle: form.value.todoTitle,
|
||||||
|
todoContent: form.value.todoContent || null,
|
||||||
|
projectId: form.value.projectId ? Number(form.value.projectId) : null,
|
||||||
|
assigneeId: form.value.assigneeId ? Number(form.value.assigneeId) : null,
|
||||||
|
dueDate: form.value.dueDate || null,
|
||||||
|
status: form.value.status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/todo/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
todoTitle: form.value.todoTitle,
|
||||||
|
todoContent: form.value.todoContent || null,
|
||||||
|
projectId: form.value.projectId ? Number(form.value.projectId) : null,
|
||||||
|
assigneeId: form.value.assigneeId ? Number(form.value.assigneeId) : null,
|
||||||
|
dueDate: form.value.dueDate || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
showModal.value = false
|
||||||
|
await loadTodos()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.data?.message || '저장에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTodo() {
|
||||||
|
if (!editTodoId.value) return
|
||||||
|
if (!confirm('정말 삭제하시겠습니까?')) return
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/todo/${editTodoId.value}/delete`, { method: 'DELETE' })
|
||||||
|
showModal.value = false
|
||||||
|
await loadTodos()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.data?.message || '삭제에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status: string) {
|
||||||
|
const badges: Record<string, string> = {
|
||||||
|
PENDING: 'badge bg-secondary',
|
||||||
|
IN_PROGRESS: 'badge bg-primary',
|
||||||
|
COMPLETED: 'badge bg-success',
|
||||||
|
CANCELLED: 'badge bg-dark'
|
||||||
|
}
|
||||||
|
return badges[status] || 'badge bg-secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
PENDING: '대기',
|
||||||
|
IN_PROGRESS: '진행',
|
||||||
|
COMPLETED: '완료',
|
||||||
|
CANCELLED: '취소'
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverdue(todo: Todo) {
|
||||||
|
if (!todo.dueDate || todo.status === 'COMPLETED') return false
|
||||||
|
return new Date(todo.dueDate) < new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string | null) {
|
||||||
|
if (!d) return '-'
|
||||||
|
return d.split('T')[0]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal.show { background: rgba(0, 0, 0, 0.5); }
|
||||||
|
</style>
|
||||||
@@ -59,6 +59,10 @@ export default defineNuxtConfig({
|
|||||||
dbUser: process.env.DB_USER || 'postgres',
|
dbUser: process.env.DB_USER || 'postgres',
|
||||||
dbPassword: process.env.DB_PASSWORD || '',
|
dbPassword: process.env.DB_PASSWORD || '',
|
||||||
sessionSecret: process.env.SESSION_SECRET || 'dev-secret-key',
|
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: {
|
public: {
|
||||||
appName: '주간업무보고'
|
appName: '주간업무보고'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user